// backend\analyze.go package main import ( "bytes" "context" "encoding/json" "fmt" "image" "image/jpeg" "io" "math" "net/http" "os" "os/exec" "path/filepath" "runtime/debug" "sort" "strings" "sync" "syscall" "time" ) type analyzeVideoReq struct { JobID string `json:"jobId"` Output string `json:"output"` Mode string `json:"mode"` // "video" Goal string `json:"goal"` // "highlights" } type analyzeHit struct { Time float64 `json:"time"` Label string `json:"label"` Score float64 `json:"score,omitempty"` Start float64 `json:"start,omitempty"` End float64 `json:"end,omitempty"` } type analyzeVideoResp struct { OK bool `json:"ok"` Mode string `json:"mode,omitempty"` Goal string `json:"goal,omitempty"` Hits []analyzeHit `json:"hits"` Segments []aiSegmentMeta `json:"segments,omitempty"` Rating *aiRatingMeta `json:"rating,omitempty"` Error string `json:"error,omitempty"` } type videoFrameSample struct { Index int Time float64 Path string } const ( analyzeSegmentMergeGapSeconds = 8.0 // Ein Label darf kurz verschwinden, ohne dass ein neues Segment entsteht. // Bei 3s Frame-Intervall heißt das: ein fehlender Frame wird überbrückt. analyzeLabelInvisibleGraceSeconds = 3.0 // Video-Modus: extrahiert 1 Frame alle N Sekunden. // 1 = jeder Sekunde, 3 = alle 3 Sekunden, 5 = alle 5 Sekunden. analyzeVideoFrameIntervalSeconds = 3 // AI-Server nicht mit tausenden Pfaden auf einmal fluten. analyzeFramePredictBatchSize = 32 // > 0 = Frames auf diese Breite skalieren. // <= 0 = Originalgröße des Videos behalten. analyzeVideoFrameWidth = 0 // Lokaler optionaler Python-Inference-Server. // Kann per Environment überschrieben werden: // AI_SERVER_URL=http://127.0.0.1:8765 analyzeAIServerDefaultURL = "http://127.0.0.1:8765" ) const ( analyzeMinPositionScore = 0.30 analyzeMinBodyScore = 0.25 analyzeMinObjectScore = 0.28 analyzeMinClothingScore = 0.40 analyzeMinDetectorScore = 0.35 analyzeMinSingleSeverity = 0.60 analyzeMinSingleScore = 0.25 analyzeMinSingleQuality = 0.24 analyzeMinComboScore = 0.36 ) func analyzeVideoFrameFilter(intervalSeconds int) string { if intervalSeconds <= 0 { intervalSeconds = 1 } fps := fmt.Sprintf("fps=1/%d", intervalSeconds) // Originalgröße behalten. if analyzeVideoFrameWidth <= 0 { return fps } return fmt.Sprintf( "%s,scale=%d:-2:flags=fast_bilinear", fps, analyzeVideoFrameWidth, ) } func analyzeSingleFrameFilter() string { // Originalgröße behalten. if analyzeVideoFrameWidth <= 0 { return "" } return fmt.Sprintf( "scale=%d:-2:flags=fast_bilinear", analyzeVideoFrameWidth, ) } func autoSelectedAILabelSet() map[string]struct{} { grouped, err := trainingGroupedLabels() if err != nil { appLogln("⚠️ analyze labels fallback:", err) return map[string]struct{}{} } out := map[string]struct{}{} add := func(values []string) { for _, value := range values { label := strings.ToLower(strings.TrimSpace(value)) if label == "" || label == "unknown" { continue } out[label] = struct{}{} } } add(grouped.BodyParts) add(grouped.Objects) add(grouped.Clothing) add(grouped.SexPositions) return out } var autoSelectedAILabelsOnce sync.Once var autoSelectedAILabelsCache map[string]struct{} func shouldAutoSelectAnalyzeHit(label string) bool { label = strings.ToLower(strings.TrimSpace(label)) if label == "" || label == "unknown" { return false } labels := autoSelectedAILabelSet() _, ok := labels[label] return ok } var nsfwIgnoredLabels = map[string]struct{}{ // Personen sollen nicht als interessante Segmente auftauchen. "person_male": {}, "person_female": {}, } func isIgnoredNSFWLabel(label string) bool { label = strings.ToLower(strings.TrimSpace(label)) _, ok := nsfwIgnoredLabels[label] return ok } func addHighlightResult(best map[string]float64, label string, score float64) { label = strings.ToLower(strings.TrimSpace(label)) if label == "" || label == "unknown" { return } if score <= 0 { score = 1 } if old, ok := best[label]; !ok || score > old { best[label] = score } } func addScoredHighlightLabels(best map[string]float64, prefix string, items []TrainingScoredLabel) { prefix = strings.ToLower(strings.TrimSpace(prefix)) if prefix == "" { return } for _, item := range items { label := strings.ToLower(strings.TrimSpace(item.Label)) if label == "" || label == "unknown" { continue } addHighlightResult(best, prefix+":"+label, item.Score) } } func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameResult { best := map[string]float64{} sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition)) if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) { addHighlightResult(best, "position:"+sexPosition, pred.SexPositionScore) } addScoredHighlightLabels(best, "body", pred.BodyPartsPresent) addScoredHighlightLabels(best, "object", pred.ObjectsPresent) addScoredHighlightLabels(best, "clothing", pred.ClothingPresent) for _, box := range pred.Boxes { label := strings.ToLower(strings.TrimSpace(box.Label)) if label == "" || label == "unknown" { continue } if isIgnoredNSFWLabel(label) { continue } // Wichtig: // Keine beliebigen YOLO-/COCO-Labels als Highlights übernehmen. // Nur bewusst erlaubte Analyse-Labels anzeigen. if !shouldAutoSelectAnalyzeHit(label) { continue } addHighlightResult(best, "detector:"+label, box.Score) } // Kombis nur erzeugen, wenn wirklich Position + Zusatz vorhanden ist. if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) { positionScore := pred.SexPositionScore if positionScore <= 0 { positionScore = 1 } addCombo := func(prefix string, items []TrainingScoredLabel) { for _, item := range items { label := strings.ToLower(strings.TrimSpace(item.Label)) if label == "" || label == "unknown" { continue } score := item.Score if score <= 0 { score = 1 } comboScore := math.Min(positionScore, score) addHighlightResult(best, "combo:position:"+sexPosition+"+"+prefix+":"+label, comboScore) } } addCombo("body", pred.BodyPartsPresent) addCombo("object", pred.ObjectsPresent) addCombo("clothing", pred.ClothingPresent) } out := make([]NsfwFrameResult, 0, len(best)) for label, score := range best { out = append(out, NsfwFrameResult{ Label: label, Score: score, }) } sort.Slice(out, func(i, j int) bool { if out[i].Score == out[j].Score { return out[i].Label < out[j].Label } return out[i].Score > out[j].Score }) return out } func pickHighlightResults(results []NsfwFrameResult) []NsfwFrameResult { out := make([]NsfwFrameResult, 0, len(results)) for _, r := range results { label := strings.ToLower(strings.TrimSpace(r.Label)) if label == "" || label == "unknown" { continue } score := r.Score if score <= 0 { score = 1 } // Schwellen kannst du später pro Gruppe anders machen. switch { case strings.HasPrefix(label, "combo:"): if score < 0.35 { continue } case strings.HasPrefix(label, "position:"): if score < 0.30 { continue } case strings.HasPrefix(label, "object:"): if score < 0.30 { continue } case strings.HasPrefix(label, "clothing:"): if score < 0.30 { continue } case strings.HasPrefix(label, "body:"): if score < 0.30 { continue } case strings.HasPrefix(label, "detector:"): raw := strings.TrimPrefix(label, "detector:") if !shouldAutoSelectAnalyzeHit(raw) { continue } if score < 0.40 { continue } } out = append(out, NsfwFrameResult{ Label: label, Score: score, }) } sort.Slice(out, func(i, j int) bool { if out[i].Score == out[j].Score { return out[i].Label < out[j].Label } return out[i].Score > out[j].Score }) return out } func predictFrameForAnalyze(ctx context.Context, img image.Image) TrainingPrediction { _ = ctx if !trainingRecognitionEnabled() { return trainingEmptyPrediction("recognition_disabled") } // Fallback für alte Sprite-/Image-Pfade: // Wenn kein echter Video-Frame-Pfad vorhanden ist, schreiben wir NICHT mehr in os.TempDir(), // sondern in generated/training/analyze-temp, damit die Bilder auffindbar bleiben. root, err := trainingRootDir() if err != nil { return trainingEmptyPrediction("root_failed") } tmpDir := filepath.Join(root, "analyze-temp") if err := os.MkdirAll(tmpDir, 0755); err != nil { return trainingEmptyPrediction("mkdir_failed") } tmp, err := os.CreateTemp(tmpDir, "training-highlight-frame-*.jpg") if err != nil { return trainingEmptyPrediction("temp_failed") } tmpPath := tmp.Name() // Wichtig: NICHT löschen, damit du die Bilder kontrollieren kannst. // defer os.Remove(tmpPath) if err := jpeg.Encode(tmp, img, &jpeg.Options{Quality: 92}); err != nil { _ = tmp.Close() return trainingEmptyPrediction("encode_failed") } if err := tmp.Close(); err != nil { return trainingEmptyPrediction("close_failed") } return trainingPredictFrame(tmpPath) } func predictFramePathForAnalyze(ctx context.Context, framePath string) TrainingPrediction { _ = ctx if !trainingRecognitionEnabled() { return trainingEmptyPrediction("recognition_disabled") } return trainingPredictFrame(framePath) } type analyzeBatchPredictReq struct { Paths []string `json:"paths"` DetectorOnly bool `json:"detectorOnly"` ImageSize int `json:"imageSize,omitempty"` Model string `json:"model,omitempty"` } type analyzeBatchPredictResp struct { OK bool `json:"ok"` Predictions []TrainingPrediction `json:"predictions"` Error string `json:"error,omitempty"` } func analyzeAIServerURL() string { raw := strings.TrimSpace(os.Getenv("AI_SERVER_URL")) if raw == "" { raw = analyzeAIServerDefaultURL } return strings.TrimRight(raw, "/") } func trainingPredictFramePathsBatchForAnalyze( ctx context.Context, paths []string, detectorOnly bool, ) ([]TrainingPrediction, error) { cleanPaths := make([]string, 0, len(paths)) for _, path := range paths { path = strings.TrimSpace(path) if path == "" { continue } cleanPaths = append(cleanPaths, path) } if len(cleanPaths) == 0 { return nil, appErrorf("keine frame-pfade für batch prediction") } if !trainingRecognitionEnabled() { out := make([]TrainingPrediction, 0, len(cleanPaths)) for range cleanPaths { out = append(out, trainingEmptyPrediction("recognition_disabled")) } return out, nil } payload := analyzeBatchPredictReq{ Paths: cleanPaths, DetectorOnly: detectorOnly, } if analyzeVideoFrameWidth > 0 { payload.ImageSize = analyzeVideoFrameWidth } body, err := json.Marshal(payload) if err != nil { return nil, err } url := analyzeAIServerURL() + "/predict-batch" req, err := http.NewRequestWithContext( ctx, http.MethodPost, url, bytes.NewReader(body), ) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") addAIServerAuth(req) client := &http.Client{ Timeout: 120 * time.Second, } res, err := client.Do(req) if err != nil { if ctxErr := ctx.Err(); ctxErr != nil { return nil, ctxErr } return nil, err } defer res.Body.Close() rawBody, readErr := io.ReadAll(res.Body) if readErr != nil { if ctxErr := ctx.Err(); ctxErr != nil { return nil, ctxErr } return nil, readErr } var parsed analyzeBatchPredictResp if err := json.Unmarshal(rawBody, &parsed); err != nil { if ctxErr := ctx.Err(); ctxErr != nil { return nil, ctxErr } appLogf( "❌ [analyze] AI server invalid JSON status=%d url=%s body=%s", res.StatusCode, url, strings.TrimSpace(string(rawBody)), ) return nil, appErrorf( "AI server lieferte ungültiges JSON: HTTP %d: %s", res.StatusCode, strings.TrimSpace(string(rawBody)), ) } if res.StatusCode < 200 || res.StatusCode >= 300 || !parsed.OK { msg := strings.TrimSpace(parsed.Error) if msg == "" { msg = fmt.Sprintf("AI server HTTP %d", res.StatusCode) } appLogf( "❌ [analyze] AI server error status=%d url=%s error=%s body=%s", res.StatusCode, url, msg, strings.TrimSpace(string(rawBody)), ) return nil, appErrorf("%s", msg) } if len(parsed.Predictions) == 0 { return nil, appErrorf("AI server lieferte keine predictions") } return parsed.Predictions, nil } func extractVideoFrameAt(ctx context.Context, outPath string, atSec float64) (image.Image, error) { tmp, err := os.CreateTemp("", "nsfw-frame-*.jpg") if err != nil { return nil, err } tmpPath := tmp.Name() _ = tmp.Close() defer os.Remove(tmpPath) ffmpegPath := strings.TrimSpace(getSettings().FFmpegPath) if ffmpegPath == "" { ffmpegPath = "ffmpeg" } args := []string{ "-ss", fmt.Sprintf("%.3f", atSec), "-i", outPath, "-frames:v", "1", } if vf := analyzeSingleFrameFilter(); vf != "" { args = append(args, "-vf", vf) } args = append(args, "-q:v", "2", "-y", tmpPath, ) cmd := exec.CommandContext(ctx, ffmpegPath, args...) cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, CreationFlags: 0x08000000, // CREATE_NO_WINDOW } if out, err := cmd.CombinedOutput(); err != nil { return nil, appErrorf("ffmpeg fehlgeschlagen: %v: %s", err, strings.TrimSpace(string(out))) } f, err := os.Open(tmpPath) if err != nil { return nil, err } defer f.Close() img, _, err := image.Decode(f) if err != nil { return nil, err } return img, nil } func analyzeFramesDirForOutput(outPath string) (string, error) { id := strings.TrimSpace(videoIDFromOutputPath(outPath)) if id == "" { return "", appErrorf("konnte keine video-id aus output ableiten") } metaPath, err := generatedMetaFile(id) if err != nil || strings.TrimSpace(metaPath) == "" { return "", appErrorf("meta.json nicht gefunden") } return filepath.Join(filepath.Dir(metaPath), "frames"), nil } func cleanupAnalyzeFramesDirForOutput(outPath string) error { framesDir, err := analyzeFramesDirForOutput(outPath) if err != nil { return err } // Sicherheitscheck: niemals versehentlich etwas anderes löschen. if filepath.Base(framesDir) != "frames" { return appErrorf("cleanup abgebrochen: unerwarteter frames-ordner: %s", framesDir) } // Erst alle JPGs im frames-Ordner löschen. patterns := []string{ filepath.Join(framesDir, "*.jpg"), filepath.Join(framesDir, "*.jpeg"), } for _, pattern := range patterns { files, globErr := filepath.Glob(pattern) if globErr != nil { return globErr } for _, file := range files { if removeErr := os.Remove(file); removeErr != nil && !os.IsNotExist(removeErr) { return removeErr } } } // Danach den Ordner selbst löschen. // Das klappt nur, wenn er leer ist. Falls dort andere Dateien liegen, // bleibt der Ordner absichtlich bestehen. if err := os.Remove(framesDir); err != nil && !os.IsNotExist(err) { return err } return nil } func extractVideoFramesBatch( ctx context.Context, outPath string, durationSec float64, intervalSeconds int, onExtracted func(current int, expected int), ) ([]videoFrameSample, func(), error) { if durationSec <= 0 { return nil, nil, appErrorf("videolänge fehlt") } if intervalSeconds <= 0 { intervalSeconds = 1 } framesDir, err := analyzeFramesDirForOutput(outPath) if err != nil { return nil, nil, err } if err := os.MkdirAll(framesDir, 0755); err != nil { return nil, nil, err } // Alte Analyse-Frames entfernen, damit keine stale Frames mitgelesen werden. oldFrames, _ := filepath.Glob(filepath.Join(framesDir, "analyze-frame-*.jpg")) for _, oldFrame := range oldFrames { _ = os.Remove(oldFrame) } cleanup := func() { if err := cleanupAnalyzeFramesDirForOutput(outPath); err != nil { appLogln("⚠️ frames cleanup:", err) } } ffmpegPath := strings.TrimSpace(getSettings().FFmpegPath) if ffmpegPath == "" { ffmpegPath = "ffmpeg" } pattern := filepath.Join(framesDir, "analyze-frame-%06d.jpg") // fps=1/ bedeutet: // intervalSeconds=1 -> 1 Frame pro Sekunde // intervalSeconds=2 -> 1 Frame alle 2 Sekunden // intervalSeconds=5 -> 1 Frame alle 5 Sekunden vf := analyzeVideoFrameFilter(intervalSeconds) cmd := exec.CommandContext( ctx, ffmpegPath, "-hide_banner", "-loglevel", "error", "-i", outPath, "-vf", vf, "-q:v", "4", "-fps_mode", "vfr", "-y", pattern, ) cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, CreationFlags: 0x08000000, } expectedFrames := int(math.Ceil(durationSec / float64(intervalSeconds))) if expectedFrames < 1 { expectedFrames = 1 } emitExtractProgress := func(current int) { if onExtracted == nil { return } if current < 0 { current = 0 } if current > expectedFrames { current = expectedFrames } onExtracted(current, expectedFrames) } countExtractedFrames := func() int { files, err := filepath.Glob(filepath.Join(framesDir, "analyze-frame-*.jpg")) if err != nil { return 0 } return len(files) } emitExtractProgress(0) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Start(); err != nil { return nil, nil, appErrorf( "ffmpeg frames extrahieren konnte nicht gestartet werden: %w", err, ) } done := make(chan error, 1) go func() { done <- cmd.Wait() }() lastCount := -1 ticker := time.NewTicker(250 * time.Millisecond) defer ticker.Stop() waiting := true for waiting { select { case <-ctx.Done(): if cmd.Process != nil { _ = cmd.Process.Kill() } select { case <-done: case <-time.After(2 * time.Second): } return nil, nil, ctx.Err() case waitErr := <-done: waiting = false count := countExtractedFrames() if count != lastCount { lastCount = count emitExtractProgress(count) } if waitErr != nil { msg := strings.TrimSpace(stderr.String()) appLogf( "❌ [analyze] ffmpeg frame extract failed input=%q err=%v stderr=%s", outPath, waitErr, msg, ) return nil, nil, appErrorf( "ffmpeg frames extrahieren fehlgeschlagen: %v: %s", waitErr, msg, ) } case <-ticker.C: count := countExtractedFrames() if count != lastCount { lastCount = count emitExtractProgress(count) } } } files, err := filepath.Glob(filepath.Join(framesDir, "analyze-frame-*.jpg")) if err != nil { return nil, nil, err } sort.Strings(files) if len(files) == 0 { return nil, nil, appErrorf("ffmpeg hat keine frames erzeugt") } // Nach ffmpeg-Ende noch einmal den echten Endstand melden. // Das sorgt dafür, dass die Extraktionshälfte zuverlässig bei 50% landen kann. emitExtractProgress(len(files)) out := make([]videoFrameSample, 0, len(files)) for i, path := range files { t := float64(i * intervalSeconds) if t < 0 { t = 0 } if durationSec > 0 && t > durationSec { t = durationSec } out = append(out, videoFrameSample{ Index: i, Time: t, Path: path, }) } return out, cleanup, nil } func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { if !mustMethod(w, r, http.MethodPost) { return } var req analyzeVideoReq file := "" outPath := "" defer func() { if rec := recover(); rec != nil { msg := fmt.Sprintf("panic in analyse: %v", rec) appLogln("❌ [analyze]", msg) appLogln("❌ [analyze] stack:\n" + string(debug.Stack())) respondJSON(w, analyzeVideoResp{ OK: false, Mode: "video", Goal: "highlights", Hits: []analyzeHit{}, Error: msg, }) } }() if err := json.NewDecoder(r.Body).Decode(&req); err != nil { logAnalyzeError("decode-request", "", "", err) respondJSON(w, analyzeVideoResp{ OK: false, Mode: "video", Goal: "highlights", Hits: []analyzeHit{}, Error: "ungültiger body: " + err.Error(), }) return } req.Mode = "video" req.Goal = "highlights" outPath = strings.TrimSpace(req.Output) file = filepath.Base(outPath) appLogf("🧪 [analyze] request file=%q path=%q", file, outPath) if outPath == "" { err := appErrorf("output fehlt") logAnalyzeError("validate-output", file, outPath, err) respondJSON(w, analyzeVideoResp{ OK: false, Mode: req.Mode, Goal: req.Goal, Hits: []analyzeHit{}, Error: err.Error(), }) return } fi, err := os.Stat(outPath) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { if err == nil { err = appErrorf("output datei nicht gefunden oder leer") } logAnalyzeError("stat-output", file, outPath, err) respondJSON(w, analyzeVideoResp{ OK: false, Mode: req.Mode, Goal: req.Goal, Hits: []analyzeHit{}, Error: "output datei nicht gefunden: " + err.Error(), }) return } appLogf( "🧪 [analyze] file ok file=%q size=%d mod=%s", file, fi.Size(), fi.ModTime().Format(time.RFC3339), ) ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute) defer cancel() hits, err := analyzeVideoFromFrames(ctx, outPath) if err != nil { logAnalyzeError("analyze-frames", file, outPath, err) respondJSON(w, analyzeVideoResp{ OK: false, Mode: req.Mode, Goal: req.Goal, Hits: []analyzeHit{}, Error: err.Error(), }) return } appLogf("🧪 [analyze] hits file=%q count=%d", file, len(hits)) durationSec, derr := durationSecondsForAnalyze(ctx, outPath) if derr != nil { logAnalyzeError("duration-after-analyze", file, outPath, derr) } if durationSec <= 0 { err := appErrorf("videolänge konnte nach analyse nicht bestimmt werden") logAnalyzeError("duration-invalid", file, outPath, err) respondJSON(w, analyzeVideoResp{ OK: false, Mode: req.Mode, Goal: req.Goal, Hits: hits, Error: err.Error(), }) return } segments := buildAnalyzeSegmentsForGoal(hits, durationSec) appLogf("🧪 [analyze] raw segments file=%q count=%d", file, len(segments)) segments = prepareAIRatingSegments(segments) appLogf("🧪 [analyze] rating segments file=%q count=%d", file, len(segments)) rating := computeHighlightRatingForVideo(segments, durationSec, outPath) ai := &aiAnalysisMeta{ Goal: "highlights", Mode: "video", Hits: hits, Segments: segments, Rating: rating, AnalyzedAtUnix: time.Now().Unix(), } if err := writeVideoAIForFile(ctx, outPath, "", ai); err != nil { logAnalyzeError("write-meta-ai", file, outPath, err) respondJSON(w, analyzeVideoResp{ OK: false, Mode: req.Mode, Goal: req.Goal, Hits: hits, Segments: segments, Rating: rating, Error: "analyse fertig, aber meta konnte nicht gespeichert werden: " + err.Error(), }) return } appLogf( "✅ [analyze] done file=%q hits=%d segments=%d rating=%.1f stars=%d", file, len(hits), len(segments), func() float64 { if rating == nil { return 0 } return rating.Score }(), func() int { if rating == nil { return 0 } return rating.Stars }(), ) respondJSON(w, analyzeVideoResp{ OK: true, Mode: req.Mode, Goal: req.Goal, Hits: hits, Segments: segments, Rating: rating, }) } type highlightSignal struct { Label string Score float64 Group string } func normalizeHighlightSignalLabel(label string) string { label = strings.ToLower(strings.TrimSpace(label)) if label == "" || label == "unknown" { return "" } switch { case strings.HasPrefix(label, "combo:"): // Bestehende alte Combos hier nicht weiterverwenden, // weil wir ab jetzt selbst saubere Kombis bauen. return "" case strings.HasPrefix(label, "detector:"): raw := strings.TrimPrefix(label, "detector:") if !shouldAutoSelectAnalyzeHit(raw) { return "" } return "detector:" + raw case strings.HasPrefix(label, "body:"): raw := strings.TrimPrefix(label, "body:") if raw == "" || raw == "unknown" { return "" } return "body:" + raw case strings.HasPrefix(label, "object:"): raw := strings.TrimPrefix(label, "object:") if raw == "" || raw == "unknown" { return "" } return "object:" + raw case strings.HasPrefix(label, "clothing:"): raw := strings.TrimPrefix(label, "clothing:") if raw == "" || raw == "unknown" { return "" } return "clothing:" + raw case strings.HasPrefix(label, "position:"): raw := strings.TrimPrefix(label, "position:") if raw == "" || raw == "unknown" || !isKnownPositionLabel(raw) { return "" } return "position:" + raw default: if isIgnoredNSFWLabel(label) { return "" } if isKnownPositionLabel(label) { return "position:" + label } if shouldAutoSelectAnalyzeHit(label) { return "detector:" + label } return "" } } func highlightSignalGroup(label string) string { label = strings.ToLower(strings.TrimSpace(label)) switch { case strings.HasPrefix(label, "position:"): return "position" case strings.HasPrefix(label, "body:"): return "body" case strings.HasPrefix(label, "object:"): return "object" case strings.HasPrefix(label, "clothing:"): return "clothing" case strings.HasPrefix(label, "detector:"): raw := strings.TrimPrefix(label, "detector:") switch { case bodyPartSeverityWeight(raw) >= 0.65: return "body" case objectSeverityWeight(raw) >= 0.55: return "object" case clothingSeverityWeight(raw) >= 0.50: return "clothing" default: return "detector" } default: return "other" } } func rawAnalyzeSignalSeverityWeight(label string) float64 { label = normalizeHighlightSignalLabel(label) if label == "" { return 0 } raw := normalizeSegmentLabel(label) if raw == "" || raw == "unknown" { return 0 } switch { case strings.HasPrefix(label, "position:"): return positionSeverityWeight(raw) case strings.HasPrefix(label, "body:"): return bodyPartSeverityWeight(raw) case strings.HasPrefix(label, "object:"): return objectSeverityWeight(raw) case strings.HasPrefix(label, "clothing:"): return clothingSeverityWeight(raw) case strings.HasPrefix(label, "detector:"): return detectorSeverityWeight(raw) default: return detectorSeverityWeight(raw) } } func highlightSignalInterestingEnough(label string, score float64) bool { label = normalizeHighlightSignalLabel(label) if label == "" { return false } if score <= 0 { score = 1 } rawSev := rawAnalyzeSignalSeverityWeight(label) switch { case strings.HasPrefix(label, "position:"): return score >= analyzeMinPositionScore && rawSev > 0 case strings.HasPrefix(label, "body:"): return score >= analyzeMinBodyScore && rawSev >= 0.65 case strings.HasPrefix(label, "object:"): return score >= analyzeMinObjectScore && rawSev >= 0.50 case strings.HasPrefix(label, "clothing:"): return score >= analyzeMinClothingScore && rawSev >= 0.50 case strings.HasPrefix(label, "detector:"): return score >= analyzeMinDetectorScore && rawSev >= 0.60 default: return false } } func addHighlightSignal(best map[string]highlightSignal, label string, score float64) { label = normalizeHighlightSignalLabel(label) if label == "" { return } if !highlightSignalInterestingEnough(label, score) { return } if score <= 0 { score = 1 } key := normalizeSegmentLabel(label) if key == "" { return } sig := highlightSignal{ Label: label, Score: score, Group: highlightSignalGroup(label), } if old, ok := best[key]; !ok || sig.Score > old.Score { best[key] = sig } } func addHighlightSignalsFromScoredLabels( best map[string]highlightSignal, prefix string, items []TrainingScoredLabel, ) { prefix = strings.ToLower(strings.TrimSpace(prefix)) if prefix == "" { return } for _, item := range items { label := strings.ToLower(strings.TrimSpace(item.Label)) if label == "" || label == "unknown" { continue } addHighlightSignal(best, prefix+":"+label, item.Score) } } func highlightComboPartOrder(label string) int { label = strings.ToLower(strings.TrimSpace(label)) switch { case strings.HasPrefix(label, "position:"): return 0 case strings.HasPrefix(label, "body:"): return 1 case strings.HasPrefix(label, "object:"): return 2 case strings.HasPrefix(label, "clothing:"): return 3 case strings.HasPrefix(label, "detector:"): return 4 default: return 9 } } func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64) (analyzeHit, bool) { if !pred.ModelAvailable { return analyzeHit{}, false } best := map[string]highlightSignal{} sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition)) 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) addHighlightSignalsFromScoredLabels(best, "object", pred.ObjectsPresent) addHighlightSignalsFromScoredLabels(best, "clothing", pred.ClothingPresent) for _, box := range pred.Boxes { label := strings.ToLower(strings.TrimSpace(box.Label)) if label == "" || label == "unknown" { continue } if isIgnoredNSFWLabel(label) { continue } if !shouldAutoSelectAnalyzeHit(label) { continue } addHighlightSignal(best, "detector:"+label, box.Score) } signals := make([]highlightSignal, 0, len(best)) groupSeen := map[string]bool{} nonPositionCount := 0 hasPosition := false var bestSingle highlightSignal var bestSingleQuality float64 for _, sig := range best { if sig.Label == "" { continue } if sig.Group == "position" { hasPosition = true } else { nonPositionCount++ sev := segmentSeverityWeight(sig.Label) quality := sig.Score * sev if quality > bestSingleQuality { bestSingle = sig bestSingleQuality = quality } } groupSeen[sig.Group] = true signals = append(signals, sig) } // Fallback: starke Einzel-Treffer sollen auch Highlights werden. // Sonst verschwinden viele explizite Stellen, wenn sie nicht zufällig // im selben Frame mit Position/Object/Clothing kombiniert werden. returnSingleIfGoodEnough := func() (analyzeHit, bool) { if bestSingle.Label == "" { return analyzeHit{}, false } sev := segmentSeverityWeight(bestSingle.Label) // Nur wirklich relevante Einzel-Signale übernehmen. // Die Grenzwerte stehen oben als Konstanten, damit du sie später // leichter feintunen kannst: // // analyzeMinSingleSeverity = 0.60 // analyzeMinSingleScore = 0.25 // analyzeMinSingleQuality = 0.24 if sev < analyzeMinSingleSeverity { return analyzeHit{}, false } if bestSingle.Score < analyzeMinSingleScore { return analyzeHit{}, false } if bestSingleQuality < analyzeMinSingleQuality { return analyzeHit{}, false } return analyzeHit{ Time: t, Label: bestSingle.Label, Score: bestSingle.Score, Start: t, End: t, }, true } // Combo nur, wenn wirklich genug Kontext da ist. if len(signals) < 2 { return returnSingleIfGoodEnough() } if hasPosition && nonPositionCount < 1 { return returnSingleIfGoodEnough() } if !hasPosition && nonPositionCount < 2 { return returnSingleIfGoodEnough() } if len(groupSeen) < 2 && len(signals) < 3 { return returnSingleIfGoodEnough() } sort.SliceStable(signals, func(i, j int) bool { oi := highlightComboPartOrder(signals[i].Label) oj := highlightComboPartOrder(signals[j].Label) if oi != oj { return oi < oj } wi := segmentSeverityWeight(signals[i].Label) * signals[i].Score wj := segmentSeverityWeight(signals[j].Label) * signals[j].Score if wi != wj { return wi > wj } return signals[i].Label < signals[j].Label }) // Nicht zu lange Titel bauen. if len(signals) > 4 { signals = signals[:4] } parts := make([]string, 0, len(signals)) var scoreSum float64 var scoreWeightSum float64 var maxWeighted float64 for _, sig := range signals { parts = append(parts, sig.Label) sev := segmentSeverityWeight(sig.Label) if sev <= 0 { sev = 0.5 } weighted := sig.Score * sev scoreSum += sig.Score * sev scoreWeightSum += sev if weighted > maxWeighted { maxWeighted = weighted } } if len(parts) < 2 || scoreWeightSum <= 0 { return analyzeHit{}, false } avgScore := scoreSum / scoreWeightSum // Kombi-Score: stärkstes Signal + Durchschnitt. score := 0.65*maxWeighted + 0.35*avgScore if score > 1 { score = 1 } if score <= 0 { score = avgScore } // Noch einmal Mindestqualität prüfen. if score < analyzeMinComboScore { return analyzeHit{}, false } return analyzeHit{ Time: t, Label: "combo:" + strings.Join(parts, "+"), Score: score, Start: t, End: t, }, true } func buildHighlightHitsFromPrediction(pred TrainingPrediction, t float64) []analyzeHit { if !pred.ModelAvailable { return nil } best := map[string]highlightSignal{} sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition)) if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) { addHighlightSignal(best, "position:"+sexPosition, pred.SexPositionScore) } addHighlightSignalsFromScoredLabels(best, "body", pred.BodyPartsPresent) addHighlightSignalsFromScoredLabels(best, "object", pred.ObjectsPresent) addHighlightSignalsFromScoredLabels(best, "clothing", pred.ClothingPresent) for _, box := range pred.Boxes { label := strings.ToLower(strings.TrimSpace(box.Label)) if label == "" || label == "unknown" { continue } if isIgnoredNSFWLabel(label) { continue } if !shouldAutoSelectAnalyzeHit(label) { continue } addHighlightSignal(best, "detector:"+label, box.Score) } out := make([]analyzeHit, 0, len(best)) for _, sig := range best { label := strings.ToLower(strings.TrimSpace(sig.Label)) if label == "" { continue } // Schwache Positions-Kontexte wie standing/sitting nicht alleine als Segment anzeigen. if sig.Group == "position" && segmentSeverityWeight(label) < 0.70 { continue } out = append(out, analyzeHit{ Time: t, Label: label, Score: sig.Score, Start: t, End: t, }) } sort.SliceStable(out, func(i, j int) bool { wi := segmentSeverityWeight(out[i].Label) * out[i].Score wj := segmentSeverityWeight(out[j].Label) * out[j].Score if wi != wj { return wi > wj } return out[i].Label < out[j].Label }) return out } func appendHighlightHitsFromPrediction( hits []analyzeHit, pred TrainingPrediction, t float64, ) []analyzeHit { // Wichtig: // Wenn eine Kombi erkannt wurde, KEINE Einzel-Hits aus demselben Frame // zusätzlich anhängen. Sonst entstehen z. B. gleichzeitig: // - combo:position:toy_play+object:vibrator // - object:vibrator if combined, ok := buildCombinedHighlightHitFromPrediction(pred, t); ok { return append(hits, combined) } next := buildHighlightHitsFromPrediction(pred, t) if len(next) == 0 { return hits } return append(hits, next...) } func analyzeVideoFromFrames(ctx context.Context, outPath string) ([]analyzeHit, error) { return analyzeVideoFromFramesForGoal(ctx, outPath) } const analyzeProgressTotal = 1000 func logAnalyzeError(stage string, file string, outPath string, err error) { if err == nil { return } appLogf( "❌ [analyze] stage=%s file=%q path=%q err=%v", strings.TrimSpace(stage), strings.TrimSpace(file), strings.TrimSpace(outPath), err, ) } func logAnalyzeInfo(stage string, file string, format string, args ...any) { prefix := fmt.Sprintf("🧪 [analyze] stage=%s file=%q ", strings.TrimSpace(stage), strings.TrimSpace(file)) appLogf(prefix+format, args...) } func publishAnalyzeExtractProgress( startedAtMs int64, file string, progress float64, message string, ) { progress = math.Max(0, math.Min(1, progress)) current := int(math.Round(progress * 0.5 * analyzeProgressTotal)) message = strings.TrimSpace(message) if message == "" || strings.EqualFold(message, "Analyse") || strings.Contains(message, "Frames werden extrahiert") { message = analyzeGlobalPercentMessageFromCurrent(current, analyzeProgressTotal) } publishAnalysisStep( startedAtMs, current, analyzeProgressTotal, file, message, ) } func analyzePercentMessage(currentFrame int, totalFrames int) string { if totalFrames <= 0 { totalFrames = 1 } ratio := float64(currentFrame) / float64(totalFrames) ratio = math.Max(0, math.Min(1, ratio)) percent := int(math.Round(ratio * 100)) if percent < 0 { percent = 0 } if percent > 100 { percent = 100 } return fmt.Sprintf("Analyse %d%%", percent) } func publishAnalyzeInferenceProgress( startedAtMs int64, file string, currentFrame int, totalFrames int, message string, ) { if totalFrames <= 0 { totalFrames = 1 } ratio := float64(currentFrame) / float64(totalFrames) ratio = math.Max(0, math.Min(1, ratio)) current := int(math.Round((0.5 + ratio*0.5) * analyzeProgressTotal)) message = strings.TrimSpace(message) if message == "" || strings.EqualFold(message, "Analyse") { message = analyzeGlobalPercentMessageFromCurrent(current, analyzeProgressTotal) } publishAnalysisStep( startedAtMs, current, analyzeProgressTotal, file, message, ) } func analyzeGlobalPercentFromCurrent(current int, total int) int { if total <= 0 { total = analyzeProgressTotal } ratio := float64(current) / float64(total) ratio = math.Max(0, math.Min(1, ratio)) percent := int(math.Round(ratio * 100)) if percent < 0 { return 0 } if percent > 100 { return 100 } return percent } func analyzeGlobalPercentMessageFromCurrent(current int, total int) string { return fmt.Sprintf( "Analyse %d%%", analyzeGlobalPercentFromCurrent(current, total), ) } func analyzeVideoFromFramesForGoal( ctx context.Context, outPath string, ) (highlightHits []analyzeHit, err error) { file := filepath.Base(strings.TrimSpace(outPath)) startedAtMs := publishAnalysisStarted(file, analyzeProgressTotal, "Analyse 0%") publishAnalyzeExtractProgress( startedAtMs, file, 0, "Analyse 0%", ) if err := ctx.Err(); err != nil { publishAnalysisError(startedAtMs, file, "Analyse abgebrochen", err) return nil, err } durationSec, _ := durationSecondsForAnalyze(ctx, outPath) if err := ctx.Err(); err != nil { publishAnalysisError(startedAtMs, file, "Analyse abgebrochen", err) return nil, err } if durationSec <= 0 { err := appErrorf("videolänge konnte nicht bestimmt werden") publishAnalysisError(startedAtMs, file, "Analyse fehlgeschlagen", err) return nil, err } publishPercentRange := func(lastPercent *int, nextPercent int, current int, total int, extractPhase bool) { if total <= 0 { total = 1 } if nextPercent < 0 { nextPercent = 0 } if nextPercent > 100 { nextPercent = 100 } if nextPercent <= *lastPercent { return } for p := *lastPercent + 1; p <= nextPercent; p++ { label := fmt.Sprintf("Analyse %d%%", p) if extractPhase { ratio := float64(p) / 50.0 if ratio < 0 { ratio = 0 } if ratio > 1 { ratio = 1 } publishAnalyzeExtractProgress( startedAtMs, file, ratio, label, ) } else { inferenceCurrent := current inferenceTotal := total if p >= 50 { inferenceTotal = 50 inferenceCurrent = p - 50 } publishAnalyzeInferenceProgress( startedAtMs, file, inferenceCurrent, inferenceTotal, label, ) } } *lastPercent = nextPercent } failCancelled := func() ([]analyzeHit, error) { err := ctx.Err() if err == nil { err = context.Canceled } publishAnalysisError(startedAtMs, file, "Analyse abgebrochen", err) return nil, err } lastExtractPercent := 0 samples, cleanup, err := extractVideoFramesBatch( ctx, outPath, durationSec, analyzeVideoFrameIntervalSeconds, func(current int, expected int) { if expected <= 0 { expected = 1 } if current < 0 { current = 0 } if current > expected { current = expected } ratio := float64(current) / float64(expected) ratio = math.Max(0, math.Min(1, ratio)) globalPercent := int(math.Round(ratio * 50)) publishPercentRange( &lastExtractPercent, globalPercent, current, expected, true, ) }, ) if cleanup != nil { defer cleanup() } if ctx.Err() != nil { return failCancelled() } if err != nil { publishAnalysisError(startedAtMs, file, "Frames konnten nicht extrahiert werden", err) return nil, err } if len(samples) == 0 { err := appErrorf("keine frame-samples vorhanden") publishAnalysisError(startedAtMs, file, "Keine Frames vorhanden", err) return nil, err } total := len(samples) if lastExtractPercent < 50 { publishPercentRange( &lastExtractPercent, 50, total, total, true, ) } if ctx.Err() != nil { return failCancelled() } paths := make([]string, 0, len(samples)) for _, sample := range samples { if ctx.Err() != nil { return failCancelled() } paths = append(paths, sample.Path) } lastInferencePercent := 50 publishAnalyzeInferenceProgress( startedAtMs, file, 0, total, "Analyse 50%", ) if ctx.Err() != nil { return failCancelled() } batchOK := true detectorOnly := false for startIdx := 0; startIdx < len(samples); startIdx += analyzeFramePredictBatchSize { if ctx.Err() != nil { return failCancelled() } endIdx := startIdx + analyzeFramePredictBatchSize if endIdx > len(samples) { endIdx = len(samples) } predictions, batchErr := trainingPredictFramePathsBatchForAnalyze( ctx, paths[startIdx:endIdx], detectorOnly, ) if ctx.Err() != nil { return failCancelled() } if batchErr != nil || len(predictions) < endIdx-startIdx { if batchErr != nil { appLogf( "❌ [analyze] batch failed file=%q batch=%d:%d err=%v", file, startIdx, endIdx, batchErr, ) } else { appLogf( "❌ [analyze] batch returned too few predictions file=%q batch=%d:%d got=%d expected=%d", file, startIdx, endIdx, len(predictions), endIdx-startIdx, ) } appLogln("⚠️ video batch analyse fehlgeschlagen, fallback auf einzelbild-analyse") batchOK = false highlightHits = nil lastInferencePercent = 50 publishAnalyzeInferenceProgress( startedAtMs, file, 0, total, "Analyse 50%", ) break } for i := 0; i < endIdx-startIdx; i++ { if ctx.Err() != nil { return failCancelled() } sample := samples[startIdx+i] pred := predictions[i] highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, sample.Time) } globalPercent := 50 + int(math.Round((float64(endIdx)/float64(total))*50)) if globalPercent > 100 { globalPercent = 100 } publishPercentRange( &lastInferencePercent, globalPercent, endIdx, total, false, ) } if batchOK { if ctx.Err() != nil { return failCancelled() } if lastInferencePercent < 100 { publishPercentRange( &lastInferencePercent, 100, total, total, false, ) } cleanHighlightHits := mergeAnalyzeHits(highlightHits) publishAnalysisFinished(startedAtMs, total, file, "Analyse abgeschlossen") return cleanHighlightHits, nil } // Fallback: langsame Einzelbild-Analyse, nur wenn der AI-Server-Batch fehlschlägt. for i, sample := range samples { if ctx.Err() != nil { return failCancelled() } pred := predictFramePathForAnalyze(ctx, sample.Path) if ctx.Err() != nil { return failCancelled() } highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, sample.Time) current := i + 1 globalPercent := 50 + int(math.Round((float64(current)/float64(total))*50)) if globalPercent > 100 { globalPercent = 100 } publishPercentRange( &lastInferencePercent, globalPercent, current, total, false, ) } if ctx.Err() != nil { return failCancelled() } if lastInferencePercent < 100 { publishPercentRange( &lastInferencePercent, 100, total, total, false, ) } cleanHighlightHits := mergeAnalyzeHits(highlightHits) publishAnalysisFinished(startedAtMs, total, file, "Analyse abgeschlossen") return cleanHighlightHits, nil } func sameAnalyzeComboLabel(a, b string) bool { a = strings.ToLower(strings.TrimSpace(a)) b = strings.ToLower(strings.TrimSpace(b)) if !strings.HasPrefix(a, "combo:") || !strings.HasPrefix(b, "combo:") { return false } parse := func(label string) (position string, parts map[string]bool) { raw := strings.TrimPrefix(label, "combo:") parts = map[string]bool{} for _, part := range strings.Split(raw, "+") { part = strings.ToLower(strings.TrimSpace(part)) if part == "" { continue } if strings.HasPrefix(part, "position:") { position = strings.TrimPrefix(part, "position:") continue } normalized := normalizeSegmentLabel(part) if normalized != "" { parts[normalized] = true } } return position, parts } posA, partsA := parse(a) posB, partsB := parse(b) // Unterschiedliche klare Hauptpositionen nicht zusammenführen. // Beispiel: doggy != missionary if posA != "" && posB != "" && posA != posB { return false } // Wenn beide keine gemeinsame Kontext-Komponente haben, nicht mergen. // Beispiel: // combo:position:doggy+object:dildo // combo:position:doggy+clothing:lingerie // => kein gemeinsames Nicht-Positionssignal, also getrennt lassen. for part := range partsA { if partsB[part] { return true } } return false } func sameAnalyzeSegmentLabel(a, b string) bool { a = strings.ToLower(strings.TrimSpace(a)) b = strings.ToLower(strings.TrimSpace(b)) if strings.HasPrefix(a, "combo:") || strings.HasPrefix(b, "combo:") { return sameAnalyzeComboLabel(a, b) } return normalizeSegmentLabel(a) == normalizeSegmentLabel(b) } func preferAnalyzeSegmentLabel(a, b string) string { a = strings.ToLower(strings.TrimSpace(a)) b = strings.ToLower(strings.TrimSpace(b)) if a == "" { return b } if b == "" { 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 } if strings.HasPrefix(b, "body:") && !strings.HasPrefix(a, "body:") { return b } // object:/clothing:/position: ebenfalls sauberer als detector: preferredPrefix := func(s string) bool { return strings.HasPrefix(s, "object:") || strings.HasPrefix(s, "clothing:") || strings.HasPrefix(s, "position:") || strings.HasPrefix(s, "combo:") } if preferredPrefix(a) && strings.HasPrefix(b, "detector:") { return a } if preferredPrefix(b) && strings.HasPrefix(a, "detector:") { return b } // Sonst kürzeres Label behalten, z. B. breasts statt detector:breasts. if len(b) < len(a) { return b } 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 analyzeHitContinuationGapSeconds() float64 { // Treffer bei 0s und 6s sollen bei 3s Sampling noch zusammengehören: // 0s erkannt, 3s nicht erkannt, 6s wieder erkannt. return float64(analyzeVideoFrameIntervalSeconds) + analyzeLabelInvisibleGraceSeconds + 0.25 } func mergeAnalyzeHits(in []analyzeHit) []analyzeHit { if len(in) == 0 { return []analyzeHit{} } maxGap := analyzeHitContinuationGapSeconds() byLabel := map[string][]analyzeHit{} for _, h := range in { label := strings.ToLower(strings.TrimSpace(h.Label)) if label == "" || label == "unknown" { continue } if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) { continue } start := h.Start end := h.End if start < 0 && end < 0 { start = h.Time end = h.Time } else { if start < 0 { start = h.Time } if end < 0 { end = h.Time } } if start > end { start, end = end, start } h.Label = label h.Start = start h.End = end key := normalizeSegmentLabel(label) if key == "" { continue } byLabel[key] = append(byLabel[key], h) } out := make([]analyzeHit, 0, len(in)) for _, items := range byLabel { if len(items) == 0 { continue } sort.SliceStable(items, func(i, j int) bool { if items[i].Start != items[j].Start { return items[i].Start < items[j].Start } if items[i].End != items[j].End { return items[i].End < items[j].End } return items[i].Label < items[j].Label }) cur := items[0] for i := 1; i < len(items); i++ { n := items[i] gap := n.Start - cur.End if gap >= -0.25 && gap <= maxGap { cur.Label = preferAnalyzeSegmentLabel(cur.Label, n.Label) if n.Start < cur.Start { cur.Start = n.Start } if n.End > cur.End { cur.End = n.End } if n.Score > cur.Score { cur.Score = n.Score } cur.Time = (cur.Start + cur.End) / 2 continue } out = append(out, cur) cur = n } out = append(out, cur) } sort.SliceStable(out, func(i, j int) bool { if out[i].Start != out[j].Start { return out[i].Start < out[j].Start } if out[i].End != out[j].End { return out[i].End < out[j].End } return normalizeSegmentLabel(out[i].Label) < normalizeSegmentLabel(out[j].Label) }) return out } func inferAnalyzePointSpanSeconds(hits []analyzeHit, duration float64) float64 { const fallback = 10.0 if len(hits) < 2 { return fallback } times := make([]float64, 0, len(hits)) for _, h := range hits { t := h.Time if t < 0 { if h.Start >= 0 { t = h.Start } else if h.End >= 0 { t = h.End } } if t < 0 { continue } if duration > 0 { t = math.Max(0, math.Min(t, duration)) } times = append(times, t) } if len(times) < 2 { return fallback } sort.Float64s(times) gaps := make([]float64, 0, len(times)-1) prev := times[0] for _, t := range times[1:] { gap := t - prev if gap > 0.05 { gaps = append(gaps, gap) prev = t } } if len(gaps) == 0 { return fallback } sort.Float64s(gaps) median := gaps[len(gaps)/2] if len(gaps)%2 == 0 { median = (gaps[len(gaps)/2-1] + gaps[len(gaps)/2]) / 2 } // Ein einzelner Frame repräsentiert ungefähr seinen Sample-Abstand, // aber wir deckeln, damit Sparse-Hits nicht riesig werden. span := median * 0.90 if span < 6 { span = 6 } if span > 12 { span = 12 } return span } func expandAnalyzePointToSpan(t, span, duration float64) (float64, float64) { if span <= 0 { span = 3 } if t < 0 { t = 0 } if duration > 0 { t = math.Max(0, math.Min(t, duration)) } half := span / 2 start := t - half end := t + half if start < 0 { start = 0 } if duration > 0 && end > duration { end = duration } if end <= start { if duration > 0 { end = math.Min(duration, start+math.Max(1, span)) if end <= start { start = math.Max(0, end-math.Max(1, span)) } } else { end = start + math.Max(1, span) } } return start, end } type analyzeLabelSegmentPoint struct { Label string Start float64 End float64 Score float64 } func isAllowedAnalyzeSegmentLabel(label string) bool { label = strings.ToLower(strings.TrimSpace(label)) if label == "" || label == "unknown" { return false } if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) { return false } if strings.HasPrefix(label, "combo:") { for _, part := range segmentLabelParts(label) { if isAllowedAnalyzeSegmentLabel(part) { return true } } return false } raw := normalizeSegmentLabel(label) if raw == "" || raw == "unknown" { return false } return shouldAutoSelectAnalyzeHit(raw) || isKnownPositionLabel(raw) } func buildLabelContinuitySegmentsFromAnalyzeHits( hits []analyzeHit, duration float64, ) []aiSegmentMeta { if len(hits) == 0 || duration <= 0 { return []aiSegmentMeta{} } sampleSpan := math.Max(1.0, float64(analyzeVideoFrameIntervalSeconds)) halfSample := sampleSpan / 2.0 maxGap := analyzeHitContinuationGapSeconds() byLabel := map[string][]analyzeLabelSegmentPoint{} for _, hit := range hits { label := strings.ToLower(strings.TrimSpace(hit.Label)) if !isAllowedAnalyzeSegmentLabel(label) { continue } start := hit.Start end := hit.End if start < 0 && end < 0 { start = hit.Time end = hit.Time } else { if start < 0 { start = hit.Time } if end < 0 { end = hit.Time } } if start > end { start, end = end, start } if start < 0 { start = hit.Time } if end < 0 { end = start } start = math.Max(0, math.Min(start, duration)) end = math.Max(0, math.Min(end, duration)) key := normalizeSegmentLabel(label) if key == "" { continue } score := hit.Score if score <= 0 { score = 1 } byLabel[key] = append(byLabel[key], analyzeLabelSegmentPoint{ Label: label, Start: start, End: end, Score: score, }) } out := make([]aiSegmentMeta, 0) for _, points := range byLabel { if len(points) == 0 { continue } sort.SliceStable(points, func(i, j int) bool { if points[i].Start != points[j].Start { return points[i].Start < points[j].Start } if points[i].End != points[j].End { return points[i].End < points[j].End } return points[i].Label < points[j].Label }) curLabel := points[0].Label curStartMarker := points[0].Start curEndMarker := points[0].End curScoreSum := points[0].Score curScoreCount := 1 for i := 1; i < len(points); i++ { n := points[i] gap := n.Start - curEndMarker if gap >= -0.25 && gap <= maxGap { curLabel = preferAnalyzeSegmentLabel(curLabel, n.Label) if n.Start < curStartMarker { curStartMarker = n.Start } if n.End > curEndMarker { curEndMarker = n.End } curScoreSum += n.Score curScoreCount++ continue } segment := makeLabelContinuitySegment( curLabel, curStartMarker, curEndMarker, curScoreSum, curScoreCount, halfSample, duration, ) if segment.DurationSeconds > 0 { out = append(out, segment) } curLabel = n.Label curStartMarker = n.Start curEndMarker = n.End curScoreSum = n.Score curScoreCount = 1 } segment := makeLabelContinuitySegment( curLabel, curStartMarker, curEndMarker, curScoreSum, curScoreCount, halfSample, duration, ) if segment.DurationSeconds > 0 { out = append(out, segment) } } sort.SliceStable(out, func(i, j int) bool { if out[i].StartSeconds != out[j].StartSeconds { return out[i].StartSeconds < out[j].StartSeconds } if out[i].EndSeconds != out[j].EndSeconds { return out[i].EndSeconds < out[j].EndSeconds } return normalizeSegmentLabel(out[i].Label) < normalizeSegmentLabel(out[j].Label) }) return out } func makeLabelContinuitySegment( label string, startMarker float64, endMarker float64, scoreSum float64, scoreCount int, halfSample float64, duration float64, ) aiSegmentMeta { label = strings.ToLower(strings.TrimSpace(label)) start := startMarker - halfSample end := endMarker + halfSample if start < 0 { start = 0 } if duration > 0 && end > duration { end = duration } if end <= start { end = math.Min(duration, start+math.Max(1, halfSample*2)) } score := 0.0 if scoreCount > 0 { score = scoreSum / float64(scoreCount) } return aiSegmentMeta{ Label: label, StartSeconds: start, EndSeconds: end, DurationSeconds: math.Max(0, end-start), Score: score, AutoSelected: true, Position: segmentPositionFromAnalyzeLabel(label), Tags: segmentTagsFromAnalyzeLabel(label), } } func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegmentMeta { return buildLabelContinuitySegmentsFromAnalyzeHits(hits, duration) } func buildHighlightSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegmentMeta { return buildLabelContinuitySegmentsFromAnalyzeHits(hits, duration) } func buildAnalyzeSegmentsForGoal( hits []analyzeHit, duration float64, ) []aiSegmentMeta { return buildHighlightSegmentsFromAnalyzeHits(hits, duration) } func durationSecondsForAnalyze(ctx context.Context, outPath string) (float64, error) { ctx2, cancel := context.WithTimeout(ctx, 8*time.Second) defer cancel() return durationSecondsCached(ctx2, outPath) } func videoIDFromOutputPath(outPath string) string { base := filepath.Base(strings.TrimSpace(outPath)) if base == "" { return "" } stem := strings.TrimSuffix(base, filepath.Ext(base)) stem = stripHotPrefix(stem) return strings.TrimSpace(stem) }