// backend\rating.go package main import ( "math" "sort" "strings" ) type aiRatingMeta struct { Score float64 `json:"score"` ActionScore float64 `json:"actionScore"` BestPosition string `json:"bestPosition,omitempty"` BestPositionRank int `json:"bestPositionRank,omitempty"` BestClothing string `json:"bestClothing,omitempty"` BestClothingRank int `json:"bestClothingRank,omitempty"` PositionScore float64 `json:"positionScore"` ClothingScore float64 `json:"clothingScore"` DurationScore float64 `json:"durationScore"` StrengthScore float64 `json:"strengthScore"` CoverageScore float64 `json:"coverageScore"` ContextScore float64 `json:"contextScore"` VarietyScore float64 `json:"varietyScore"` 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 ratingSaturatingEvidence(value, scale float64) float64 { if value <= 0 || scale <= 0 { return 0 } return ratingClamp01(1 - math.Exp(-value/scale)) } 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 } } type explicitRatingRank struct { Rank int Weight float64 } func positionExplicitRatingRank(label string) explicitRatingRank { label = strings.ToLower(strings.TrimSpace(label)) switch label { case "cowgirl": return explicitRatingRank{Rank: 1, Weight: 1.00} case "reverse_cowgirl": return explicitRatingRank{Rank: 2, Weight: 0.99} case "doggy", "doggystyle", "standing_doggy": return explicitRatingRank{Rank: 3, Weight: 0.98} case "toy_play": return explicitRatingRank{Rank: 4, Weight: 0.96} case "fingering": return explicitRatingRank{Rank: 5, Weight: 0.94} case "missionary": return explicitRatingRank{Rank: 6, Weight: 0.92} case "prone_bone": return explicitRatingRank{Rank: 7, Weight: 0.90} case "spooning": return explicitRatingRank{Rank: 8, Weight: 0.86} case "standing": return explicitRatingRank{Rank: 9, Weight: 0.56} case "69", "facesitting": return explicitRatingRank{Rank: 10, Weight: 0.54} case "blowjob", "handjob", "cunnilingus", "boobjob": return explicitRatingRank{Rank: 11, Weight: 0.52} default: return explicitRatingRank{} } } func positionSeverityWeight(label string) float64 { return positionExplicitRatingRank(label).Weight } 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 clothingExplicitRatingRank(label string) explicitRatingRank { label = strings.ToLower(strings.TrimSpace(label)) switch label { case "lingerie": return explicitRatingRank{Rank: 1, Weight: 0.62} case "stockings": return explicitRatingRank{Rank: 2, Weight: 0.57} case "skirt": return explicitRatingRank{Rank: 3, Weight: 0.52} case "hotpants": return explicitRatingRank{Rank: 4, Weight: 0.47} case "bikini": return explicitRatingRank{Rank: 5, Weight: 0.44} case "bra": return explicitRatingRank{Rank: 6, Weight: 0.41} case "panties": return explicitRatingRank{Rank: 7, Weight: 0.36} case "croptop": return explicitRatingRank{Rank: 8, Weight: 0.31} case "heels": return explicitRatingRank{Rank: 9, Weight: 0.26} case "dress": return explicitRatingRank{Rank: 10, Weight: 0.22} default: return explicitRatingRank{} } } func clothingSeverityWeight(label string) float64 { return clothingExplicitRatingRank(label).Weight } 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) return contextualSegmentSeverityWeightFromSet(set) } func comboSegmentSeverityWeight(label string) float64 { set := ratingSignalSetFromLabel(label) return contextualSegmentSeverityWeightFromSet(set) } func contextualSegmentSeverityWeightFromSet(set ratingSignalSet) float64 { if !ratingSignalSetHasInterestingContent(set) { return 0 } var score float64 if set.HasPosition { // Position soll das Hauptkriterium sein. // Sexuelle Körperteile und explizite Kleidung verstärken die Position. score = 0.82*set.Position + 0.12*set.Body + 0.08*set.Clothing + 0.015*set.Object + 0.005*set.Person if set.HasBody { score += 0.030 } if set.HasClothing { score += 0.020 } if set.HasObject { score += 0.006 } if set.HasPerson { score += 0.002 } // Standing ohne echten Kontext klar schwächer halten. if set.Position < 0.60 && !set.HasBody && !set.HasObject && !set.HasClothing { // Den Rang innerhalb der schwachen Positionen erhalten, statt // Standing, 69 und orale Positionen auf denselben Wert zu kappen. score = math.Min(score, 0.10+0.40*set.Position) } 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 updateBestExplicitRatingRanks( label string, bestPosition *string, bestPositionRank *int, bestClothing *string, bestClothingRank *int, ) { labels := map[string]bool{} addRatingLabelsFromSegment(labels, label) for normalized := range labels { switch { case strings.HasPrefix(normalized, "position:"): raw := strings.TrimPrefix(normalized, "position:") ranked := positionExplicitRatingRank(raw) if ranked.Rank > 0 && (*bestPositionRank == 0 || ranked.Rank < *bestPositionRank) { *bestPosition = raw *bestPositionRank = ranked.Rank } case strings.HasPrefix(normalized, "clothing:"): raw := strings.TrimPrefix(normalized, "clothing:") ranked := clothingExplicitRatingRank(raw) if ranked.Rank > 0 && (*bestClothingRank == 0 || ranked.Rank < *bestClothingRank) { *bestClothing = raw *bestClothingRank = ranked.Rank } } } } 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 positionRanges map[string][]segmentBounds marker float64 markerWeight float64 } var addRatingPositionSignal func( weights map[string]float64, bounds map[string]segmentBounds, ranges map[string][]segmentBounds, label string, weight float64, start float64, end float64, ) addRatingPositionSignal = func( weights map[string]float64, bounds map[string]segmentBounds, ranges 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, ranges, 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 if end > start { ranges[normalized] = append(ranges[normalized], segmentBounds{ start: start, end: end, ok: true, }) } } 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{} positionRanges := map[string][]segmentBounds{} addRatingPositionSignal( positionWeights, positionBounds, positionRanges, 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, positionRanges: positionRanges, marker: marker, markerWeight: markerWeight, } } coveredDuration := func(ranges []segmentBounds, start, end float64) float64 { if len(ranges) == 0 || end <= start { return 0 } clipped := make([]segmentBounds, 0, len(ranges)) for _, r := range ranges { rangeStart := math.Max(start, r.start) rangeEnd := math.Min(end, r.end) if rangeEnd > rangeStart { clipped = append(clipped, segmentBounds{ start: rangeStart, end: rangeEnd, ok: true, }) } } if len(clipped) == 0 { return 0 } sort.SliceStable(clipped, func(i, j int) bool { if clipped[i].start != clipped[j].start { return clipped[i].start < clipped[j].start } return clipped[i].end < clipped[j].end }) total := 0.0 currentStart := clipped[0].start currentEnd := clipped[0].end for _, r := range clipped[1:] { if r.start <= currentEnd { if r.end > currentEnd { currentEnd = r.end } continue } total += currentEnd - currentStart currentStart = r.start currentEnd = r.end } return total + currentEnd - currentStart } 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 end := b.end // Wenn der finale Block eine Position trägt, soll der Block // wirklich auf die echte Positions-Zeit gekürzt werden — // nicht nur am Anfang, sondern auch am Ende. if bestPosition != "" { if bounds, ok := b.positionBounds[bestPosition]; ok && bounds.ok { if bounds.start > start { start = bounds.start } if bounds.end < end { end = bounds.end } } } if end < start { end = start } dur = end - start if dur <= 0 { return aiSegmentMeta{}, false } ratingDuration := dur if bestPosition != "" { if evidenceDuration := coveredDuration( b.positionRanges[bestPosition], start, end, ); evidenceDuration > 0 { ratingDuration = evidenceDuration } } // Kurze Segmente dürfen bleiben, wenn sie stark genug sind. if ratingDuration < 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 } marker := b.marker if marker < start || marker > end { marker = (start + end) / 2 } return aiSegmentMeta{ Label: label, Score: ratingClamp01(score), RatingIntensity: ratingClamp01(sev * ratingConfidenceWeight(score)), StartSeconds: start, EndSeconds: end, DurationSeconds: ratingDuration, AutoSelected: true, Position: segmentPositionFromAnalyzeLabel(label), Tags: segmentTagsFromAnalyzeLabel(label), MarkerSeconds: marker, PreviewSeconds: 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 nextLabels := map[string]bool{} addRatingLabelsFromSegment(nextLabels, n.Label) positionConflict := ratingPositionSetsConflict(cur.labels, nextLabels) // A short gap may connect supporting signals or the same position, but // must not turn consecutive different positions into one long segment. shouldBridge := gap <= maxSilentGapSec && !positionConflict if !shouldBridge && !positionConflict && gap <= ratingBridgeStrongGapSec { 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, cur.positionRanges, 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)) } type ratingTimeRange struct { Start float64 End float64 } func ratingCoveredSeconds(ranges []ratingTimeRange, durationSec float64) float64 { if len(ranges) == 0 || durationSec <= 0 { return 0 } valid := make([]ratingTimeRange, 0, len(ranges)) for _, item := range ranges { start := math.Max(0, math.Min(item.Start, durationSec)) end := math.Max(0, math.Min(item.End, durationSec)) if end > start { valid = append(valid, ratingTimeRange{Start: start, End: end}) } } if len(valid) == 0 { return 0 } sort.SliceStable(valid, func(i, j int) bool { if valid[i].Start != valid[j].Start { return valid[i].Start < valid[j].Start } return valid[i].End < valid[j].End }) total := 0.0 current := valid[0] for _, item := range valid[1:] { if item.Start <= current.End { if item.End > current.End { current.End = item.End } continue } total += current.End - current.Start current = item } return total + current.End - current.Start } func ratingStrengthComponent(avgConfidence, peakConfidence float64) float64 { avg := ratingSmoothStep(ratingLinearGate(avgConfidence, 0.35, 0.90)) peak := ratingSmoothStep(ratingLinearGate(peakConfidence, 0.50, 0.95)) return ratingClamp01(0.75*avg + 0.25*peak) } func ratingDurationComponent(totalSeconds, longestSeconds float64) float64 { total := ratingSaturatingEvidence(totalSeconds, 55.0) longest := ratingSaturatingEvidence(longestSeconds, 30.0) return ratingClamp01(0.62*total + 0.38*longest) } func ratingCoverageComponent(coveredSeconds, durationSec float64) float64 { if coveredSeconds <= 0 || durationSec <= 0 { return 0 } return ratingSaturatingEvidence(ratingClamp01(coveredSeconds/durationSec), 0.12) } func ratingActionComponent(duration, coverage, strength float64) float64 { return ratingClamp01(0.50*duration + 0.30*coverage + 0.20*strength) } func ratingApplyPositionEvidenceCaps( raw float64, positionSeconds float64, longestSeconds float64, avgConfidence float64, positionScore float64, ) float64 { switch { case positionSeconds < 5: raw = math.Min(raw, 0.21) case positionSeconds < 12: raw = math.Min(raw, 0.44) case positionSeconds < 30: raw = math.Min(raw, 0.67) } if positionSeconds < 75 || longestSeconds < 18 || avgConfidence < 0.55 || positionScore < 0.65 { raw = math.Min(raw, 0.84) } if avgConfidence < 0.50 && positionScore < 0.65 { raw = math.Min(raw, 0.21) } return ratingClamp01(raw) } func starsFromHighlightScore(score float64) int { switch { case score < 22: return 1 case score < 45: return 2 case score < 68: return 3 case score < 85: 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 positionRanges []ratingTimeRange var positionWeighted float64 var positionExplicitSum float64 var positionEvidenceWeight float64 var peakPositionExplicitness float64 var peakPositionConfidence float64 var contextRanges []ratingTimeRange var contextWeighted float64 var contextExplicitSum float64 var contextEvidenceWeight float64 var peakContextExplicitness float64 var peakContextConfidence float64 var contextLongest float64 var contextConfSum float64 var contextConfWeightSum float64 var contextN int var bodyEffectiveWeighted float64 var clothingEffectiveWeighted float64 var objectEffectiveWeighted float64 var clothingExplicitSum float64 var clothingEvidenceWeight float64 var longest float64 var confSum float64 var confWeightSum float64 var n int uniquePositions := map[string]bool{} bestPosition := "" bestPositionRank := 0 bestClothing := "" bestClothingRank := 0 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 segmentRange := ratingTimeRange{ Start: s.StartSeconds, End: s.StartSeconds + segDur, } set := ratingSignalSetFromLabel(s.Label) updateBestExplicitRatingRanks( s.Label, &bestPosition, &bestPositionRank, &bestClothing, &bestClothingRank, ) bodyEffectiveWeighted += effectiveDur * set.Body * confWeight clothingEffectiveWeighted += effectiveDur * set.Clothing * confWeight objectEffectiveWeighted += effectiveDur * set.Object * confWeight if set.HasClothing { clothingExplicitSum += (set.Clothing / clothingExplicitRatingRank("lingerie").Weight) * effectiveDur clothingEvidenceWeight += effectiveDur } if !set.HasPosition { contextRanges = append(contextRanges, segmentRange) contextWeighted += weightedDur contextExplicitSum += sev * effectiveDur contextEvidenceWeight += effectiveDur contextConfSum += conf * effectiveDur contextConfWeightSum += effectiveDur contextN++ if sev > peakContextExplicitness { peakContextExplicitness = sev } if conf > peakContextConfidence { peakContextConfidence = conf } if segDur > contextLongest { contextLongest = segDur } continue } positionRanges = append(positionRanges, segmentRange) positionWeighted += weightedDur positionExplicitSum += set.Position * effectiveDur positionEvidenceWeight += effectiveDur if set.Position > peakPositionExplicitness { peakPositionExplicitness = set.Position } if conf > peakPositionConfidence { peakPositionConfidence = conf } confSum += conf * effectiveDur confWeightSum += effectiveDur n++ if segDur > longest { longest = segDur } labels := map[string]bool{} addRatingLabelsFromSegment(labels, s.Label) for position := range ratingPositionSetFromLabels(labels) { uniquePositions[position] = true } } clothingScore := 0.0 if clothingEvidenceWeight > 0 { avgClothingExplicitness := ratingClamp01(clothingExplicitSum / clothingEvidenceWeight) clothingDensity := clothingEffectiveWeighted / videoMinutes clothingScore = ratingClamp01( 0.70*avgClothingExplicitness + 0.30*ratingSaturatingEvidence(clothingDensity, 1.8), ) } bodyContext := ratingSaturatingEvidence(bodyEffectiveWeighted/videoMinutes, 1.8) objectContext := ratingSaturatingEvidence(objectEffectiveWeighted/videoMinutes, 2.2) contextScore := ratingClamp01(0.78*bodyContext + 0.22*objectContext) if n == 0 { if contextN == 0 { appLogf("⚠️ %s rating result is zero because all segments were skipped", ratingLogSubject(username)) return r } contextFlagged := ratingCoveredSeconds(contextRanges, durationSec) contextCoverageRatio := ratingClamp01(contextFlagged / durationSec) contextWeightedCoverageRatio := ratingClamp01(contextWeighted / durationSec) contextSegmentsPerMinute := float64(contextN) / videoMinutes contextAvgConfidence := contextConfSum / contextConfWeightSum avgContextExplicitness := contextExplicitSum / contextEvidenceWeight contextQuality := ratingClamp01( (0.75*avgContextExplicitness + 0.25*peakContextExplicitness) / 0.72, ) durationScore := ratingDurationComponent(contextFlagged, contextLongest) strengthScore := ratingStrengthComponent(contextAvgConfidence, peakContextConfidence) coverageScore := ratingCoverageComponent(contextFlagged, durationSec) raw := 0.30*contextQuality + 0.25*durationScore + 0.15*strengthScore + 0.12*coverageScore + 0.12*clothingScore + 0.06*contextScore actionRaw := ratingActionComponent(durationScore, coverageScore, strengthScore) if contextFlagged < 20.0 { raw = math.Min(raw, 0.44) } semanticCeiling := 0.52 + 0.12*math.Max( contextQuality, math.Max(clothingScore, contextScore), ) raw = math.Min(raw, semanticCeiling) score := ratingRound(ratingClamp01(raw)*100, 1) r.Score = score r.ActionScore = ratingRound(actionRaw*100, 1) r.BestPosition = bestPosition r.BestPositionRank = bestPositionRank r.BestClothing = bestClothing r.BestClothingRank = bestClothingRank r.ClothingScore = ratingRound(clothingScore*100, 1) r.DurationScore = ratingRound(durationScore*100, 1) r.StrengthScore = ratingRound(strengthScore*100, 1) r.CoverageScore = ratingRound(coverageScore*100, 1) r.ContextScore = ratingRound(contextScore*100, 1) r.Stars = starsFromHighlightScore(score) r.Segments = contextN r.SegmentsPerMinute = ratingRound(contextSegmentsPerMinute, 2) r.FlaggedSeconds = ratingRound(contextFlagged, 2) r.WeightedFlaggedSeconds = ratingRound(contextWeighted, 2) r.CoverageRatio = ratingRound(contextCoverageRatio, 4) r.WeightedCoverageRatio = ratingRound(contextWeightedCoverageRatio, 4) r.LongestSegmentSeconds = ratingRound(contextLongest, 2) r.AvgConfidence = ratingRound(contextAvgConfidence, 3) appLogf( "rating %s context score=%.1f stars=%d segs=%d flagged=%.1f quality=%.2f coverage=%.4f longest=%.1f avgConf=%.3f", ratingLogSubject(username), r.Score, r.Stars, r.Segments, r.FlaggedSeconds, contextQuality, r.WeightedCoverageRatio, r.LongestSegmentSeconds, r.AvgConfidence, ) return r } positionFlagged := ratingCoveredSeconds(positionRanges, durationSec) coverageRatio := ratingClamp01(positionFlagged / durationSec) weightedCoverageRatio := ratingClamp01(positionWeighted / durationSec) segmentsPerMinute := float64(n) / videoMinutes avgConfidence := confSum / confWeightSum avgPositionExplicitness := positionExplicitSum / positionEvidenceWeight positionScore := ratingClamp01( 0.75*avgPositionExplicitness + 0.25*peakPositionExplicitness, ) durationScore := ratingDurationComponent(positionFlagged, longest) strengthScore := ratingStrengthComponent(avgConfidence, peakPositionConfidence) coverageScore := ratingCoverageComponent(positionFlagged, durationSec) varietyScore := ratingLinearGate(float64(len(uniquePositions)), 1, 4) raw := 0.36*positionScore + 0.27*durationScore + 0.14*strengthScore + 0.10*coverageScore + 0.06*contextScore + 0.04*clothingScore + 0.03*varietyScore actionRaw := ratingActionComponent(durationScore, coverageScore, strengthScore) // Short or weak detections cannot reach high ratings on rank alone. raw = ratingApplyPositionEvidenceCaps( raw, positionFlagged, longest, avgConfidence, positionScore, ) score := ratingRound(raw*100, 1) r.Score = score r.ActionScore = ratingRound(actionRaw*100, 1) r.BestPosition = bestPosition r.BestPositionRank = bestPositionRank r.BestClothing = bestClothing r.BestClothingRank = bestClothingRank r.PositionScore = ratingRound(positionScore*100, 1) r.ClothingScore = ratingRound(clothingScore*100, 1) r.DurationScore = ratingRound(durationScore*100, 1) r.StrengthScore = ratingRound(strengthScore*100, 1) r.CoverageScore = ratingRound(coverageScore*100, 1) r.ContextScore = ratingRound(contextScore*100, 1) r.VarietyScore = ratingRound(varietyScore*100, 1) r.Stars = starsFromHighlightScore(score) r.Segments = n r.SegmentsPerMinute = ratingRound(segmentsPerMinute, 2) r.FlaggedSeconds = ratingRound(positionFlagged, 2) r.WeightedFlaggedSeconds = ratingRound(positionWeighted, 2) r.CoverageRatio = ratingRound(coverageRatio, 4) r.WeightedCoverageRatio = ratingRound(weightedCoverageRatio, 4) r.LongestSegmentSeconds = ratingRound(longest, 2) r.AvgConfidence = ratingRound(avgConfidence, 3) appLogf( "rating %s score=%.1f stars=%d position=%.1f clothing=%.1f segs=%d flagged=%.1f duration=%.1f strength=%.1f coverage=%.4f longest=%.1f avgConf=%.3f", ratingLogSubject(username), r.Score, r.Stars, r.PositionScore, r.ClothingScore, r.Segments, r.FlaggedSeconds, r.DurationScore, r.StrengthScore, r.WeightedCoverageRatio, r.LongestSegmentSeconds, r.AvgConfidence, ) return r }