1495 lines
31 KiB
Go
1495 lines
31 KiB
Go
// backend\rating.go
|
||
|
||
package main
|
||
|
||
import (
|
||
"math"
|
||
"sort"
|
||
"strings"
|
||
)
|
||
|
||
type aiRatingMeta struct {
|
||
Score float64 `json:"score"`
|
||
Stars int `json:"stars"`
|
||
Segments int `json:"segments"`
|
||
SegmentsPerMinute float64 `json:"segmentsPerMinute"`
|
||
FlaggedSeconds float64 `json:"flaggedSeconds"`
|
||
WeightedFlaggedSeconds float64 `json:"weightedFlaggedSeconds"`
|
||
CoverageRatio float64 `json:"coverageRatio"`
|
||
WeightedCoverageRatio float64 `json:"weightedCoverageRatio"`
|
||
LongestSegmentSeconds float64 `json:"longestSegmentSeconds"`
|
||
AvgConfidence float64 `json:"avgConfidence"`
|
||
}
|
||
|
||
const (
|
||
// Normales Zusammenziehen sehr kurzer Pausen.
|
||
ratingMaxSilentGapSec = 4.5
|
||
|
||
// Größere Lücken dürfen überbrückt werden,
|
||
// wenn beide Seiten stark genug / thematisch ähnlich sind.
|
||
ratingBridgeStrongGapSec = 12.0
|
||
|
||
ratingMinSegmentDurationSec = 2.5
|
||
)
|
||
|
||
func ratingClamp01(v float64) float64 {
|
||
if v < 0 {
|
||
return 0
|
||
}
|
||
if v > 1 {
|
||
return 1
|
||
}
|
||
return v
|
||
}
|
||
|
||
func ratingRound(v float64, places int) float64 {
|
||
p := math.Pow(10, float64(places))
|
||
return math.Round(v*p) / p
|
||
}
|
||
|
||
func ratingSmoothStep(v float64) float64 {
|
||
v = ratingClamp01(v)
|
||
return v * v * (3 - 2*v)
|
||
}
|
||
|
||
func ratingSoftCap(value, knee float64) float64 {
|
||
if value <= 0 || knee <= 0 {
|
||
return 0
|
||
}
|
||
return value / (value + knee)
|
||
}
|
||
|
||
func ratingEffectiveDurationSeconds(seconds float64) float64 {
|
||
if seconds <= 0 {
|
||
return 0
|
||
}
|
||
|
||
// Lange Segmente zählen, aber nicht linear unendlich stark.
|
||
const kneeSeconds = 26.0
|
||
return kneeSeconds * math.Log1p(seconds/kneeSeconds)
|
||
}
|
||
|
||
func normalizeSegmentLabel(label string) string {
|
||
label = strings.ToLower(strings.TrimSpace(label))
|
||
|
||
label = strings.TrimPrefix(label, "detector:")
|
||
label = strings.TrimPrefix(label, "body:")
|
||
label = strings.TrimPrefix(label, "object:")
|
||
label = strings.TrimPrefix(label, "clothing:")
|
||
label = strings.TrimPrefix(label, "position:")
|
||
label = strings.TrimPrefix(label, "person:")
|
||
|
||
return strings.TrimSpace(label)
|
||
}
|
||
|
||
func isPersonSegmentLabel(label string) bool {
|
||
label = strings.ToLower(strings.TrimSpace(label))
|
||
|
||
label = strings.TrimPrefix(label, "detector:")
|
||
label = strings.TrimPrefix(label, "body:")
|
||
label = strings.TrimPrefix(label, "object:")
|
||
label = strings.TrimPrefix(label, "clothing:")
|
||
label = strings.TrimPrefix(label, "position:")
|
||
label = strings.TrimPrefix(label, "person:")
|
||
|
||
switch label {
|
||
case "person",
|
||
"person_unknown",
|
||
"person_male",
|
||
"person_female",
|
||
"male_person",
|
||
"female_person",
|
||
"people_male",
|
||
"people_female":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func isKnownPositionLabel(label string) bool {
|
||
label = strings.ToLower(strings.TrimSpace(label))
|
||
|
||
switch label {
|
||
case "unknown",
|
||
"missionary",
|
||
"doggy",
|
||
"doggystyle",
|
||
"cowgirl",
|
||
"reverse_cowgirl",
|
||
"cunnilingus",
|
||
"prone_bone",
|
||
"standing",
|
||
"standing_doggy",
|
||
"spooning",
|
||
"facesitting",
|
||
"handjob",
|
||
"blowjob",
|
||
"boobjob",
|
||
"toy_play",
|
||
"fingering",
|
||
"69":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func positionSeverityWeight(label string) float64 {
|
||
label = strings.ToLower(strings.TrimSpace(label))
|
||
|
||
switch label {
|
||
case "doggy", "doggystyle", "standing_doggy":
|
||
return 1.00
|
||
case "cowgirl", "reverse_cowgirl":
|
||
return 0.98
|
||
case "missionary", "prone_bone":
|
||
return 0.95
|
||
case "blowjob", "cunnilingus", "69", "facesitting", "boobjob":
|
||
return 0.94
|
||
case "toy_play":
|
||
return 0.88
|
||
case "handjob", "fingering":
|
||
return 0.84
|
||
case "spooning":
|
||
return 0.78
|
||
case "standing":
|
||
return 0.42
|
||
default:
|
||
return 0.00
|
||
}
|
||
}
|
||
|
||
func bodyPartSeverityWeight(label string) float64 {
|
||
label = strings.ToLower(strings.TrimSpace(label))
|
||
|
||
switch label {
|
||
case "pussy", "vulva":
|
||
return 1.00
|
||
case "penis":
|
||
return 0.95
|
||
case "anus":
|
||
return 0.90
|
||
case "breasts":
|
||
return 0.80
|
||
case "ass", "buttocks":
|
||
return 0.66
|
||
case "tongue":
|
||
return 0.46
|
||
default:
|
||
return 0.00
|
||
}
|
||
}
|
||
|
||
func objectSeverityWeight(label string) float64 {
|
||
label = strings.ToLower(strings.TrimSpace(label))
|
||
|
||
switch label {
|
||
case "dildo", "vibrator", "strapon", "buttplug":
|
||
return 0.86
|
||
case "handcuffs", "blindfold", "collar":
|
||
return 0.58
|
||
case "shower":
|
||
return 0.42
|
||
case "towel":
|
||
return 0.28
|
||
default:
|
||
return 0.00
|
||
}
|
||
}
|
||
|
||
func clothingSeverityWeight(label string) float64 {
|
||
label = strings.ToLower(strings.TrimSpace(label))
|
||
|
||
switch label {
|
||
case "lingerie":
|
||
return 0.50
|
||
case "panties", "bra":
|
||
return 0.44
|
||
case "bikini":
|
||
return 0.30
|
||
case "stockings", "heels":
|
||
return 0.28
|
||
case "skirt", "dress", "hotpants", "croptop":
|
||
return 0.22
|
||
default:
|
||
return 0.00
|
||
}
|
||
}
|
||
|
||
func personContextWeight(label string) float64 {
|
||
if !isPersonSegmentLabel(label) {
|
||
return 0
|
||
}
|
||
|
||
// Personen sind Kontext, aber nie alleiniger Rating-Treiber.
|
||
return 0.18
|
||
}
|
||
|
||
func detectorSeverityWeight(label string) float64 {
|
||
label = strings.ToLower(strings.TrimSpace(label))
|
||
|
||
if isPersonSegmentLabel(label) {
|
||
return personContextWeight(label)
|
||
}
|
||
|
||
if w := bodyPartSeverityWeight(label); w > 0 {
|
||
return w
|
||
}
|
||
if w := objectSeverityWeight(label); w > 0 {
|
||
return w
|
||
}
|
||
if w := clothingSeverityWeight(label); w > 0 {
|
||
return w
|
||
}
|
||
if isKnownPositionLabel(label) {
|
||
return positionSeverityWeight(label)
|
||
}
|
||
|
||
return 0.00
|
||
}
|
||
|
||
type ratingSignalSet struct {
|
||
Position float64
|
||
Body float64
|
||
Object float64
|
||
Clothing float64
|
||
Person float64
|
||
|
||
HasPosition bool
|
||
HasBody bool
|
||
HasObject bool
|
||
HasClothing bool
|
||
HasPerson bool
|
||
}
|
||
|
||
func normalizeRatingSignalLabel(label string) string {
|
||
label = strings.ToLower(strings.TrimSpace(label))
|
||
if label == "" || label == "unknown" {
|
||
return ""
|
||
}
|
||
|
||
if strings.HasPrefix(label, "combo:") {
|
||
return label
|
||
}
|
||
|
||
if strings.HasPrefix(label, "detector:") {
|
||
return normalizeRatingSignalLabel(strings.TrimPrefix(label, "detector:"))
|
||
}
|
||
|
||
if strings.HasPrefix(label, "position:") {
|
||
raw := strings.TrimPrefix(label, "position:")
|
||
if isKnownPositionLabel(raw) && positionSeverityWeight(raw) > 0 {
|
||
return "position:" + raw
|
||
}
|
||
return ""
|
||
}
|
||
|
||
if strings.HasPrefix(label, "body:") {
|
||
raw := strings.TrimPrefix(label, "body:")
|
||
if bodyPartSeverityWeight(raw) > 0 {
|
||
return "body:" + raw
|
||
}
|
||
return ""
|
||
}
|
||
|
||
if strings.HasPrefix(label, "object:") {
|
||
raw := strings.TrimPrefix(label, "object:")
|
||
if objectSeverityWeight(raw) > 0 {
|
||
return "object:" + raw
|
||
}
|
||
return ""
|
||
}
|
||
|
||
if strings.HasPrefix(label, "clothing:") {
|
||
raw := strings.TrimPrefix(label, "clothing:")
|
||
if clothingSeverityWeight(raw) > 0 {
|
||
return "clothing:" + raw
|
||
}
|
||
return ""
|
||
}
|
||
|
||
if strings.HasPrefix(label, "person:") {
|
||
raw := strings.TrimPrefix(label, "person:")
|
||
if isPersonSegmentLabel(raw) {
|
||
return "person:" + normalizeSegmentLabel(raw)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
raw := normalizeSegmentLabel(label)
|
||
if raw == "" || raw == "unknown" {
|
||
return ""
|
||
}
|
||
|
||
if isPersonSegmentLabel(raw) {
|
||
return "person:" + raw
|
||
}
|
||
if isKnownPositionLabel(raw) && positionSeverityWeight(raw) > 0 {
|
||
return "position:" + raw
|
||
}
|
||
if bodyPartSeverityWeight(raw) > 0 {
|
||
return "body:" + raw
|
||
}
|
||
if objectSeverityWeight(raw) > 0 {
|
||
return "object:" + raw
|
||
}
|
||
if clothingSeverityWeight(raw) > 0 {
|
||
return "clothing:" + raw
|
||
}
|
||
|
||
return ""
|
||
}
|
||
|
||
func ratingSignalSetAddLabel(set *ratingSignalSet, label string) {
|
||
label = normalizeRatingSignalLabel(label)
|
||
if label == "" {
|
||
return
|
||
}
|
||
|
||
switch {
|
||
case strings.HasPrefix(label, "position:"):
|
||
raw := strings.TrimPrefix(label, "position:")
|
||
w := positionSeverityWeight(raw)
|
||
if w > set.Position {
|
||
set.Position = w
|
||
}
|
||
if w > 0 {
|
||
set.HasPosition = true
|
||
}
|
||
|
||
case strings.HasPrefix(label, "body:"):
|
||
raw := strings.TrimPrefix(label, "body:")
|
||
w := bodyPartSeverityWeight(raw)
|
||
if w > set.Body {
|
||
set.Body = w
|
||
}
|
||
if w > 0 {
|
||
set.HasBody = true
|
||
}
|
||
|
||
case strings.HasPrefix(label, "object:"):
|
||
raw := strings.TrimPrefix(label, "object:")
|
||
w := objectSeverityWeight(raw)
|
||
if w > set.Object {
|
||
set.Object = w
|
||
}
|
||
if w > 0 {
|
||
set.HasObject = true
|
||
}
|
||
|
||
case strings.HasPrefix(label, "clothing:"):
|
||
raw := strings.TrimPrefix(label, "clothing:")
|
||
w := clothingSeverityWeight(raw)
|
||
if w > set.Clothing {
|
||
set.Clothing = w
|
||
}
|
||
if w > 0 {
|
||
set.HasClothing = true
|
||
}
|
||
|
||
case strings.HasPrefix(label, "person:"):
|
||
raw := strings.TrimPrefix(label, "person:")
|
||
w := personContextWeight(raw)
|
||
if w > set.Person {
|
||
set.Person = w
|
||
}
|
||
if w > 0 {
|
||
set.HasPerson = true
|
||
}
|
||
}
|
||
}
|
||
|
||
func ratingSignalSetFromLabel(label string) ratingSignalSet {
|
||
label = strings.ToLower(strings.TrimSpace(label))
|
||
|
||
var set ratingSignalSet
|
||
|
||
if strings.HasPrefix(label, "combo:") {
|
||
raw := strings.TrimPrefix(label, "combo:")
|
||
for _, part := range strings.Split(raw, "+") {
|
||
ratingSignalSetAddLabel(&set, part)
|
||
}
|
||
return set
|
||
}
|
||
|
||
ratingSignalSetAddLabel(&set, label)
|
||
return set
|
||
}
|
||
|
||
func ratingSignalSetHasInterestingContent(set ratingSignalSet) bool {
|
||
return set.HasPosition || set.HasBody || set.HasObject || set.HasClothing
|
||
}
|
||
|
||
func contextualSegmentSeverityWeight(label string) float64 {
|
||
set := ratingSignalSetFromLabel(label)
|
||
|
||
if !ratingSignalSetHasInterestingContent(set) {
|
||
return 0
|
||
}
|
||
|
||
var score float64
|
||
|
||
if set.HasPosition {
|
||
// Priorität: Position > Kleidung > Objekte (Kombination erhöht den Score).
|
||
score =
|
||
0.66*set.Position +
|
||
0.15*set.Body +
|
||
0.12*set.Clothing +
|
||
0.05*set.Object +
|
||
0.02*set.Person
|
||
|
||
if set.HasBody {
|
||
score += 0.055
|
||
}
|
||
if set.HasClothing {
|
||
score += 0.030
|
||
}
|
||
if set.HasObject {
|
||
score += 0.020
|
||
}
|
||
if set.HasPerson {
|
||
score += 0.015
|
||
}
|
||
|
||
// Standing ohne Kontext bleibt schwach.
|
||
if set.Position < 0.60 && !set.HasBody && !set.HasObject && !set.HasClothing {
|
||
score = math.Min(score, 0.42)
|
||
}
|
||
|
||
return ratingClamp01(score)
|
||
}
|
||
|
||
score =
|
||
0.54*set.Body +
|
||
0.28*set.Clothing +
|
||
0.16*set.Object +
|
||
0.02*set.Person
|
||
|
||
if set.HasBody && set.HasClothing {
|
||
score += 0.05
|
||
}
|
||
if set.HasBody && set.HasObject {
|
||
score += 0.04
|
||
}
|
||
if set.HasClothing && set.HasObject {
|
||
score += 0.03
|
||
}
|
||
if set.HasPerson && (set.HasBody || set.HasObject || set.HasClothing) {
|
||
score += 0.015
|
||
}
|
||
|
||
if set.HasClothing && !set.HasBody && !set.HasObject {
|
||
score = math.Min(score, 0.38)
|
||
}
|
||
|
||
score = math.Min(score, 0.72)
|
||
|
||
return ratingClamp01(score)
|
||
}
|
||
|
||
func comboSegmentSeverityWeight(label string) float64 {
|
||
set := ratingSignalSetFromLabel(label)
|
||
return contextualSegmentSeverityWeightFromSet(set)
|
||
}
|
||
|
||
func contextualSegmentSeverityWeightFromSet(set ratingSignalSet) float64 {
|
||
if !ratingSignalSetHasInterestingContent(set) {
|
||
return 0
|
||
}
|
||
|
||
var labelParts []string
|
||
|
||
if set.HasPosition {
|
||
labelParts = append(labelParts, "position:x")
|
||
}
|
||
if set.HasBody {
|
||
labelParts = append(labelParts, "body:x")
|
||
}
|
||
if set.HasObject {
|
||
labelParts = append(labelParts, "object:x")
|
||
}
|
||
if set.HasClothing {
|
||
labelParts = append(labelParts, "clothing:x")
|
||
}
|
||
if set.HasPerson {
|
||
labelParts = append(labelParts, "person:x")
|
||
}
|
||
|
||
_ = labelParts
|
||
|
||
var score float64
|
||
|
||
if set.HasPosition {
|
||
score =
|
||
0.66*set.Position +
|
||
0.15*set.Body +
|
||
0.12*set.Clothing +
|
||
0.05*set.Object +
|
||
0.02*set.Person
|
||
|
||
if set.HasBody {
|
||
score += 0.055
|
||
}
|
||
if set.HasClothing {
|
||
score += 0.030
|
||
}
|
||
if set.HasObject {
|
||
score += 0.020
|
||
}
|
||
if set.HasPerson {
|
||
score += 0.015
|
||
}
|
||
|
||
if set.Position < 0.60 && !set.HasBody && !set.HasObject && !set.HasClothing {
|
||
score = math.Min(score, 0.42)
|
||
}
|
||
|
||
return ratingClamp01(score)
|
||
}
|
||
|
||
score =
|
||
0.54*set.Body +
|
||
0.28*set.Clothing +
|
||
0.16*set.Object +
|
||
0.02*set.Person
|
||
|
||
if set.HasBody && set.HasClothing {
|
||
score += 0.05
|
||
}
|
||
if set.HasBody && set.HasObject {
|
||
score += 0.04
|
||
}
|
||
if set.HasClothing && set.HasObject {
|
||
score += 0.03
|
||
}
|
||
if set.HasPerson && (set.HasBody || set.HasObject || set.HasClothing) {
|
||
score += 0.015
|
||
}
|
||
|
||
if set.HasClothing && !set.HasBody && !set.HasObject {
|
||
score = math.Min(score, 0.38)
|
||
}
|
||
|
||
score = math.Min(score, 0.72)
|
||
|
||
return ratingClamp01(score)
|
||
}
|
||
|
||
func segmentSeverityWeight(label string) float64 {
|
||
label = strings.ToLower(strings.TrimSpace(label))
|
||
if label == "" {
|
||
return 0
|
||
}
|
||
|
||
if strings.HasPrefix(label, "combo:") {
|
||
return comboSegmentSeverityWeight(label)
|
||
}
|
||
|
||
normalized := normalizeRatingSignalLabel(label)
|
||
if normalized == "" {
|
||
return 0
|
||
}
|
||
|
||
set := ratingSignalSetFromLabel(normalized)
|
||
sev := contextualSegmentSeverityWeightFromSet(set)
|
||
if sev > 0 {
|
||
return sev
|
||
}
|
||
|
||
raw := normalizeSegmentLabel(normalized)
|
||
if raw == "" {
|
||
return 0
|
||
}
|
||
|
||
return detectorSeverityWeight(raw)
|
||
}
|
||
|
||
func ratingSegmentSeverity(label string) float64 {
|
||
return segmentSeverityWeight(label)
|
||
}
|
||
|
||
func ratingSignalPriority(label string) int {
|
||
label = normalizeRatingSignalLabel(label)
|
||
|
||
switch {
|
||
case strings.HasPrefix(label, "position:"):
|
||
return 100
|
||
case strings.HasPrefix(label, "body:"):
|
||
return 80
|
||
case strings.HasPrefix(label, "object:"):
|
||
return 60
|
||
case strings.HasPrefix(label, "clothing:"):
|
||
return 40
|
||
case strings.HasPrefix(label, "person:"):
|
||
return 20
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
func buildRatingComboLabel(labels map[string]bool) string {
|
||
if len(labels) == 0 {
|
||
return ""
|
||
}
|
||
|
||
parts := make([]string, 0, len(labels))
|
||
set := ratingSignalSet{}
|
||
|
||
for label := range labels {
|
||
normalized := normalizeRatingSignalLabel(label)
|
||
if normalized == "" {
|
||
continue
|
||
}
|
||
|
||
ratingSignalSetAddLabel(&set, normalized)
|
||
parts = append(parts, normalized)
|
||
}
|
||
|
||
if !ratingSignalSetHasInterestingContent(set) {
|
||
return ""
|
||
}
|
||
|
||
sort.SliceStable(parts, func(i, j int) bool {
|
||
pi := ratingSignalPriority(parts[i])
|
||
pj := ratingSignalPriority(parts[j])
|
||
|
||
if pi != pj {
|
||
return pi > pj
|
||
}
|
||
|
||
wi := ratingSegmentSeverity(parts[i])
|
||
wj := ratingSegmentSeverity(parts[j])
|
||
|
||
if wi != wj {
|
||
return wi > wj
|
||
}
|
||
|
||
return parts[i] < parts[j]
|
||
})
|
||
|
||
deduped := make([]string, 0, len(parts))
|
||
seen := map[string]bool{}
|
||
|
||
for _, part := range parts {
|
||
if part == "" || seen[part] {
|
||
continue
|
||
}
|
||
seen[part] = true
|
||
deduped = append(deduped, part)
|
||
}
|
||
|
||
if len(deduped) == 0 {
|
||
return ""
|
||
}
|
||
|
||
if len(deduped) == 1 {
|
||
// Person alleine wird oben schon verhindert.
|
||
return deduped[0]
|
||
}
|
||
|
||
return "combo:" + strings.Join(deduped, "+")
|
||
}
|
||
|
||
func addRatingLabelsFromSegment(labels map[string]bool, label string) {
|
||
label = strings.ToLower(strings.TrimSpace(label))
|
||
if label == "" {
|
||
return
|
||
}
|
||
|
||
if strings.HasPrefix(label, "combo:") {
|
||
raw := strings.TrimPrefix(label, "combo:")
|
||
for _, part := range strings.Split(raw, "+") {
|
||
addRatingLabelsFromSegment(labels, part)
|
||
}
|
||
return
|
||
}
|
||
|
||
normalized := normalizeRatingSignalLabel(label)
|
||
if normalized == "" {
|
||
return
|
||
}
|
||
|
||
labels[normalized] = true
|
||
}
|
||
|
||
func ratingSignalSetFromLabels(labels map[string]bool) ratingSignalSet {
|
||
var set ratingSignalSet
|
||
|
||
for label := range labels {
|
||
ratingSignalSetAddLabel(&set, label)
|
||
}
|
||
|
||
return set
|
||
}
|
||
|
||
func ratingBridgeStrengthFromSet(set ratingSignalSet) float64 {
|
||
strength := 0.0
|
||
|
||
if set.HasPosition && set.Position > strength {
|
||
strength = set.Position
|
||
}
|
||
if set.HasBody && set.Body > strength {
|
||
strength = set.Body
|
||
}
|
||
if set.HasObject && set.Object > strength {
|
||
strength = set.Object
|
||
}
|
||
if set.HasClothing && set.Clothing > strength {
|
||
strength = set.Clothing
|
||
}
|
||
|
||
return strength
|
||
}
|
||
|
||
func ratingPositionSetFromLabels(labels map[string]bool) map[string]bool {
|
||
out := map[string]bool{}
|
||
|
||
for label := range labels {
|
||
normalized := normalizeRatingSignalLabel(label)
|
||
if strings.HasPrefix(normalized, "position:") {
|
||
out[normalized] = true
|
||
}
|
||
}
|
||
|
||
return out
|
||
}
|
||
|
||
func ratingPositionSetsConflict(a map[string]bool, b map[string]bool) bool {
|
||
ap := ratingPositionSetFromLabels(a)
|
||
bp := ratingPositionSetFromLabels(b)
|
||
|
||
if len(ap) == 0 || len(bp) == 0 {
|
||
return false
|
||
}
|
||
|
||
for label := range ap {
|
||
if bp[label] {
|
||
return false
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
func ratingLabelsBridgeCompatible(a map[string]bool, b map[string]bool) bool {
|
||
if ratingPositionSetsConflict(a, b) {
|
||
return false
|
||
}
|
||
|
||
for la := range a {
|
||
na := normalizeRatingSignalLabel(la)
|
||
if na == "" {
|
||
continue
|
||
}
|
||
|
||
for lb := range b {
|
||
nb := normalizeRatingSignalLabel(lb)
|
||
if nb == "" {
|
||
continue
|
||
}
|
||
|
||
if na == nb {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
|
||
as := ratingSignalSetFromLabels(a)
|
||
bs := ratingSignalSetFromLabels(b)
|
||
|
||
// Wichtig:
|
||
// Unterschiedliche Positionen NICHT pauschal verbinden.
|
||
// Vorher hat "Doggy" + "Reverse Cowgirl" als kompatibel gegolten,
|
||
// nur weil beide irgendeine Position waren.
|
||
|
||
if as.HasBody && bs.HasBody {
|
||
return true
|
||
}
|
||
if as.HasObject && bs.HasObject {
|
||
return true
|
||
}
|
||
if as.HasClothing && bs.HasClothing {
|
||
return true
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
func mergeRatingActivitySegments(
|
||
segments []aiSegmentMeta,
|
||
maxSilentGapSec float64,
|
||
minDurationSec float64,
|
||
) []aiSegmentMeta {
|
||
if len(segments) == 0 {
|
||
return nil
|
||
}
|
||
|
||
valid := make([]aiSegmentMeta, 0, len(segments))
|
||
|
||
for _, s := range segments {
|
||
if strings.TrimSpace(s.Label) == "" {
|
||
continue
|
||
}
|
||
|
||
start := s.StartSeconds
|
||
end := s.EndSeconds
|
||
|
||
if end <= start && s.DurationSeconds > 0 {
|
||
end = start + s.DurationSeconds
|
||
}
|
||
if end <= start {
|
||
continue
|
||
}
|
||
|
||
labels := map[string]bool{}
|
||
addRatingLabelsFromSegment(labels, s.Label)
|
||
|
||
if buildRatingComboLabel(labels) == "" {
|
||
continue
|
||
}
|
||
|
||
s.StartSeconds = start
|
||
s.EndSeconds = end
|
||
s.DurationSeconds = end - start
|
||
|
||
if ratingSegmentSeverity(s.Label) <= 0 {
|
||
continue
|
||
}
|
||
|
||
valid = append(valid, s)
|
||
}
|
||
|
||
if len(valid) == 0 {
|
||
return nil
|
||
}
|
||
|
||
sort.SliceStable(valid, func(i, j int) bool {
|
||
if valid[i].StartSeconds != valid[j].StartSeconds {
|
||
return valid[i].StartSeconds < valid[j].StartSeconds
|
||
}
|
||
if valid[i].EndSeconds != valid[j].EndSeconds {
|
||
return valid[i].EndSeconds < valid[j].EndSeconds
|
||
}
|
||
return valid[i].Label < valid[j].Label
|
||
})
|
||
|
||
type segmentBounds struct {
|
||
start float64
|
||
end float64
|
||
ok bool
|
||
}
|
||
|
||
type activityBlock struct {
|
||
start float64
|
||
end float64
|
||
scoreSum float64
|
||
scoreWeight float64
|
||
labels map[string]bool
|
||
positionWeights map[string]float64
|
||
positionBounds map[string]segmentBounds
|
||
marker float64
|
||
markerWeight float64
|
||
}
|
||
|
||
var addRatingPositionSignal func(
|
||
weights map[string]float64,
|
||
bounds map[string]segmentBounds,
|
||
label string,
|
||
weight float64,
|
||
start float64,
|
||
end float64,
|
||
)
|
||
|
||
addRatingPositionSignal = func(
|
||
weights map[string]float64,
|
||
bounds map[string]segmentBounds,
|
||
label string,
|
||
weight float64,
|
||
start float64,
|
||
end float64,
|
||
) {
|
||
label = strings.ToLower(strings.TrimSpace(label))
|
||
if label == "" {
|
||
return
|
||
}
|
||
|
||
if strings.HasPrefix(label, "combo:") {
|
||
raw := strings.TrimPrefix(label, "combo:")
|
||
for _, part := range strings.Split(raw, "+") {
|
||
addRatingPositionSignal(weights, bounds, part, weight, start, end)
|
||
}
|
||
return
|
||
}
|
||
|
||
normalized := normalizeRatingSignalLabel(label)
|
||
if !strings.HasPrefix(normalized, "position:") {
|
||
return
|
||
}
|
||
|
||
if end < start {
|
||
end = start
|
||
}
|
||
|
||
weights[normalized] += weight
|
||
|
||
b := bounds[normalized]
|
||
if !b.ok {
|
||
b = segmentBounds{
|
||
start: start,
|
||
end: end,
|
||
ok: true,
|
||
}
|
||
} else {
|
||
if start < b.start {
|
||
b.start = start
|
||
}
|
||
if end > b.end {
|
||
b.end = end
|
||
}
|
||
}
|
||
|
||
bounds[normalized] = b
|
||
}
|
||
|
||
effectiveRatingLabelsForBlock := func(
|
||
labels map[string]bool,
|
||
positionWeights map[string]float64,
|
||
) (map[string]bool, string) {
|
||
out := map[string]bool{}
|
||
|
||
bestPosition := ""
|
||
bestWeight := -1.0
|
||
|
||
for label := range labels {
|
||
normalized := normalizeRatingSignalLabel(label)
|
||
if normalized == "" {
|
||
continue
|
||
}
|
||
|
||
if strings.HasPrefix(normalized, "position:") {
|
||
w := positionWeights[normalized]
|
||
if w <= 0 {
|
||
w = ratingSegmentSeverity(normalized)
|
||
}
|
||
|
||
if w > bestWeight {
|
||
bestWeight = w
|
||
bestPosition = normalized
|
||
}
|
||
|
||
continue
|
||
}
|
||
|
||
out[normalized] = true
|
||
}
|
||
|
||
if bestPosition != "" {
|
||
out[bestPosition] = true
|
||
}
|
||
|
||
return out, bestPosition
|
||
}
|
||
|
||
segmentMarker := func(s aiSegmentMeta) float64 {
|
||
if s.PreviewSeconds > 0 {
|
||
return s.PreviewSeconds
|
||
}
|
||
if s.MarkerSeconds > 0 {
|
||
return s.MarkerSeconds
|
||
}
|
||
if s.EndSeconds > s.StartSeconds {
|
||
return (s.StartSeconds + s.EndSeconds) / 2
|
||
}
|
||
return s.StartSeconds
|
||
}
|
||
|
||
segmentMarkerWeight := func(s aiSegmentMeta) float64 {
|
||
conf := ratingClamp01(s.Score)
|
||
if conf <= 0 {
|
||
conf = 0.50
|
||
}
|
||
|
||
sev := ratingSegmentSeverity(s.Label)
|
||
if sev <= 0 {
|
||
sev = 0.50
|
||
}
|
||
|
||
return conf * sev
|
||
}
|
||
|
||
newBlock := func(s aiSegmentMeta) activityBlock {
|
||
labels := map[string]bool{}
|
||
addRatingLabelsFromSegment(labels, s.Label)
|
||
|
||
dur := s.EndSeconds - s.StartSeconds
|
||
conf := ratingClamp01(s.Score)
|
||
if conf <= 0 {
|
||
conf = 0.50
|
||
}
|
||
|
||
positionWeights := map[string]float64{}
|
||
positionBounds := map[string]segmentBounds{}
|
||
|
||
addRatingPositionSignal(
|
||
positionWeights,
|
||
positionBounds,
|
||
s.Label,
|
||
conf*math.Max(1, dur),
|
||
s.StartSeconds,
|
||
s.EndSeconds,
|
||
)
|
||
|
||
marker := segmentMarker(s)
|
||
markerWeight := segmentMarkerWeight(s)
|
||
|
||
return activityBlock{
|
||
start: s.StartSeconds,
|
||
end: s.EndSeconds,
|
||
scoreSum: conf * dur,
|
||
scoreWeight: dur,
|
||
labels: labels,
|
||
positionWeights: positionWeights,
|
||
positionBounds: positionBounds,
|
||
marker: marker,
|
||
markerWeight: markerWeight,
|
||
}
|
||
}
|
||
|
||
finishBlock := func(b activityBlock) (aiSegmentMeta, bool) {
|
||
dur := b.end - b.start
|
||
if dur <= 0 {
|
||
return aiSegmentMeta{}, false
|
||
}
|
||
|
||
effectiveLabels, bestPosition := effectiveRatingLabelsForBlock(b.labels, b.positionWeights)
|
||
label := buildRatingComboLabel(effectiveLabels)
|
||
if label == "" {
|
||
return aiSegmentMeta{}, false
|
||
}
|
||
|
||
sev := ratingSegmentSeverity(label)
|
||
|
||
start := b.start
|
||
|
||
// Wenn der finale Block eine Position trägt, soll die Anzeige/der Klick
|
||
// beim ersten echten Auftreten dieser Position starten — nicht schon bei
|
||
// früheren Body-/Object-Signalen aus demselben Aktivitätsblock.
|
||
if bestPosition != "" {
|
||
if bounds, ok := b.positionBounds[bestPosition]; ok && bounds.ok {
|
||
if bounds.start > start {
|
||
start = bounds.start
|
||
}
|
||
}
|
||
}
|
||
|
||
dur = b.end - start
|
||
if dur <= 0 {
|
||
return aiSegmentMeta{}, false
|
||
}
|
||
|
||
// Kurze Segmente dürfen bleiben, wenn sie stark genug sind.
|
||
if dur < minDurationSec && sev < 0.72 {
|
||
return aiSegmentMeta{}, false
|
||
}
|
||
|
||
score := 0.0
|
||
if b.scoreWeight > 0 {
|
||
score = b.scoreSum / b.scoreWeight
|
||
}
|
||
if score <= 0 {
|
||
score = 0.50
|
||
}
|
||
|
||
return aiSegmentMeta{
|
||
Label: label,
|
||
Score: ratingClamp01(score),
|
||
StartSeconds: start,
|
||
EndSeconds: b.end,
|
||
DurationSeconds: dur,
|
||
AutoSelected: true,
|
||
Position: segmentPositionFromAnalyzeLabel(label),
|
||
Tags: segmentTagsFromAnalyzeLabel(label),
|
||
MarkerSeconds: b.marker,
|
||
PreviewSeconds: b.marker,
|
||
}, true
|
||
}
|
||
|
||
out := make([]aiSegmentMeta, 0, len(valid))
|
||
cur := newBlock(valid[0])
|
||
|
||
for i := 1; i < len(valid); i++ {
|
||
n := valid[i]
|
||
|
||
gap := n.StartSeconds - cur.end
|
||
|
||
shouldBridge := gap <= maxSilentGapSec
|
||
|
||
if !shouldBridge && gap <= ratingBridgeStrongGapSec {
|
||
nextLabels := map[string]bool{}
|
||
addRatingLabelsFromSegment(nextLabels, n.Label)
|
||
|
||
curSet := ratingSignalSetFromLabels(cur.labels)
|
||
nextSet := ratingSignalSetFromLabels(nextLabels)
|
||
|
||
curBridgeStrength := ratingBridgeStrengthFromSet(curSet)
|
||
nextBridgeStrength := ratingBridgeStrengthFromSet(nextSet)
|
||
|
||
curDur := cur.end - cur.start
|
||
nextDur := n.EndSeconds - n.StartSeconds
|
||
|
||
shouldBridge =
|
||
ratingLabelsBridgeCompatible(cur.labels, nextLabels) &&
|
||
curBridgeStrength >= 0.65 &&
|
||
nextBridgeStrength >= 0.65 &&
|
||
(curDur >= 8 || nextDur >= 8)
|
||
}
|
||
|
||
if !shouldBridge {
|
||
if finished, ok := finishBlock(cur); ok {
|
||
out = append(out, finished)
|
||
}
|
||
|
||
cur = newBlock(n)
|
||
continue
|
||
}
|
||
|
||
if n.StartSeconds < cur.start {
|
||
cur.start = n.StartSeconds
|
||
}
|
||
if n.EndSeconds > cur.end {
|
||
cur.end = n.EndSeconds
|
||
}
|
||
|
||
dur := n.EndSeconds - n.StartSeconds
|
||
if dur > 0 {
|
||
conf := ratingClamp01(n.Score)
|
||
if conf <= 0 {
|
||
conf = 0.50
|
||
}
|
||
|
||
cur.scoreSum += conf * dur
|
||
cur.scoreWeight += dur
|
||
|
||
addRatingPositionSignal(
|
||
cur.positionWeights,
|
||
cur.positionBounds,
|
||
n.Label,
|
||
conf*math.Max(1, dur),
|
||
n.StartSeconds,
|
||
n.EndSeconds,
|
||
)
|
||
}
|
||
|
||
nextMarkerWeight := segmentMarkerWeight(n)
|
||
if nextMarkerWeight > cur.markerWeight {
|
||
cur.markerWeight = nextMarkerWeight
|
||
cur.marker = segmentMarker(n)
|
||
}
|
||
|
||
addRatingLabelsFromSegment(cur.labels, n.Label)
|
||
}
|
||
|
||
if finished, ok := finishBlock(cur); ok {
|
||
out = append(out, finished)
|
||
}
|
||
|
||
return out
|
||
}
|
||
|
||
func prepareAIRatingSegments(segments []aiSegmentMeta) []aiSegmentMeta {
|
||
return mergeRatingActivitySegments(
|
||
segments,
|
||
ratingMaxSilentGapSec,
|
||
ratingMinSegmentDurationSec,
|
||
)
|
||
}
|
||
|
||
func ratingConfidenceWeight(conf float64) float64 {
|
||
conf = ratingClamp01(conf)
|
||
|
||
// Confidence soll relevant sein, aber schlechte Scores nicht komplett töten.
|
||
n := ratingSmoothStep((conf - 0.25) / 0.70)
|
||
|
||
return 0.58 + 0.42*n
|
||
}
|
||
|
||
func ratingLinearGate(value float64, start float64, full float64) float64 {
|
||
if full <= start {
|
||
if value >= full {
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
return ratingClamp01((value - start) / (full - start))
|
||
}
|
||
|
||
func ratingExcellenceBonus(
|
||
peakQuality float64,
|
||
positionEffectiveWeighted float64,
|
||
positionDensity float64,
|
||
weightedCoverageRatio float64,
|
||
totalFlagged float64,
|
||
longest float64,
|
||
avgConfidence float64,
|
||
segmentsPerMinute float64,
|
||
n int,
|
||
) float64 {
|
||
// Kein pauschaler Score-Shift:
|
||
// Der Bonus darf nur greifen, wenn wirklich mehrere starke Signale vorhanden sind.
|
||
if n < 2 {
|
||
return 0
|
||
}
|
||
if positionEffectiveWeighted <= 0 {
|
||
return 0
|
||
}
|
||
if totalFlagged < 12.0 {
|
||
return 0
|
||
}
|
||
if peakQuality < 0.68 {
|
||
return 0
|
||
}
|
||
if avgConfidence < 0.42 {
|
||
return 0
|
||
}
|
||
|
||
peakGate := ratingLinearGate(peakQuality, 0.68, 0.90)
|
||
positionGate := ratingLinearGate(positionDensity, 2.20, 7.00)
|
||
coverageGate := ratingLinearGate(weightedCoverageRatio, 0.045, 0.160)
|
||
durationGate := ratingLinearGate(totalFlagged, 12.0, 90.0)
|
||
longestGate := ratingLinearGate(longest, 8.0, 45.0)
|
||
confGate := ratingLinearGate(avgConfidence, 0.42, 0.78)
|
||
frequencyGate := ratingLinearGate(segmentsPerMinute, 0.25, 1.00)
|
||
|
||
bonus :=
|
||
0.030*peakGate +
|
||
0.022*positionGate +
|
||
0.016*coverageGate +
|
||
0.012*durationGate +
|
||
0.012*longestGate +
|
||
0.010*confGate +
|
||
0.006*frequencyGate
|
||
|
||
// Maximal +8.5 Scorepunkte.
|
||
// Dadurch werden starke 72-79 Scores 5-Sterne-fähig,
|
||
// aber mittelmäßige Scores springen nicht einfach pauschal hoch.
|
||
if bonus > 0.085 {
|
||
bonus = 0.085
|
||
}
|
||
|
||
return bonus
|
||
}
|
||
|
||
func starsFromHighlightScore(score float64) int {
|
||
switch {
|
||
case score < 18:
|
||
return 1
|
||
case score < 38:
|
||
return 2
|
||
case score < 60:
|
||
return 3
|
||
case score < 80:
|
||
return 4
|
||
default:
|
||
return 5
|
||
}
|
||
}
|
||
|
||
func ratingUsernameFromVideoPath(videoPath string) string {
|
||
id := strings.TrimSpace(assetIDFromVideoPath(videoPath))
|
||
if id == "" {
|
||
return ""
|
||
}
|
||
|
||
user := strings.ToLower(strings.TrimSpace(modelNameFromFilename(id)))
|
||
if user != "" && user != "unknown" {
|
||
return user
|
||
}
|
||
|
||
// Fallback nur, wenn die ID nicht wie ein kompletter Dateiname mit Timestamp aussieht.
|
||
if !strings.Contains(id, "__") {
|
||
return strings.ToLower(strings.TrimSpace(id))
|
||
}
|
||
|
||
return ""
|
||
}
|
||
|
||
func ratingLogSubject(username string) string {
|
||
username = strings.ToLower(strings.TrimSpace(username))
|
||
if username == "" || username == "unknown" {
|
||
return "[rating]"
|
||
}
|
||
|
||
return "[" + username + "]"
|
||
}
|
||
|
||
func computeHighlightRating(segments []aiSegmentMeta, durationSec float64) *aiRatingMeta {
|
||
return computeHighlightRatingWithUsername(segments, durationSec, "")
|
||
}
|
||
|
||
func computeHighlightRatingForVideo(segments []aiSegmentMeta, durationSec float64, videoPath string) *aiRatingMeta {
|
||
return computeHighlightRatingWithUsername(segments, durationSec, ratingUsernameFromVideoPath(videoPath))
|
||
}
|
||
|
||
func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec float64, username string) *aiRatingMeta {
|
||
r := &aiRatingMeta{
|
||
Score: 0,
|
||
Stars: 1,
|
||
}
|
||
|
||
if durationSec <= 0 || len(segments) == 0 {
|
||
return r
|
||
}
|
||
|
||
videoMinutes := math.Max(durationSec/60.0, 0.25)
|
||
|
||
var totalFlagged float64
|
||
var totalWeighted float64
|
||
|
||
var positionEffectiveWeighted float64
|
||
var positionFlagged float64
|
||
var peakQuality float64
|
||
|
||
var longest float64
|
||
var confSum float64
|
||
var n int
|
||
|
||
for _, s := range segments {
|
||
segDur := s.DurationSeconds
|
||
if segDur <= 0 {
|
||
segDur = s.EndSeconds - s.StartSeconds
|
||
}
|
||
if segDur <= 0 {
|
||
continue
|
||
}
|
||
|
||
sev := ratingSegmentSeverity(s.Label)
|
||
if sev <= 0 {
|
||
appLogf("🧪 [rating] skip zero severity label=%q normalized=%q", s.Label, normalizeRatingSignalLabel(s.Label))
|
||
continue
|
||
}
|
||
|
||
conf := ratingClamp01(s.Score)
|
||
if conf <= 0 {
|
||
conf = 0.50
|
||
}
|
||
|
||
confWeight := ratingConfidenceWeight(conf)
|
||
quality := sev * confWeight
|
||
|
||
if quality <= 0 {
|
||
continue
|
||
}
|
||
|
||
effectiveDur := ratingEffectiveDurationSeconds(segDur)
|
||
weightedDur := segDur * quality
|
||
effectiveWeightedDur := effectiveDur * quality
|
||
|
||
totalFlagged += segDur
|
||
totalWeighted += weightedDur
|
||
|
||
set := ratingSignalSetFromLabel(s.Label)
|
||
if !set.HasPosition {
|
||
// Nur Positions-Segmente fließen ins Rating ein.
|
||
// Kleidung und Objekte wirken über Combo-Segmente (position+clothing etc.).
|
||
continue
|
||
}
|
||
positionEffectiveWeighted += effectiveWeightedDur
|
||
positionFlagged += segDur
|
||
|
||
if quality > peakQuality {
|
||
peakQuality = quality
|
||
}
|
||
|
||
confSum += conf
|
||
n++
|
||
|
||
if segDur > longest {
|
||
longest = segDur
|
||
}
|
||
}
|
||
|
||
if n == 0 {
|
||
appLogf("⚠️ %s rating result is zero because all segments were skipped", ratingLogSubject(username))
|
||
return r
|
||
}
|
||
|
||
coverageRatio := ratingClamp01(totalFlagged / durationSec)
|
||
weightedCoverageRatio := ratingClamp01(totalWeighted / durationSec)
|
||
segmentsPerMinute := float64(n) / videoMinutes
|
||
avgConfidence := confSum / float64(n)
|
||
|
||
positionDensity := positionEffectiveWeighted / videoMinutes
|
||
|
||
peakNorm := ratingSmoothStep(peakQuality)
|
||
positionDensityNorm := ratingSoftCap(positionDensity, 5.5)
|
||
coverageNorm := ratingSoftCap(weightedCoverageRatio, 0.20)
|
||
longestNorm := ratingSoftCap(longest, 24.0)
|
||
positionDurationNorm := ratingSoftCap(positionFlagged, 45.0)
|
||
confNorm := ratingSmoothStep((avgConfidence - 0.30) / 0.65)
|
||
|
||
// Positionen dominieren. Kleidung/Objekte fließen durch Combo-Segment-Qualität (peakNorm) ein.
|
||
// Die Länge der erkannten Positionen wird bewusst stark gewichtet:
|
||
// längstes Positions-Segment (longestNorm) + Gesamtdauer aller Positionen (positionDurationNorm).
|
||
// Je länger eine Position erkannt wird, desto höher der Score.
|
||
// Keine Penalties, keine Caps – die Formel bewertet natürlich nach Positions-Intensität.
|
||
// Summe = 1.00.
|
||
raw :=
|
||
0.34*positionDensityNorm +
|
||
0.22*peakNorm +
|
||
0.13*coverageNorm +
|
||
0.16*longestNorm +
|
||
0.11*positionDurationNorm +
|
||
0.04*confNorm
|
||
|
||
// Sehr wenig Material nicht überbewerten.
|
||
if totalFlagged < 5.0 && n <= 1 {
|
||
raw = math.Min(raw, 0.44)
|
||
}
|
||
|
||
excellenceBonus := ratingExcellenceBonus(
|
||
peakQuality,
|
||
positionEffectiveWeighted,
|
||
positionDensity,
|
||
weightedCoverageRatio,
|
||
totalFlagged,
|
||
longest,
|
||
avgConfidence,
|
||
segmentsPerMinute,
|
||
n,
|
||
)
|
||
|
||
raw = ratingClamp01(raw + excellenceBonus)
|
||
|
||
score := ratingRound(raw*100, 1)
|
||
|
||
r.Score = score
|
||
r.Stars = starsFromHighlightScore(score)
|
||
r.Segments = n
|
||
r.SegmentsPerMinute = ratingRound(segmentsPerMinute, 2)
|
||
r.FlaggedSeconds = ratingRound(totalFlagged, 2)
|
||
r.WeightedFlaggedSeconds = ratingRound(totalWeighted, 2)
|
||
r.CoverageRatio = ratingRound(coverageRatio, 4)
|
||
r.WeightedCoverageRatio = ratingRound(weightedCoverageRatio, 4)
|
||
r.LongestSegmentSeconds = ratingRound(longest, 2)
|
||
r.AvgConfidence = ratingRound(avgConfidence, 3)
|
||
|
||
appLogf(
|
||
"✅ %s rating score=%.1f stars=%d bonus=%.1f segs=%d flagged=%.1f posFlagged=%.1f posDensity=%.2f coverage=%.4f longest=%.1f avgConf=%.3f",
|
||
ratingLogSubject(username),
|
||
r.Score,
|
||
r.Stars,
|
||
ratingRound(excellenceBonus*100, 1),
|
||
r.Segments,
|
||
r.FlaggedSeconds,
|
||
ratingRound(positionFlagged, 1),
|
||
positionDensity,
|
||
r.WeightedCoverageRatio,
|
||
r.LongestSegmentSeconds,
|
||
r.AvgConfidence,
|
||
)
|
||
|
||
return r
|
||
}
|