// 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" "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 = 1 // 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 ) const ( analyzePositionClipWindowSeconds = 3.0 analyzePositionClipMinScore = 0.22 analyzePositionClipMinFrames = 2 analyzePositionConflictMargin = 0.08 analyzePositionMinStableSeconds = 8.0 analyzePositionShortMaxScore = 0.62 ) type analyzePositionEvidence struct { Time float64 Start float64 End float64 Label string Score float64 Source string PersonCount int HasPose bool HasContext bool HasClip bool } func isAnalyzeContextOnlyPositionLabel(label string) bool { return false } func isAnalyzeTimelinePositionLabel(label string) bool { label = strings.ToLower(strings.TrimSpace(label)) label = strings.TrimPrefix(label, "position:") return !isNoSexPositionLabel(label) && isKnownPositionLabel(label) && !isAnalyzeContextOnlyPositionLabel(label) } 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 isNoSexPositionLabel(label) { 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 isNoSexPositionLabel(label) { return false } if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) { return false } labels := autoSelectedAILabelSet() if _, ok := labels[label]; ok { return true } // Robuster Fallback: // Auch wenn trainingGroupedLabels()/detection_labels.json gerade leer, // veraltet oder nicht ladbar ist, sollen bekannte Rating-/Analyze-Labels // trotzdem durchkommen. if bodyPartSeverityWeight(label) > 0 { return true } if objectSeverityWeight(label) > 0 { return true } if clothingSeverityWeight(label) > 0 { return true } if isKnownPositionLabel(label) && positionSeverityWeight(label) > 0 { return true } return false } 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 isNoSexPositionLabel(label) { 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 isNoSexPositionLabel(label) { continue } addHighlightResult(best, prefix+":"+label, item.Score) } } func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameResult { best := map[string]float64{} sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition)) if isAnalyzeTimelinePositionLabel(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 isNoSexPositionLabel(label) { 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 isAnalyzeTimelinePositionLabel(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 isNoSexPositionLabel(label) { 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 isNoSexPositionLabel(label) { 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...) hideCommandWindow(cmd) 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, ) hideCommandWindow(cmd) 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, analyzeStartedAtMs, _, 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)) publishAnalyzePersistProgress(analyzeStartedAtMs, file, 0.1, "") 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) publishAnalysisError(analyzeStartedAtMs, file, "Analyse fehlgeschlagen", 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)) publishAnalyzePersistProgress(analyzeStartedAtMs, file, 0.45, "") rating := computeHighlightRatingForVideo(segments, durationSec, outPath) ai := &aiAnalysisMeta{ Goal: "highlights", Mode: "video", Completed: true, Hits: hits, Segments: segments, Rating: rating, AnalyzedAtUnix: time.Now().Unix(), } persistCtx, persistCancel := context.WithTimeout(ctx, 90*time.Second) defer persistCancel() if err := writeVideoAIForFile(persistCtx, outPath, "", ai); err != nil { logAnalyzeError("write-meta-ai", file, outPath, err) publishAnalysisError(analyzeStartedAtMs, file, "Speichern fehlgeschlagen", 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 } autoDeleteLowRatedDownloadAfterAnalysis(persistCtx, outPath, rating) publishAnalyzePersistProgress(analyzeStartedAtMs, file, 1, "") publishAnalysisFinished(analyzeStartedAtMs, analyzeProgressTotal, file, "Analyse abgeschlossen") 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 isNoSexPositionLabel(label) { 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 isNoSexPositionLabel(raw) { return "" } return "body:" + raw case strings.HasPrefix(label, "object:"): raw := strings.TrimPrefix(label, "object:") if isNoSexPositionLabel(raw) { return "" } return "object:" + raw case strings.HasPrefix(label, "clothing:"): raw := strings.TrimPrefix(label, "clothing:") if isNoSexPositionLabel(raw) { return "" } return "clothing:" + raw case strings.HasPrefix(label, "position:"): raw := strings.TrimPrefix(label, "position:") if !isAnalyzeTimelinePositionLabel(raw) { return "" } return "position:" + raw default: if isIgnoredNSFWLabel(label) { return "" } if isAnalyzeTimelinePositionLabel(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 isNoSexPositionLabel(raw) { 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 isNoSexPositionLabel(label) { 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 predictionHasAnyAnalyzeSignal(pred TrainingPrediction) bool { sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition)) if !isNoSexPositionLabel(sexPosition) { return true } if len(pred.BodyPartsPresent) > 0 { return true } if len(pred.ObjectsPresent) > 0 { return true } if len(pred.ClothingPresent) > 0 { return true } if len(pred.Boxes) > 0 { return true } return false } func predictionUsableForAnalyze(pred TrainingPrediction) bool { if pred.ModelAvailable { return true } // Fallback: // Einige AI-Server/Antworten liefern verwertbare Labels/Boxes, // setzen modelAvailable aber nicht sauber. return predictionHasAnyAnalyzeSignal(pred) } func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64) (analyzeHit, bool) { if !predictionUsableForAnalyze(pred) { return analyzeHit{}, false } best := map[string]highlightSignal{} sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition)) if isAnalyzeTimelinePositionLabel(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 isNoSexPositionLabel(label) { 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 !predictionUsableForAnalyze(pred) { return nil } best := map[string]highlightSignal{} sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition)) if isAnalyzeTimelinePositionLabel(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 isNoSexPositionLabel(label) { 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 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 analyzeHitWithoutRawPosition(hit analyzeHit) (analyzeHit, bool) { label := strings.ToLower(strings.TrimSpace(hit.Label)) if label == "" { return analyzeHit{}, false } if strings.HasPrefix(label, "position:") { return analyzeHit{}, false } if !strings.HasPrefix(label, "combo:") { hit.Label = label return hit, true } parts := []string{} for _, part := range strings.Split(strings.TrimPrefix(label, "combo:"), "+") { part = strings.ToLower(strings.TrimSpace(part)) if part == "" || strings.HasPrefix(part, "position:") { continue } parts = append(parts, part) } switch len(parts) { case 0: return analyzeHit{}, false case 1: hit.Label = parts[0] default: hit.Label = "combo:" + strings.Join(parts, "+") } return hit, true } func appendVideoFrameHighlightHitsFromPrediction( hits []analyzeHit, pred TrainingPrediction, t float64, ) []analyzeHit { next := appendHighlightHitsFromPrediction(nil, pred, t) if len(next) == 0 { return hits } for _, hit := range next { if stripped, ok := analyzeHitWithoutRawPosition(hit); ok { hits = append(hits, stripped) } } return hits } func analyzePositionEvidenceFromPrediction( pred TrainingPrediction, t float64, ) (analyzePositionEvidence, bool) { if !predictionUsableForAnalyze(pred) { return analyzePositionEvidence{}, false } label := strings.ToLower(strings.TrimSpace(pred.SexPosition)) if !isAnalyzeTimelinePositionLabel(label) { return analyzePositionEvidence{}, false } score := pred.SexPositionScore if score <= 0 { score = 0.35 } score = clamp01(score) if score < 0.16 { return analyzePositionEvidence{}, false } source := strings.ToLower(strings.TrimSpace(pred.Source)) personCount := len(pred.Persons) if personCount == 0 { for _, box := range pred.Boxes { if trainingIsPersonLikeLabel(box.Label) { personCount++ } } } return analyzePositionEvidence{ Time: t, Start: t, End: t, Label: label, Score: score, Source: source, PersonCount: personCount, HasPose: strings.Contains(source, "yolo_pose") || len(pred.Persons) > 0, HasContext: strings.Contains(source, "box_context") || len(pred.Boxes) > 0, }, true } func analyzePositionEvidenceWeight(item analyzePositionEvidence) float64 { weight := 1.0 if item.HasClip && (item.HasPose || item.HasContext) { weight = 1.70 } else if item.HasClip { weight = 1.55 } else if item.HasPose && item.HasContext { weight = 1.05 } else if item.HasPose { weight = 0.76 } else if item.HasContext { weight = 0.82 } if item.PersonCount >= 2 { weight += 0.08 } return weight } type analyzePositionAggregate struct { Label string WeightedSum float64 WeightSum float64 Count int PoseCount int ContextCount int ClipCount int Start float64 End float64 Marker float64 BestScore float64 } type analyzePositionCandidate struct { Agg *analyzePositionAggregate Score float64 } type analyzePositionLedgerEntry struct { Start float64 End float64 Center float64 Candidates []analyzePositionCandidate Winner analyzePositionCandidate Conflict bool } func analyzePositionEvidenceSpan(item analyzePositionEvidence) (float64, float64) { start := item.Start end := item.End if start == 0 && end == 0 { start = item.Time end = item.Time } if start < 0 { start = item.Time } if end < 0 { end = item.Time } if start > end { start, end = end, start } return start, end } func analyzePositionAggregateScore(agg *analyzePositionAggregate, requiredFrames int) (float64, bool) { if agg == nil || agg.WeightSum <= 0 { return 0, false } minCount := requiredFrames if agg.ClipCount > 0 { // Ein VideoMAE-Clip steht bereits für ein Zeitfenster und muss nicht // zusätzlich noch mehrere Einzel-Frames derselben Position haben. minCount = 1 } else if agg.PoseCount > 0 && agg.ContextCount == 0 { // Pose-only braucht etwas mehr zeitliche Stabilität. if minCount < 3 { minCount = 3 } } if agg.Count < minCount { return 0, false } avg := clamp01(agg.WeightedSum / agg.WeightSum) stability := clamp01(float64(agg.Count) / math.Max(float64(requiredFrames), 3)) sourceBonus := 0.0 if agg.ClipCount > 0 && (agg.PoseCount > 0 || agg.ContextCount > 0) { sourceBonus = 0.07 } else if agg.ClipCount > 0 { sourceBonus = 0.06 } else if agg.PoseCount > 0 && agg.ContextCount > 0 { sourceBonus = 0.04 } score := clamp01(avg*(0.86+0.14*stability) + sourceBonus) if agg.ClipCount == 0 && agg.PoseCount == 0 { score = math.Min(score, trainingPositionContextMaxScore) } if agg.ClipCount == 0 && agg.ContextCount == 0 { score = math.Min(score, trainingPoseStrongUnconfirmedMaxScore) } if score < analyzePositionClipMinScore { return 0, false } return score, true } func analyzePositionCandidateConflict(best, other analyzePositionCandidate) bool { if best.Agg == nil || other.Agg == nil { return false } if best.Agg.Label == other.Agg.Label { return false } // VideoMAE darf bei gleicher Groessenordnung als zeitliches Hauptsignal // gewinnen. Wenn aber zwei Clip-Positionen fast gleich stark sind, ist // das echter Konflikt und wird lieber nicht hart segmentiert. if best.Agg.ClipCount > 0 { if other.Agg.ClipCount > 0 { return other.Score >= best.Score-analyzePositionConflictMargin } return false } if other.Agg.ClipCount > 0 { return true } return other.Score >= best.Score-analyzePositionConflictMargin } func analyzePositionLedgerCenters(evidence []analyzePositionEvidence) []float64 { centers := make([]float64, 0, len(evidence)*3) for _, item := range evidence { start, end := analyzePositionEvidenceSpan(item) center := item.Time if center < start || center > end { center = (start + end) / 2 } centers = append(centers, center) if item.HasClip && end > start { centers = append(centers, start, end) } } sort.Float64s(centers) out := centers[:0] for _, center := range centers { if len(out) == 0 || math.Abs(center-out[len(out)-1]) > 0.20 { out = append(out, center) } } return out } func analyzePositionCandidatesForWindow( evidence []analyzePositionEvidence, windowStart float64, windowEnd float64, requiredFrames int, ) []analyzePositionCandidate { byLabel := map[string]*analyzePositionAggregate{} for _, item := range evidence { itemStart, itemEnd := analyzePositionEvidenceSpan(item) if itemEnd < windowStart-0.001 || itemStart > windowEnd+0.001 { continue } label := strings.ToLower(strings.TrimSpace(item.Label)) if !isAnalyzeTimelinePositionLabel(label) { continue } score := clamp01(item.Score) if score <= 0 { continue } agg := byLabel[label] if agg == nil { agg = &analyzePositionAggregate{ Label: label, Start: itemStart, End: itemEnd, Marker: item.Time, } byLabel[label] = agg } weight := analyzePositionEvidenceWeight(item) agg.WeightedSum += score * weight agg.WeightSum += weight agg.Count++ if item.HasPose { agg.PoseCount++ } if item.HasContext { agg.ContextCount++ } if item.HasClip { agg.ClipCount++ } if itemStart < agg.Start { agg.Start = itemStart } if itemEnd > agg.End { agg.End = itemEnd } if score > agg.BestScore { agg.BestScore = score agg.Marker = item.Time } } candidates := make([]analyzePositionCandidate, 0, len(byLabel)) for _, agg := range byLabel { score, ok := analyzePositionAggregateScore(agg, requiredFrames) if ok { candidates = append(candidates, analyzePositionCandidate{ Agg: agg, Score: score, }) } } sort.SliceStable(candidates, func(i, j int) bool { if candidates[i].Score != candidates[j].Score { return candidates[i].Score > candidates[j].Score } if candidates[i].Agg.ClipCount != candidates[j].Agg.ClipCount { return candidates[i].Agg.ClipCount > candidates[j].Agg.ClipCount } return candidates[i].Agg.Label < candidates[j].Agg.Label }) return candidates } func stabilizeAnalyzePositionHits(in []analyzeHit, duration float64) []analyzeHit { if len(in) == 0 { return []analyzeHit{} } out := make([]analyzeHit, 0, len(in)) for _, hit := range in { label := strings.ToLower(strings.TrimSpace(hit.Label)) position := strings.TrimPrefix(label, "position:") if !isAnalyzeTimelinePositionLabel(position) { continue } start := hit.Start end := hit.End if end < start { start, end = end, start } if duration > 0 { start = math.Max(0, math.Min(duration, start)) end = math.Max(0, math.Min(duration, end)) } span := end - start if duration >= 60 && span < analyzePositionMinStableSeconds && hit.Score < analyzePositionShortMaxScore { continue } hit.Label = "position:" + position hit.Start = start hit.End = end out = append(out, hit) } return mergeAnalyzeHits(out) } func buildAnalyzePositionLedger( evidence []analyzePositionEvidence, duration float64, requiredFrames int, ) []analyzePositionLedgerEntry { if len(evidence) == 0 { return []analyzePositionLedgerEntry{} } halfWindow := analyzePositionClipWindowSeconds / 2 if halfWindow <= 0 { halfWindow = math.Max(1, float64(analyzeVideoFrameIntervalSeconds)) } centers := analyzePositionLedgerCenters(evidence) ledger := make([]analyzePositionLedgerEntry, 0, len(centers)) for _, center := range centers { windowStart := math.Max(0, center-halfWindow) windowEnd := center + halfWindow if duration > 0 { windowEnd = math.Min(duration, windowEnd) } if windowEnd <= windowStart { windowEnd = windowStart + math.Max(1, float64(analyzeVideoFrameIntervalSeconds)) if duration > 0 { windowEnd = math.Min(duration, windowEnd) } } entry := analyzePositionLedgerEntry{ Start: windowStart, End: windowEnd, Center: center, } entry.Candidates = analyzePositionCandidatesForWindow( evidence, windowStart, windowEnd, requiredFrames, ) if len(entry.Candidates) > 0 { if len(entry.Candidates) > 1 && analyzePositionCandidateConflict(entry.Candidates[0], entry.Candidates[1]) { entry.Conflict = true } else { entry.Winner = entry.Candidates[0] } } ledger = append(ledger, entry) } return ledger } func buildClipPositionHitsFromEvidence( evidence []analyzePositionEvidence, duration float64, ) []analyzeHit { if len(evidence) == 0 { return []analyzeHit{} } sort.SliceStable(evidence, func(i, j int) bool { if evidence[i].Time != evidence[j].Time { return evidence[i].Time < evidence[j].Time } return evidence[i].Label < evidence[j].Label }) requiredFrames := analyzePositionClipMinFrames if len(evidence) < requiredFrames { requiredFrames = len(evidence) } if requiredFrames < 1 { requiredFrames = 1 } ledger := buildAnalyzePositionLedger(evidence, duration, requiredFrames) hits := make([]analyzeHit, 0, len(ledger)) var cur analyzeHit hasCur := false flush := func() { if !hasCur { return } if cur.End <= cur.Start { cur.End = cur.Start + math.Max(1, float64(analyzeVideoFrameIntervalSeconds)) if duration > 0 { cur.End = math.Min(duration, cur.End) } } cur.Time = (cur.Start + cur.End) / 2 hits = append(hits, cur) hasCur = false } for _, entry := range ledger { if entry.Conflict || entry.Winner.Agg == nil { flush() continue } label := "position:" + entry.Winner.Agg.Label if hasCur && cur.Label == label && entry.Start <= cur.End+analyzeHitContinuationGapSeconds() { if entry.End > cur.End { cur.End = entry.End } if entry.Winner.Score > cur.Score { cur.Score = entry.Winner.Score } continue } flush() cur = analyzeHit{ Time: entry.Center, Label: label, Score: entry.Winner.Score, Start: entry.Start, End: entry.End, } hasCur = true } flush() return stabilizeAnalyzePositionHits(mergeAnalyzeHits(hits), duration) } func analyzeVideoFromFrames(ctx context.Context, outPath string) ([]analyzeHit, int64, int, error) { return analyzeVideoFromFramesForGoal(ctx, outPath) } const ( analyzeProgressTotal = 1000 analyzeProgressExtractEnd = 450 analyzeProgressInferenceStart = analyzeProgressExtractEnd analyzeProgressInferenceEnd = 850 analyzeProgressVideoMAEStart = analyzeProgressInferenceEnd analyzeProgressVideoMAEEnd = 950 analyzeProgressPersistStart = analyzeProgressVideoMAEEnd analyzeProgressPersistEnd = 990 ) 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, ) { publishAnalyzePhaseProgress( startedAtMs, file, "Analyse", 0, analyzeProgressExtractEnd, progress, 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)) publishAnalyzePhaseProgress( startedAtMs, file, "Analyse", analyzeProgressInferenceStart, analyzeProgressInferenceEnd, ratio, message, ) } func publishAnalyzeVideoMAEProgress(startedAtMs int64, file string, currentClip int, totalClips int) { if totalClips <= 0 { totalClips = 1 } ratio := float64(currentClip) / float64(totalClips) publishAnalyzePhaseProgress( startedAtMs, file, "Analyse", analyzeProgressVideoMAEStart, analyzeProgressVideoMAEEnd, ratio, "", ) } func publishAnalyzePersistProgress(startedAtMs int64, file string, progress float64, message string) { publishAnalyzePhaseProgress( startedAtMs, file, "Speichern", analyzeProgressPersistStart, analyzeProgressPersistEnd, progress, message, ) } func publishAnalyzePhaseProgress( startedAtMs int64, file string, phase string, rangeStart int, rangeEnd int, progress float64, message string, ) { progress = math.Max(0, math.Min(1, progress)) if rangeEnd < rangeStart { rangeEnd = rangeStart } current := rangeStart + int(math.Round(progress*float64(rangeEnd-rangeStart))) if current < 0 { current = 0 } if current > analyzeProgressTotal { current = analyzeProgressTotal } message = strings.TrimSpace(message) if message == "" || strings.EqualFold(message, "Analyse") || strings.Contains(message, "Frames werden extrahiert") { phase = strings.TrimSpace(phase) if phase == "" { phase = "Analyse" } message = fmt.Sprintf("%s %d%%", phase, analyzeGlobalPercentFromCurrent(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, startedAtMs int64, totalFrames int, 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, startedAtMs, 0, err } durationSec, _ := durationSecondsForAnalyze(ctx, outPath) if err := ctx.Err(); err != nil { publishAnalysisError(startedAtMs, file, "Analyse abgebrochen", err) return nil, startedAtMs, 0, err } if durationSec <= 0 { err := appErrorf("videolänge konnte nicht bestimmt werden") publishAnalysisError(startedAtMs, file, "Analyse fehlgeschlagen", err) return nil, startedAtMs, 0, 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++ { if extractPhase { ratio := float64(p) / 50.0 if ratio < 0 { ratio = 0 } if ratio > 1 { ratio = 1 } publishAnalyzeExtractProgress( startedAtMs, file, ratio, "", ) } else { inferenceCurrent := current inferenceTotal := total if p >= 50 { inferenceTotal = 50 inferenceCurrent = p - 50 } publishAnalyzeInferenceProgress( startedAtMs, file, inferenceCurrent, inferenceTotal, "", ) } } *lastPercent = nextPercent } failCancelled := func() ([]analyzeHit, int64, int, error) { err := ctx.Err() if err == nil { err = context.Canceled } publishAnalysisError(startedAtMs, file, "Analyse abgebrochen", err) return nil, startedAtMs, 0, 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, startedAtMs, 0, err } if len(samples) == 0 { err := appErrorf("keine frame-samples vorhanden") publishAnalysisError(startedAtMs, file, "Keine Frames vorhanden", err) return nil, startedAtMs, 0, 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, "", ) if ctx.Err() != nil { return failCancelled() } batchOK := true detectorOnly := false positionEvidence := []analyzePositionEvidence{} 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, "", ) break } for i := 0; i < endIdx-startIdx; i++ { if ctx.Err() != nil { return failCancelled() } sample := samples[startIdx+i] pred := predictions[i] if !pred.ModelAvailable && predictionHasAnyAnalyzeSignal(pred) { appLogf( "⚠️ [analyze] prediction has signals but modelAvailable=false file=%q t=%.3f sex=%q body=%d objects=%d clothing=%d boxes=%d", file, sample.Time, strings.TrimSpace(pred.SexPosition), len(pred.BodyPartsPresent), len(pred.ObjectsPresent), len(pred.ClothingPresent), len(pred.Boxes), ) } highlightHits = appendVideoFrameHighlightHitsFromPrediction(highlightHits, pred, sample.Time) if item, ok := analyzePositionEvidenceFromPrediction(pred, sample.Time); ok { positionEvidence = append(positionEvidence, item) } } 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, ) } highlightHits, positionEvidence = applyVideoMAEPositionClipsForAnalyze( ctx, file, samples, durationSec, highlightHits, positionEvidence, func(current int, total int) { publishAnalyzeVideoMAEProgress(startedAtMs, file, current, total) }, ) if ctx.Err() != nil { return failCancelled() } highlightHits = append( highlightHits, buildClipPositionHitsFromEvidence(positionEvidence, durationSec)..., ) cleanHighlightHits := mergeAnalyzeHits(highlightHits) return cleanHighlightHits, startedAtMs, total, 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 = appendVideoFrameHighlightHitsFromPrediction(highlightHits, pred, sample.Time) if item, ok := analyzePositionEvidenceFromPrediction(pred, sample.Time); ok { positionEvidence = append(positionEvidence, item) } 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, ) } highlightHits, positionEvidence = applyVideoMAEPositionClipsForAnalyze( ctx, file, samples, durationSec, highlightHits, positionEvidence, func(current int, total int) { publishAnalyzeVideoMAEProgress(startedAtMs, file, current, total) }, ) if ctx.Err() != nil { return failCancelled() } highlightHits = append( highlightHits, buildClipPositionHitsFromEvidence(positionEvidence, durationSec)..., ) cleanHighlightHits := mergeAnalyzeHits(highlightHits) return cleanHighlightHits, startedAtMs, total, 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 isAnalyzeTimelinePositionLabel(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 !isAnalyzeTimelinePositionLabel(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 isNoSexPositionLabel(label) { 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 Time float64 Score float64 } func isAllowedAnalyzeSegmentLabel(label string) bool { label = strings.ToLower(strings.TrimSpace(label)) if isNoSexPositionLabel(label) { 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 isNoSexPositionLabel(raw) { return false } if strings.HasPrefix(label, "position:") && !isAnalyzeTimelinePositionLabel(label) { return false } if isAnalyzeContextOnlyPositionLabel(raw) { return false } return shouldAutoSelectAnalyzeHit(raw) || isAnalyzeTimelinePositionLabel(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 } markerTime := hit.Time if markerTime < 0 { markerTime = (start + end) / 2 } if duration > 0 { markerTime = math.Max(0, math.Min(markerTime, duration)) } byLabel[key] = append(byLabel[key], analyzeLabelSegmentPoint{ Label: label, Start: start, End: end, Time: markerTime, 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 curMarkerTime := points[0].Time curBestScore := points[0].Score 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++ if n.Score > curBestScore { curBestScore = n.Score curMarkerTime = n.Time } continue } segment := makeLabelContinuitySegment( curLabel, curStartMarker, curEndMarker, curMarkerTime, 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 curMarkerTime = n.Time curBestScore = n.Score } segment := makeLabelContinuitySegment( curLabel, curStartMarker, curEndMarker, curMarkerTime, 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, markerTime 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) } if markerTime < 0 { markerTime = (start + end) / 2 } if duration > 0 { markerTime = math.Max(0, math.Min(markerTime, duration)) } return aiSegmentMeta{ Label: label, StartSeconds: start, EndSeconds: end, DurationSeconds: math.Max(0, end-start), Score: score, AutoSelected: true, Position: segmentPositionFromAnalyzeLabel(label), Tags: segmentTagsFromAnalyzeLabel(label), MarkerSeconds: markerTime, PreviewSeconds: markerTime, } } 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) }