diff --git a/backend/meta.go b/backend/meta.go index 819d4b0..8a27f44 100644 --- a/backend/meta.go +++ b/backend/meta.go @@ -107,6 +107,7 @@ type aiSegmentMeta struct { EndSeconds float64 `json:"endSeconds"` DurationSeconds float64 `json:"durationSeconds"` Score float64 `json:"score,omitempty"` + RatingIntensity float64 `json:"ratingIntensity,omitempty"` AutoSelected bool `json:"autoSelected,omitempty"` Position string `json:"position,omitempty"` Tags []string `json:"tags,omitempty"` diff --git a/backend/rating.go b/backend/rating.go index f6b2c0b..4a29ebe 100644 --- a/backend/rating.go +++ b/backend/rating.go @@ -10,6 +10,7 @@ import ( type aiRatingMeta struct { Score float64 `json:"score"` + ActionScore float64 `json:"actionScore"` Stars int `json:"stars"` Segments int `json:"segments"` SegmentsPerMinute float64 `json:"segmentsPerMinute"` @@ -1130,6 +1131,7 @@ func mergeRatingActivitySegments( return aiSegmentMeta{ Label: label, Score: ratingClamp01(score), + RatingIntensity: ratingClamp01(sev * ratingConfidenceWeight(score)), StartSeconds: start, EndSeconds: end, DurationSeconds: ratingDuration, @@ -1276,6 +1278,43 @@ func ratingExplicitContextBonus( return math.Min(bonus, 0.10) } +func ratingBlendWithAction(base, action, actionWeight float64) float64 { + actionWeight = ratingClamp01(actionWeight) + return ratingClamp01((1-actionWeight)*base + actionWeight*action) +} + +func ratingPositionActionScore( + durationNorm float64, + longestNorm float64, + densityNorm float64, + coverageNorm float64, + peakNorm float64, +) float64 { + return ratingClamp01( + 0.38*durationNorm + + 0.24*longestNorm + + 0.18*densityNorm + + 0.12*coverageNorm + + 0.08*peakNorm, + ) +} + +func ratingContextActionScore( + durationNorm float64, + longestNorm float64, + coverageNorm float64, + densityNorm float64, + peakNorm float64, +) float64 { + return ratingClamp01( + 0.40*durationNorm + + 0.25*longestNorm + + 0.20*coverageNorm + + 0.10*densityNorm + + 0.05*peakNorm, + ) +} + func ratingExcellenceBonus( peakQuality float64, positionEffectiveWeighted float64, @@ -1539,6 +1578,15 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl 0.15*coverageNorm + 0.05*densityNorm + actionRaw := ratingContextActionScore( + durationNorm, + longestNorm, + coverageNorm, + densityNorm, + peakNorm, + ) + raw = ratingBlendWithAction(raw, actionRaw, 0.35) + // Context can produce useful 1-3 star ratings, but never the 4-5 star // range reserved for sustained position detections. if contextFlagged < 20.0 { @@ -1549,6 +1597,7 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl score := ratingRound(ratingClamp01(raw)*100, 1) r.Score = score + r.ActionScore = ratingRound(actionRaw*100, 1) r.Stars = starsFromHighlightScore(score) r.Segments = contextN r.SegmentsPerMinute = ratingRound(contextSegmentsPerMinute, 2) @@ -1606,6 +1655,15 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl 0.06*coverageNorm + 0.02*positionVarietyNorm + actionRaw := ratingPositionActionScore( + positionDurationNorm, + longestNorm, + positionDensityNorm, + coverageNorm, + peakNorm, + ) + raw = ratingBlendWithAction(raw, actionRaw, 0.30) + explicitContextBonus := ratingExplicitContextBonus( bodyEffectiveWeighted, clothingEffectiveWeighted, @@ -1635,6 +1693,7 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl score := ratingRound(raw*100, 1) r.Score = score + r.ActionScore = ratingRound(actionRaw*100, 1) r.Stars = starsFromHighlightScore(score) r.Segments = n r.SegmentsPerMinute = ratingRound(segmentsPerMinute, 2) diff --git a/backend/rating_test.go b/backend/rating_test.go index b3310aa..c781fd0 100644 --- a/backend/rating_test.go +++ b/backend/rating_test.go @@ -98,6 +98,30 @@ func TestComputeHighlightRatingRewardsPositionDuration(t *testing.T) { } } +func TestComputeHighlightRatingActionPrefersSustainedActivity(t *testing.T) { + briefPeak := computeHighlightRating([]aiSegmentMeta{ + ratingTestSegment("position:doggy", 10, 8, 0.95), + }, 600) + sustained := computeHighlightRating([]aiSegmentMeta{ + ratingTestSegment("position:doggy", 10, 45, 0.55), + }, 600) + + if sustained.ActionScore <= briefPeak.ActionScore { + t.Fatalf( + "sustained action should outrank a brief peak: sustained=%.1f brief=%.1f", + sustained.ActionScore, + briefPeak.ActionScore, + ) + } + if sustained.Score <= briefPeak.Score { + t.Fatalf( + "rating should follow sustained action: sustained=%.1f brief=%.1f", + sustained.Score, + briefPeak.Score, + ) + } +} + func TestComputeHighlightRatingRatesContextWithoutPosition(t *testing.T) { clothingOnly := computeHighlightRating([]aiSegmentMeta{ ratingTestSegment("clothing:lingerie", 10, 60, 0.90), @@ -268,4 +292,19 @@ func TestPrepareAIRatingSegmentsMergesSamePositionAcrossShortGap(t *testing.T) { segments[0].DurationSeconds, ) } + if segments[0].RatingIntensity <= 0 { + t.Fatalf("rating intensity = %.3f, want a positive heatcurve value", segments[0].RatingIntensity) + } + + wantIntensity := ratingClamp01( + ratingSegmentSeverity(segments[0].Label) * + ratingConfidenceWeight(segments[0].Score), + ) + if diff := segments[0].RatingIntensity - wantIntensity; diff < -0.0001 || diff > 0.0001 { + t.Fatalf( + "rating intensity = %.4f, want %.4f", + segments[0].RatingIntensity, + wantIntensity, + ) + } } diff --git a/frontend/src/aiLabels.ts b/frontend/src/aiLabels.ts index 90e8830..f7d23a7 100644 --- a/frontend/src/aiLabels.ts +++ b/frontend/src/aiLabels.ts @@ -137,6 +137,7 @@ export const AI_LABEL_FALLBACKS: Record = { export const RATING_VALUE_LABELS: Record = { stars: 'Sterne', score: 'Score', + actionScore: 'Action', confidence: 'Konfidenz', probability: 'Wahrscheinlichkeit', nsfw: 'NSFW', @@ -452,4 +453,4 @@ export function isDerivedFilterTag(tag: unknown): boolean { 'kurz', 'lang', ].includes(key) -} \ No newline at end of file +} diff --git a/frontend/src/components/ui/videoHeatmap.ts b/frontend/src/components/ui/videoHeatmap.ts index 7fbe904..5754d92 100644 --- a/frontend/src/components/ui/videoHeatmap.ts +++ b/frontend/src/components/ui/videoHeatmap.ts @@ -77,6 +77,18 @@ function readScore(value: unknown): number { return 0.35 } +function readOptionalScore(value: unknown): number | null { + if (value == null || value === '') return null + + const n = Number(value) + if (!Number.isFinite(n)) return null + + if (n >= 0 && n <= 1) return clamp(n, 0, 1) + if (n > 1 && n <= 100) return clamp(n / 100, 0, 1) + + return null +} + type WeightedDetectedLabel = { label: string scoreWeight: number @@ -115,16 +127,34 @@ function weightedLabelFromValue(value: unknown): WeightedDetectedLabel | null { if (group === 'position') { return { label: prettyAiLabel(normalized), - scoreWeight: 1.0, - priority: 2, + scoreWeight: 0.82, + priority: 4, source: 'position', } } + if (group === 'body') { + return { + label: prettyAiLabel(normalized), + scoreWeight: 0.50, + priority: 3, + source: 'other', + } + } + + if (group === 'object') { + return { + label: prettyAiLabel(normalized), + scoreWeight: 0.15, + priority: 2, + source: 'other', + } + } + if (group === 'clothing') { return { label: prettyAiLabel(normalized), - scoreWeight: 0.58, + scoreWeight: 0.28, priority: 1, source: 'clothing', } @@ -237,56 +267,45 @@ export function buildVideoHeatmapSegments( ? (ai?.segments ?? ai?.Segments) : [] - for (const item of segments) { - if (!isPlainObject(item)) continue + const appendItems = (items: unknown[]) => { + for (const item of items) { + if (!isPlainObject(item)) continue - const detected = readWeightedLabel(item) - if (!detected) continue + const detected = readWeightedLabel(item) + if (!detected) continue - const rawScore = readScore( - item.score ?? item.Score ?? item.confidence - ) + const ratingIntensity = readOptionalScore( + item.ratingIntensity ?? item.RatingIntensity + ) + const rawScore = readScore( + item.score ?? item.Score ?? item.confidence + ) + const intensity = + ratingIntensity ?? + clamp(rawScore * detected.scoreWeight, 0, 1) - const weightedScore = clamp(rawScore * detected.scoreWeight, 0, 1) - - addSegment( + addSegment( out, item.startSeconds ?? item.StartSeconds ?? item.start ?? item.Start, item.endSeconds ?? item.EndSeconds ?? item.end ?? item.End, item.time ?? item.Time ?? item.at ?? item.timestamp, durationSec, detected.label, - weightedScore, + intensity, detected.source - ) + ) + } } - const hits = Array.isArray(ai?.hits ?? ai?.Hits) - ? (ai?.hits ?? ai?.Hits) - : [] + appendItems(segments) - for (const item of hits) { - if (!isPlainObject(item)) continue - - const detected = readWeightedLabel(item) - if (!detected) continue - - const rawScore = readScore( - item.score ?? item.Score ?? item.confidence - ) - - const weightedScore = clamp(rawScore * detected.scoreWeight, 0, 1) - - addSegment( - out, - item.startSeconds ?? item.StartSeconds ?? item.start ?? item.Start, - item.endSeconds ?? item.EndSeconds ?? item.end ?? item.End, - item.time ?? item.Time ?? item.at ?? item.timestamp, - durationSec, - detected.label, - weightedScore, - detected.source - ) + // Raw hits are the source of the condensed segments and would otherwise + // paint the same activity twice. Keep them only for legacy metadata. + if (out.length === 0) { + const hits = Array.isArray(ai?.hits ?? ai?.Hits) + ? (ai?.hits ?? ai?.Hits) + : [] + appendItems(hits) } return mergeHeatmapSegments(out, durationSec) @@ -326,4 +345,4 @@ function mergeHeatmapSegments( } return out -} \ No newline at end of file +}