diff --git a/backend/analyze.go b/backend/analyze.go index c3321e3..abc3502 100644 --- a/backend/analyze.go +++ b/backend/analyze.go @@ -373,7 +373,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe } comboScore := math.Min(positionScore, score) - addHighlightResult(best, "combo:"+sexPosition+"+"+prefix+":"+label, comboScore) + addHighlightResult(best, "combo:position:"+sexPosition+"+"+prefix+":"+label, comboScore) } } @@ -1504,8 +1504,21 @@ func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64) best := map[string]highlightSignal{} sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition)) - if sexPosition != "" && sexPosition != "unknown" { - addHighlightSignal(best, "position:"+sexPosition, pred.SexPositionScore) + + if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) { + positionScore := pred.SexPositionScore + if positionScore <= 0 { + positionScore = 0.35 + } + + // Position soll als Kontext in Kombis bleiben. + // Sie erzeugt weiterhin kein Segment alleine, weil unten mindestens + // ein Nicht-Positionssignal verlangt wird. + best["position:"+sexPosition] = highlightSignal{ + Label: "position:" + sexPosition, + Score: math.Max(positionScore, 0.20), + Group: "position", + } } addHighlightSignalsFromScoredLabels(best, "body", pred.BodyPartsPresent) @@ -2297,6 +2310,19 @@ func preferAnalyzeSegmentLabel(a, b string) string { return a } + if strings.HasPrefix(a, "combo:") && strings.HasPrefix(b, "combo:") { + if strings.Contains(a, "position:") && !strings.Contains(b, "position:") { + return a + } + if strings.Contains(b, "position:") && !strings.Contains(a, "position:") { + return b + } + if len(b) > len(a) { + return b + } + return a + } + // body: ist meist semantisch sauberer als detector: if strings.HasPrefix(a, "body:") && !strings.HasPrefix(b, "body:") { return a @@ -2328,6 +2354,91 @@ func preferAnalyzeSegmentLabel(a, b string) string { return a } +func segmentLabelParts(label string) []string { + label = strings.ToLower(strings.TrimSpace(label)) + if label == "" { + return nil + } + + if strings.HasPrefix(label, "combo:") { + raw := strings.TrimPrefix(label, "combo:") + parts := strings.Split(raw, "+") + + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.ToLower(strings.TrimSpace(part)) + if part != "" { + out = append(out, part) + } + } + + return out + } + + return []string{label} +} + +func segmentPositionFromAnalyzeLabel(label string) string { + for _, part := range segmentLabelParts(label) { + part = strings.TrimSpace(part) + part = strings.TrimPrefix(part, "position:") + + if isKnownPositionLabel(part) { + return part + } + } + + return "" +} + +func segmentTagsFromAnalyzeLabel(label string) []string { + parts := segmentLabelParts(label) + if len(parts) == 0 { + return nil + } + + out := make([]string, 0, len(parts)) + seen := map[string]bool{} + + for _, part := range parts { + part = strings.ToLower(strings.TrimSpace(part)) + if part == "" { + continue + } + + // Person nicht als Segment-Tag speichern. + if isPersonSegmentLabel(part) { + continue + } + + if strings.HasPrefix(part, "position:") { + pos := strings.TrimPrefix(part, "position:") + if pos == "" || !isKnownPositionLabel(pos) { + continue + } + + tag := "position:" + pos + if !seen[tag] { + seen[tag] = true + out = append(out, tag) + } + + continue + } + + if !seen[part] { + seen[part] = true + out = append(out, part) + } + } + + if len(out) == 0 { + return nil + } + + return out +} + func mergeAnalyzeHits(in []analyzeHit) []analyzeHit { if len(in) == 0 { return []analyzeHit{} @@ -2576,13 +2687,17 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme continue } + label := strings.ToLower(strings.TrimSpace(hit.Label)) + out = append(out, aiSegmentMeta{ - Label: strings.ToLower(strings.TrimSpace(hit.Label)), + Label: label, StartSeconds: start, EndSeconds: end, DurationSeconds: end - start, Score: hit.Score, AutoSelected: true, + Position: segmentPositionFromAnalyzeLabel(label), + Tags: segmentTagsFromAnalyzeLabel(label), }) } @@ -2696,6 +2811,8 @@ func buildHighlightSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) DurationSeconds: end - start, Score: hit.Score, AutoSelected: true, + Position: segmentPositionFromAnalyzeLabel(label), + Tags: segmentTagsFromAnalyzeLabel(label), }) } diff --git a/backend/meta.go b/backend/meta.go index 0941306..f1a7cbd 100644 --- a/backend/meta.go +++ b/backend/meta.go @@ -104,12 +104,14 @@ type videoMeta struct { } type aiSegmentMeta struct { - Label string `json:"label"` - StartSeconds float64 `json:"startSeconds"` - EndSeconds float64 `json:"endSeconds"` - DurationSeconds float64 `json:"durationSeconds"` - Score float64 `json:"score,omitempty"` - AutoSelected bool `json:"autoSelected,omitempty"` + Label string `json:"label"` + StartSeconds float64 `json:"startSeconds"` + EndSeconds float64 `json:"endSeconds"` + DurationSeconds float64 `json:"durationSeconds"` + Score float64 `json:"score,omitempty"` + AutoSelected bool `json:"autoSelected,omitempty"` + Position string `json:"position,omitempty"` + Tags []string `json:"tags,omitempty"` } type aiAnalysisMeta struct { diff --git a/frontend/src/components/ui/RatingOverlay.tsx b/frontend/src/components/ui/RatingOverlay.tsx index 2d389a0..7fc184a 100644 --- a/frontend/src/components/ui/RatingOverlay.tsx +++ b/frontend/src/components/ui/RatingOverlay.tsx @@ -289,12 +289,111 @@ function prettyAiLabel(value: unknown): string { return isAiLikeLabel(raw) ? titleCaseLabel(raw) : raw } +function rawSegmentLabelParts(value: unknown): string[] { + const raw = String(value ?? '').trim().toLowerCase() + if (!raw) return [] + + if (raw.startsWith('combo:')) { + return raw + .slice('combo:'.length) + .split('+') + .map((part) => part.trim()) + .filter(Boolean) + } + + return [raw] +} + +function tagsFromSegmentLabel(value: unknown): string[] { + const out: string[] = [] + + for (const part of rawSegmentLabelParts(value)) { + const clean = part.trim() + if (!clean) continue + + if (clean.startsWith('position:')) { + const label = prettyAiLabel(clean) + if (label) out.push(`Position: ${label}`) + continue + } + + if (clean.startsWith('body:')) { + const label = prettyAiLabel(clean) + if (label) out.push(label) + continue + } + + if (clean.startsWith('object:')) { + const label = prettyAiLabel(clean) + if (label) out.push(label) + continue + } + + if (clean.startsWith('clothing:')) { + const label = prettyAiLabel(clean) + if (label) out.push(label) + continue + } + + if (clean.startsWith('detector:')) { + const label = prettyAiLabel(clean) + if (label) out.push(label) + continue + } + + if (isKnownFrontendPositionLabel(clean)) { + const label = prettyAiLabel(clean) + if (label) out.push(`Position: ${label}`) + continue + } + + const label = prettyAiLabel(clean) + if (label) out.push(label) + } + + return out +} + +function isKnownFrontendPositionLabel(value: unknown): boolean { + const label = String(value ?? '').trim().toLowerCase() + + return [ + 'missionary', + 'doggy', + 'doggystyle', + 'cowgirl', + 'reverse_cowgirl', + 'cunnilingus', + 'prone_bone', + 'standing', + 'standing_doggy', + 'spooning', + 'sitting', + 'facesitting', + 'handjob', + 'blowjob', + 'toy_play', + 'fingering', + '69', + 'other', + ].includes(label) +} + function segmentTitleFromRawLabel(raw: string): string { return prettyAiLabel(raw) } function readSegmentTags(obj: Record): string[] { - const tags = readTags(obj) + const position = typeof obj.position === 'string' && obj.position.trim() + ? `Position: ${prettyAiLabel(obj.position)}` + : '' + + const tags = [ + position, + ...tagsFromSegmentLabel(obj.label ?? obj.title ?? obj.category ?? obj.type ?? obj.kind ?? obj.name), + ...readTags(obj), + ] + const out: string[] = [] for (const tag of tags) { @@ -303,7 +402,7 @@ function readSegmentTags(obj: Record): string[] { out.push(tag) } - return out.slice(0, 6) + return out.slice(0, 8) } function prettyMaybeAiValue(key: string, value: string): string {