nsfwapp/backend/rating.go
2026-06-12 09:21:35 +02:00

1495 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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