nsfwapp/backend/rating.go
2026-06-14 22:27:43 +02:00

1775 lines
41 KiB
Go

// 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
}