// backend\analyze.go package main import ( "bytes" "context" "encoding/json" "fmt" "image" "image/draw" "image/jpeg" "math" "net/http" "os" "os/exec" "path/filepath" "sort" "strings" "syscall" "time" ) type analyzeVideoReq struct { JobID string `json:"jobId"` Output string `json:"output"` Mode string `json:"mode"` // "video" | "sprite" Goal string `json:"goal"` // "highlights" | "nsfw" } 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 spriteFrameCandidate struct { Index int Time float64 } type videoFrameSample struct { Index int Time float64 Path string } const ( analyzeSegmentMergeGapSeconds = 8.0 nsfwThresholdModerate = 0.35 nsfwThresholdStrong = 0.60 // Sprite-Modus ist aktuell deaktiviert. Analyse läuft über Video-Frames. analyzeMaxSpriteCandidates = 24 // Video-Modus: schnelle Vorschau. Für bessere Trefferquote später 24. // neu, falls du später alle N Sekunden willst analyzeVideoFrameIntervalSeconds = 3 // AI-Server nicht mit tausenden Pfaden auf einmal fluten. analyzeFramePredictBatchSize = 32 // 640 ist für YOLO meist deutlich schneller als 960/1280. analyzeVideoFrameWidth = 640 // 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" ) var autoSelectedAILabels = map[string]struct{}{ // bodyParts aus detecton_labels.json "anus": {}, "ass": {}, "breasts": {}, "penis": {}, "tongue": {}, "pussy": {}, // objects aus detecton_labels.json "blindfold": {}, "buttplug": {}, "collar": {}, "dildo": {}, "handcuffs": {}, "shower": {}, "strapon": {}, "towel": {}, "vibrator": {}, // clothing aus detecton_labels.json "bikini": {}, "bra": {}, "dress": {}, "heels": {}, "hotpants": {}, "lingerie": {}, "panties": {}, "skirt": {}, "stockings": {}, "croptop": {}, // sexPositions aus detecton_labels.json "missionary": {}, "doggy": {}, "cowgirl": {}, "reverse_cowgirl": {}, "cunnilingus": {}, "prone_bone": {}, "standing": {}, "standing_doggy": {}, "spooning": {}, "sitting": {}, "facesitting": {}, "handjob": {}, "blowjob": {}, "toy_play": {}, "fingering": {}, "69": {}, "other": {}, } var nsfwIgnoredLabels = map[string]struct{}{ // Personen sollen nicht als interessante Segmente auftauchen. "person": {}, "person_male": {}, "person_female": {}, "person_unknown": {}, // Falls dein Detector irgendwann diese Varianten liefert: "people_male": {}, "people_female": {}, } func shouldAutoSelectAnalyzeHit(label string) bool { label = strings.ToLower(strings.TrimSpace(label)) _, ok := autoSelectedAILabels[label] return ok } func isIgnoredNSFWLabel(label string) bool { label = strings.ToLower(strings.TrimSpace(label)) _, ok := nsfwIgnoredLabels[label] return ok } func extractSpriteFrames(spritePath string, ps previewSpriteMetaFileInfo) ([]image.Image, error) { f, err := os.Open(spritePath) if err != nil { return nil, err } defer f.Close() img, _, err := image.Decode(f) if err != nil { return nil, err } b := img.Bounds() if ps.Cols <= 0 || ps.Rows <= 0 { return nil, appErrorf("sprite cols/rows fehlen") } cellW := b.Dx() / ps.Cols cellH := b.Dy() / ps.Rows if cellW <= 0 || cellH <= 0 { return nil, appErrorf("ungültige sprite cell size") } count := ps.Count if count <= 0 { count = ps.Cols * ps.Rows } out := make([]image.Image, 0, count) for i := 0; i < count; i++ { col := i % ps.Cols row := i / ps.Cols if row >= ps.Rows { break } srcRect := image.Rect( b.Min.X+col*cellW, b.Min.Y+row*cellH, b.Min.X+(col+1)*cellW, b.Min.Y+(row+1)*cellH, ) dst := image.NewRGBA(image.Rect(0, 0, cellW, cellH)) draw.Draw(dst, dst.Bounds(), img, srcRect.Min, draw.Src) out = append(out, dst) } return out, nil } func classifyFrameNSFW(ctx context.Context, img image.Image) (*NsfwImageResponse, error) { _ = ctx results, err := detectNSFWFromImage(img) if err != nil { return nil, err } return &NsfwImageResponse{ Ok: true, Results: results, }, nil } func addTrainingAnalyzeResult(best map[string]float64, label string, score float64) { label = strings.ToLower(strings.TrimSpace(label)) if label == "" { return } if score <= 0 { score = 1 } if old, ok := best[label]; !ok || score > old { best[label] = score } } func trainingPredictionToNSFWResults(pred TrainingPrediction) []NsfwFrameResult { best := map[string]float64{} // Für NSFW/AI-Segmente nur echte Boxen verwenden. // BodyPartsPresent/ObjectsPresent/ClothingPresent sind daraus abgeleitete Übersichten // und können sonst Labels doppelt oder zu breit einbringen. for _, box := range pred.Boxes { label := strings.ToLower(strings.TrimSpace(box.Label)) if label == "" { continue } if isIgnoredNSFWLabel(label) { continue } // Nur Labels zulassen, die für Analyse/Rating relevant sind. // Dadurch erzeugen neue YOLO-Klassen nur dann Segmente, // wenn du sie bewusst in autoSelectedAILabels einträgst. if !shouldAutoSelectAnalyzeHit(label) { continue } score := box.Score if score <= 0 { score = 1 } if old, ok := best[label]; !ok || score > old { best[label] = score } } 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 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" { 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" { 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:"+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 classifyFrameForAnalyze(ctx context.Context, img image.Image) (*NsfwImageResponse, error) { _ = ctx if !trainingRecognitionEnabled() { return &NsfwImageResponse{ Ok: true, Results: []NsfwFrameResult{}, }, nil } tmp, err := os.CreateTemp("", "training-analyze-frame-*.jpg") if err != nil { return nil, err } tmpPath := tmp.Name() defer os.Remove(tmpPath) if err := jpeg.Encode(tmp, img, &jpeg.Options{Quality: 92}); err != nil { _ = tmp.Close() return nil, err } if err := tmp.Close(); err != nil { return nil, err } pred := trainingPredictFrameDetectorOnly(tmpPath) // Wichtig: kein Fallback mehr auf altes ONNX-NSFW-Modell. if !pred.ModelAvailable { return &NsfwImageResponse{ Ok: true, Results: []NsfwFrameResult{}, }, nil } results := trainingPredictionToNSFWResults(pred) return &NsfwImageResponse{ Ok: true, Results: results, }, nil } 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 classifyFramePathForAnalyze(ctx context.Context, framePath string) (*NsfwImageResponse, error) { _ = ctx if !trainingRecognitionEnabled() { return &NsfwImageResponse{ Ok: true, Results: []NsfwFrameResult{}, }, nil } pred := trainingPredictFrameDetectorOnly(framePath) if !pred.ModelAvailable { return &NsfwImageResponse{ Ok: true, Results: []NsfwFrameResult{}, }, nil } results := trainingPredictionToNSFWResults(pred) return &NsfwImageResponse{ Ok: true, Results: results, }, nil } 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"` 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, 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") client := &http.Client{ Timeout: 120 * time.Second, } res, err := client.Do(req) if err != nil { return nil, err } defer res.Body.Close() var parsed analyzeBatchPredictResp if err := json.NewDecoder(res.Body).Decode(&parsed); err != nil { return nil, err } 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) } return nil, appErrorf("%s", msg) } if len(parsed.Predictions) == 0 { return nil, appErrorf("AI server lieferte keine predictions") } return parsed.Predictions, nil } func nsfwLabelPriority(label string) int { label = strings.ToLower(strings.TrimSpace(label)) switch label { case "vulva", "pussy": return 1000 case "penis": return 950 case "anus": return 900 case "breasts": return 800 case "buttocks", "ass": return 700 default: if shouldAutoSelectAnalyzeHit(label) { return 500 } return 0 } } func pickBestNSFWResult(results []NsfwFrameResult) (string, float64) { bestLabel := "" bestScore := 0.0 bestPriority := -1 for _, r := range results { label := strings.ToLower(strings.TrimSpace(r.Label)) if label == "" { continue } if isIgnoredNSFWLabel(label) { continue } score := r.Score priority := nsfwLabelPriority(label) if priority > bestPriority { bestLabel = label bestScore = score bestPriority = priority continue } if priority == bestPriority && score > bestScore { bestLabel = label bestScore = score bestPriority = priority } } return bestLabel, bestScore } 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" } cmd := exec.CommandContext( ctx, ffmpegPath, "-ss", fmt.Sprintf("%.3f", atSec), "-i", outPath, "-frames:v", "1", "-vf", fmt.Sprintf("scale=%d:-2:flags=fast_bilinear", analyzeVideoFrameWidth), "-q:v", "2", "-y", tmpPath, ) 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 := fmt.Sprintf( "fps=1/%d,scale=%d:-2:flags=fast_bilinear", intervalSeconds, analyzeVideoFrameWidth, ) 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 { return nil, nil, appErrorf( "ffmpeg frames extrahieren fehlgeschlagen: %v: %s", waitErr, strings.TrimSpace(stderr.String()), ) } 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 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "ungültiger body: "+err.Error(), http.StatusBadRequest) return } req.Mode = "video" req.Goal = strings.ToLower(strings.TrimSpace(req.Goal)) if req.Goal == "" { req.Goal = "highlights" } // Sprite-Modus ist deaktiviert, weil kein predict_sprite_batch.py vorhanden ist. // Analyse läuft immer über den Video-Frame-Batch-Pfad. switch req.Goal { case "highlights", "nsfw": default: http.Error(w, "goal muss 'highlights' oder 'nsfw' sein", http.StatusBadRequest) return } outPath := strings.TrimSpace(req.Output) if outPath == "" { http.Error(w, "output fehlt", http.StatusBadRequest) return } fi, err := os.Stat(outPath) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { http.Error(w, "output datei nicht gefunden", http.StatusNotFound) return } ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute) defer cancel() hits, err := analyzeVideoFromFrames(ctx, outPath, req.Goal) if err != nil { respondJSON(w, analyzeVideoResp{ OK: false, Mode: req.Mode, Goal: req.Goal, Hits: []analyzeHit{}, Error: err.Error(), }) return } durationSec, _ := durationSecondsForAnalyze(ctx, outPath) segments := buildAnalyzeSegmentsForGoal(hits, durationSec, req.Goal) var rating *aiRatingMeta if req.Goal == "nsfw" || req.Goal == "highlights" { rating = computeNSFWRating(segments, durationSec) } ai := &aiAnalysisMeta{ Goal: req.Goal, Mode: req.Mode, Hits: hits, Segments: segments, Rating: rating, AnalyzedAtUnix: time.Now().Unix(), } if err := writeVideoAIForFile(ctx, outPath, "", ai); err != nil { appLogln("⚠️ writeVideoAIForFile:", err) } respondJSON(w, analyzeVideoResp{ OK: true, Mode: req.Mode, Goal: req.Goal, Hits: hits, Segments: segments, Rating: rating, }) } func analyzeVideoFromSpriteAllGoals(ctx context.Context, outPath string) (nsfwHits []analyzeHit, highlightHits []analyzeHit, err error) { id := strings.TrimSpace(videoIDFromOutputPath(outPath)) if id == "" { return nil, nil, appErrorf("konnte keine video-id aus output ableiten") } metaPath, err := generatedMetaFile(id) if err != nil || strings.TrimSpace(metaPath) == "" { return nil, nil, appErrorf("meta.json nicht gefunden") } ps, ok := readPreviewSpriteMetaFromMetaFile(metaPath) if !ok { return nil, nil, appErrorf("previewSprite meta fehlt") } if ps.Count <= 0 { return nil, nil, appErrorf("previewSprite count fehlt") } spritePath := filepath.Join(filepath.Dir(metaPath), "preview-sprite.jpg") if fi, err := os.Stat(spritePath); err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { return nil, nil, appErrorf("preview-sprite.jpg nicht gefunden") } durationSec := ps.StepSeconds * math.Max(1, float64(ps.Count-1)) if durationSec <= 0 { durationSec, _ = durationSecondsForAnalyze(ctx, outPath) } candidates := buildSpriteFrameCandidates(ps.Count, ps.StepSeconds, durationSec) candidates = limitSpriteFrameCandidates(candidates, analyzeMaxSpriteCandidates) if len(candidates) == 0 { return nil, nil, appErrorf("keine sprite-kandidaten vorhanden") } // 1) Schneller Pfad: Python-Batch. results, batchErr := trainingPredictSpriteBatch(ctx, spritePath, ps, candidates) if batchErr == nil { for _, item := range results { pred := item.Prediction if !pred.ModelAvailable { continue } t := item.Time nsfwResults := trainingPredictionToNSFWResults(pred) bestLabel, bestScore := pickBestNSFWResult(nsfwResults) if bestLabel != "" && bestScore >= nsfwThresholdForLabel(bestLabel) { nsfwHits = append(nsfwHits, analyzeHit{ Time: t, Label: bestLabel, Score: bestScore, Start: t, End: t, }) } highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, t) } return mergeAnalyzeHits(nsfwHits), mergeAnalyzeHits(highlightHits), nil } // 2) Fallback: alte langsame Methode, damit Analyse nicht komplett fehlschlägt. appLogln("⚠️ sprite batch analyse fehlgeschlagen, fallback auf langsame Analyse:", batchErr) frames, err := extractSpriteFrames(spritePath, ps) if err != nil { return nil, nil, appErrorf("sprite frames extrahieren fehlgeschlagen: %w", err) } for _, c := range candidates { if c.Index < 0 || c.Index >= len(frames) { continue } pred := predictFrameForAnalyze(ctx, frames[c.Index]) if !pred.ModelAvailable { continue } t := c.Time nsfwResults := trainingPredictionToNSFWResults(pred) bestLabel, bestScore := pickBestNSFWResult(nsfwResults) if bestLabel != "" && bestScore >= nsfwThresholdForLabel(bestLabel) { nsfwHits = append(nsfwHits, analyzeHit{ Time: t, Label: bestLabel, Score: bestScore, Start: t, End: t, }) } highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, t) } return mergeAnalyzeHits(nsfwHits), mergeAnalyzeHits(highlightHits), nil } func nsfwThresholdForLabel(label string) float64 { label = strings.ToLower(strings.TrimSpace(label)) switch label { case "vulva", "penis", "anus": return nsfwThresholdStrong case "pussy", "breasts", "buttocks", "ass": return nsfwThresholdModerate default: if shouldAutoSelectAnalyzeHit(label) { return 0.40 } return 0.50 } } func appendNSFWHitFromPrediction( hits []analyzeHit, pred TrainingPrediction, t float64, ) []analyzeHit { if !pred.ModelAvailable { return hits } nsfwResults := trainingPredictionToNSFWResults(pred) bestLabel, bestScore := pickBestNSFWResult(nsfwResults) if bestLabel == "" { return hits } if bestScore < nsfwThresholdForLabel(bestLabel) { return hits } return append(hits, analyzeHit{ Time: t, Label: bestLabel, Score: bestScore, Start: t, End: t, }) } 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 highlightSignalInterestingEnough(label string, score float64) bool { label = normalizeHighlightSignalLabel(label) if label == "" { return false } if score <= 0 { score = 1 } switch { case strings.HasPrefix(label, "position:"): // Position alleine ist nicht interessant genug, aber als Kombi-Kontext okay. return score >= 0.35 case strings.HasPrefix(label, "body:"): return score >= 0.35 && segmentSeverityWeight(label) >= 0.65 case strings.HasPrefix(label, "object:"): return score >= 0.35 && segmentSeverityWeight(label) >= 0.50 case strings.HasPrefix(label, "clothing:"): // Kleidung nur anzeigen, wenn sie als Kombi-Kontext dient. return score >= 0.45 && segmentSeverityWeight(label) >= 0.50 case strings.HasPrefix(label, "detector:"): return score >= 0.45 && segmentSeverityWeight(label) >= 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" { 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) } if len(best) < 2 { return analyzeHit{}, false } signals := make([]highlightSignal, 0, len(best)) groupSeen := map[string]bool{} nonPositionCount := 0 hasPosition := false for _, sig := range best { if sig.Label == "" { continue } if sig.Group == "position" { hasPosition = true } else { nonPositionCount++ } groupSeen[sig.Group] = true signals = append(signals, sig) } // Nur echte interessante Kombis: // - Position + mindestens ein weiteres Signal // - oder mindestens zwei Nicht-Positions-Signale // - oder mindestens zwei unterschiedliche Signalgruppen if len(signals) < 2 { return analyzeHit{}, false } if hasPosition && nonPositionCount < 1 { return analyzeHit{}, false } if !hasPosition && nonPositionCount < 2 { return analyzeHit{}, false } if len(groupSeen) < 2 && len(signals) < 3 { return analyzeHit{}, false } 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 < 0.42 { return analyzeHit{}, false } return analyzeHit{ Time: t, Label: "combo:" + strings.Join(parts, "+"), Score: score, Start: t, End: t, }, true } func appendHighlightHitsFromPrediction( hits []analyzeHit, pred TrainingPrediction, t float64, ) []analyzeHit { hit, ok := buildCombinedHighlightHitFromPrediction(pred, t) if !ok { return hits } return append(hits, hit) } func analyzeVideoFromFrames(ctx context.Context, outPath, goal string) ([]analyzeHit, error) { goal = strings.ToLower(strings.TrimSpace(goal)) nsfwHits, highlightHits, err := analyzeVideoFromFramesForGoal(ctx, outPath, goal) if err != nil { return nil, err } switch goal { case "nsfw": return nsfwHits, nil case "highlights": return highlightHits, nil default: return []analyzeHit{}, nil } } const analyzeProgressTotal = 1000 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, goal string, ) (nsfwHits []analyzeHit, highlightHits []analyzeHit, err error) { goal = strings.ToLower(strings.TrimSpace(goal)) if goal == "" { goal = "all" } file := filepath.Base(strings.TrimSpace(outPath)) startedAtMs := publishAnalysisStarted(file, analyzeProgressTotal, "Analyse 0%") publishAnalyzeExtractProgress( startedAtMs, file, 0, "Analyse 0%", ) durationSec, _ := durationSecondsForAnalyze(ctx, outPath) if durationSec <= 0 { err := appErrorf("videolänge konnte nicht bestimmt werden") publishAnalysisError(startedAtMs, file, "Analyse fehlgeschlagen", err) return nil, nil, err } // Hilfsfunktion: garantiert, dass keine Prozentpunkte übersprungen werden. // Beispiel: letzter Stand 12, neuer Stand 17 -> sendet 13,14,15,16,17. 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 { // p läuft global 0..50, publishAnalyzeExtractProgress erwartet aber 0..1. ratio := float64(p) / 50.0 if ratio < 0 { ratio = 0 } if ratio > 1 { ratio = 1 } publishAnalyzeExtractProgress( startedAtMs, file, ratio, label, ) } else { // Für die Inferenzphase current/total so wählen, dass der globale // Fortschritt in publishAnalyzeInferenceProgress wieder exakt p ergibt: // current/total = (p - 50) / 50 inferenceCurrent := current inferenceTotal := total if p >= 50 { inferenceTotal = 50 inferenceCurrent = p - 50 } publishAnalyzeInferenceProgress( startedAtMs, file, inferenceCurrent, inferenceTotal, label, ) } } *lastPercent = nextPercent } 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 err != nil { publishAnalysisError(startedAtMs, file, "Frames konnten nicht extrahiert werden", err) return nil, nil, err } if len(samples) == 0 { err := appErrorf("keine frame-samples vorhanden") publishAnalysisError(startedAtMs, file, "Keine Frames vorhanden", err) return nil, nil, err } total := len(samples) if lastExtractPercent < 50 { publishPercentRange( &lastExtractPercent, 50, total, total, true, ) } paths := make([]string, 0, len(samples)) for _, sample := range samples { paths = append(paths, sample.Path) } lastInferencePercent := 50 publishAnalyzeInferenceProgress( startedAtMs, file, 0, total, "Analyse 50%", ) // Schneller AI-Server-Batch-Pfad für nsfw, highlights und all. // Wichtig: ensureAnalyzeAllGoalsForVideoCtx ruft goal="all" auf. // Ohne diesen Block fällt "all" auf die sehr langsame Einzelbild-Analyse zurück. if goal == "nsfw" || goal == "highlights" || goal == "all" { batchOK := true // Für nsfw könnte detectorOnly=true reichen. // Dein ai_server.py liefert aber ohnehin alle Felder aus YOLO-Resultaten, // daher ist false für alle Goals okay und vermeidet Sonderlogik. detectorOnly := false for startIdx := 0; startIdx < len(samples); startIdx += analyzeFramePredictBatchSize { endIdx := startIdx + analyzeFramePredictBatchSize if endIdx > len(samples) { endIdx = len(samples) } predictions, batchErr := trainingPredictFramePathsBatchForAnalyze( ctx, paths[startIdx:endIdx], detectorOnly, ) if batchErr != nil || len(predictions) < endIdx-startIdx { appLogln("⚠️ video batch analyse fehlgeschlagen, fallback auf einzelbild-analyse:", batchErr) batchOK = false nsfwHits = nil highlightHits = nil lastInferencePercent = 50 publishAnalyzeInferenceProgress( startedAtMs, file, 0, total, "Analyse 50%", ) break } for i := 0; i < endIdx-startIdx; i++ { sample := samples[startIdx+i] pred := predictions[i] switch goal { case "nsfw": nsfwHits = appendNSFWHitFromPrediction(nsfwHits, pred, sample.Time) case "highlights": highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, sample.Time) default: nsfwHits = appendNSFWHitFromPrediction(nsfwHits, pred, sample.Time) 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 lastInferencePercent < 100 { publishPercentRange( &lastInferencePercent, 100, total, total, false, ) } cleanNSFWHits := mergeAnalyzeHits(nsfwHits) cleanHighlightHits := mergeAnalyzeHits(highlightHits) cleanup() publishAnalysisFinished(startedAtMs, total, file, "Analyse abgeschlossen") return cleanNSFWHits, cleanHighlightHits, nil } } // Fallback: langsame Einzelbild-Analyse. // Dieser Pfad sollte nur laufen, wenn der AI-Server-Batch fehlschlägt. for i, sample := range samples { t := sample.Time switch goal { case "nsfw": res, err := classifyFramePathForAnalyze(ctx, sample.Path) if err == nil { bestLabel, bestScore := pickBestNSFWResult(res.Results) if bestLabel != "" && bestScore >= nsfwThresholdForLabel(bestLabel) { nsfwHits = append(nsfwHits, analyzeHit{ Time: t, Label: bestLabel, Score: bestScore, Start: t, End: t, }) } } case "highlights": pred := predictFramePathForAnalyze(ctx, sample.Path) highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, t) default: pred := predictFramePathForAnalyze(ctx, sample.Path) nsfwHits = appendNSFWHitFromPrediction(nsfwHits, pred, t) highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, t) } 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 lastInferencePercent < 100 { publishPercentRange( &lastInferencePercent, 100, total, total, false, ) } cleanNSFWHits := mergeAnalyzeHits(nsfwHits) cleanHighlightHits := mergeAnalyzeHits(highlightHits) cleanup() publishAnalysisFinished(startedAtMs, total, file, "Analyse abgeschlossen") return cleanNSFWHits, cleanHighlightHits, nil } func analyzeSpriteCandidatesWithAI( ctx context.Context, spritePath string, ps previewSpriteMetaFileInfo, candidates []spriteFrameCandidate, goal string, ) ([]analyzeHit, error) { frames, err := extractSpriteFrames(spritePath, ps) if err != nil { return nil, appErrorf("sprite frames extrahieren fehlgeschlagen: %w", err) } hits := make([]analyzeHit, 0, len(candidates)) for _, c := range candidates { if c.Index < 0 || c.Index >= len(frames) { continue } img := frames[c.Index] switch goal { case "nsfw": res, err := classifyFrameForAnalyze(ctx, img) if err != nil { continue } bestLabel, bestScore := pickBestNSFWResult(res.Results) if bestLabel == "" { continue } threshold := nsfwThresholdForLabel(bestLabel) if bestScore < threshold { continue } hits = append(hits, analyzeHit{ Time: c.Time, Label: bestLabel, Score: bestScore, Start: c.Time, End: c.Time, }) case "highlights": pred := predictFrameForAnalyze(ctx, img) if !pred.ModelAvailable { continue } hits = appendHighlightHitsFromPrediction(hits, pred, c.Time) } } return hits, 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 } // 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 mergeAnalyzeHits(in []analyzeHit) []analyzeHit { if len(in) == 0 { return []analyzeHit{} } cp := make([]analyzeHit, 0, len(in)) for _, h := range in { label := strings.ToLower(strings.TrimSpace(h.Label)) if label == "" { continue } if isIgnoredNSFWLabel(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 } } h.Label = label h.Start = start h.End = end cp = append(cp, h) } if len(cp) == 0 { return []analyzeHit{} } sort.Slice(cp, func(i, j int) bool { if cp[i].Start != cp[j].Start { return cp[i].Start < cp[j].Start } if cp[i].End != cp[j].End { return cp[i].End < cp[j].End } return cp[i].Label < cp[j].Label }) out := make([]analyzeHit, 0, len(cp)) cur := cp[0] for i := 1; i < len(cp); i++ { n := cp[i] // Direkt aufeinanderfolgende Treffer mit gleichem Label immer zusammenfassen. // Sobald ein anderes Label dazwischen liegt, wird automatisch nicht gemergt. sameLabel := sameAnalyzeSegmentLabel(cur.Label, n.Label) gap := n.Start - cur.End if sameLabel && gap >= -0.25 && gap <= analyzeSegmentMergeGapSeconds { 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) return out } func inferAnalyzePointSpanSeconds(hits []analyzeHit, duration float64) float64 { const fallback = 3.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 < 2 { span = 2 } 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 } func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegmentMeta { if len(hits) == 0 || duration <= 0 { return []aiSegmentMeta{} } pointSpan := inferAnalyzePointSpanSeconds(hits, duration) out := make([]aiSegmentMeta, 0, len(hits)) for _, hit := range hits { if !shouldAutoSelectAnalyzeHit(hit.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 } start = math.Max(0, math.Min(start, duration)) end = math.Max(0, math.Min(end, duration)) // Wichtig: // Einzelne AI-Treffer sind oft Punkt-Treffer: Start == End. // Für Segmente und Rating brauchen sie aber eine kleine Dauer. if end <= start { marker := hit.Time if marker < 0 { marker = start } start, end = expandAnalyzePointToSpan(marker, pointSpan, duration) } if end <= start { continue } out = append(out, aiSegmentMeta{ Label: strings.ToLower(strings.TrimSpace(hit.Label)), StartSeconds: start, EndSeconds: end, DurationSeconds: end - start, Score: hit.Score, AutoSelected: true, }) } if len(out) == 0 { return []aiSegmentMeta{} } sort.Slice(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 out[i].Label < out[j].Label }) merged := make([]aiSegmentMeta, 0, len(out)) cur := out[0] for i := 1; i < len(out); i++ { n := out[i] gap := n.StartSeconds - cur.EndSeconds if gap < 0 { gap = 0 } if sameAnalyzeSegmentLabel(cur.Label, n.Label) && gap <= analyzeSegmentMergeGapSeconds { cur.Label = preferAnalyzeSegmentLabel(cur.Label, n.Label) if n.StartSeconds < cur.StartSeconds { cur.StartSeconds = n.StartSeconds } if n.EndSeconds > cur.EndSeconds { cur.EndSeconds = n.EndSeconds } cur.DurationSeconds = cur.EndSeconds - cur.StartSeconds if n.Score > cur.Score { cur.Score = n.Score } cur.AutoSelected = cur.AutoSelected || n.AutoSelected continue } merged = append(merged, cur) cur = n } merged = append(merged, cur) return mergeAdjacentAISegments(merged, analyzeSegmentMergeGapSeconds) } func buildHighlightSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegmentMeta { if len(hits) == 0 || duration <= 0 { return []aiSegmentMeta{} } pointSpan := inferAnalyzePointSpanSeconds(hits, duration) out := make([]aiSegmentMeta, 0, len(hits)) for _, hit := range hits { label := strings.ToLower(strings.TrimSpace(hit.Label)) if label == "" || label == "unknown" { continue } if isIgnoredNSFWLabel(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 } start = math.Max(0, math.Min(start, duration)) end = math.Max(0, math.Min(end, duration)) if end <= start { marker := hit.Time if marker < 0 { marker = start } start, end = expandAnalyzePointToSpan(marker, pointSpan, duration) } if end <= start { continue } out = append(out, aiSegmentMeta{ Label: label, StartSeconds: start, EndSeconds: end, DurationSeconds: end - start, Score: hit.Score, AutoSelected: true, }) } if len(out) == 0 { return []aiSegmentMeta{} } sort.Slice(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 out[i].Label < out[j].Label }) merged := make([]aiSegmentMeta, 0, len(out)) cur := out[0] for i := 1; i < len(out); i++ { n := out[i] gap := n.StartSeconds - cur.EndSeconds if gap < 0 { gap = 0 } if sameAnalyzeSegmentLabel(cur.Label, n.Label) && gap <= analyzeSegmentMergeGapSeconds { cur.Label = preferAnalyzeSegmentLabel(cur.Label, n.Label) if n.StartSeconds < cur.StartSeconds { cur.StartSeconds = n.StartSeconds } if n.EndSeconds > cur.EndSeconds { cur.EndSeconds = n.EndSeconds } cur.DurationSeconds = cur.EndSeconds - cur.StartSeconds if n.Score > cur.Score { cur.Score = n.Score } cur.AutoSelected = cur.AutoSelected || n.AutoSelected continue } merged = append(merged, cur) cur = n } merged = append(merged, cur) return mergeAdjacentAISegments(merged, analyzeSegmentMergeGapSeconds) } func buildAnalyzeSegmentsForGoal( hits []analyzeHit, duration float64, goal string, ) []aiSegmentMeta { goal = strings.ToLower(strings.TrimSpace(goal)) switch goal { case "highlights": return buildHighlightSegmentsFromAnalyzeHits(hits, duration) case "nsfw": return buildSegmentsFromAnalyzeHits(hits, duration) default: return []aiSegmentMeta{} } } func buildSpriteFrameCandidates(count int, stepSeconds, durationSec float64) []spriteFrameCandidate { if count <= 0 { return nil } out := make([]spriteFrameCandidate, 0, count) stepLooksUsable := false if stepSeconds > 0 && durationSec > 0 { coverage := stepSeconds * math.Max(1, float64(count-1)) stepLooksUsable = coverage >= durationSec*0.7 && coverage <= durationSec*1.3 } for i := 0; i < count; i++ { var t float64 if stepLooksUsable { t = float64(i) * stepSeconds } else if durationSec > 0 && count > 1 { t = (float64(i) / float64(count-1)) * durationSec } else if stepSeconds > 0 { t = float64(i) * stepSeconds } else { t = float64(i) } out = append(out, spriteFrameCandidate{ Index: i, Time: t, }) } return out } func limitSpriteFrameCandidates(in []spriteFrameCandidate, max int) []spriteFrameCandidate { if max <= 0 || len(in) <= max { return in } out := make([]spriteFrameCandidate, 0, max) seen := map[int]bool{} if max == 1 { return []spriteFrameCandidate{in[len(in)/2]} } for i := 0; i < max; i++ { ratio := float64(i) / float64(max-1) idx := int(math.Round(ratio * float64(len(in)-1))) if idx < 0 { idx = 0 } if idx >= len(in) { idx = len(in) - 1 } if seen[idx] { continue } seen[idx] = true out = append(out, in[idx]) } if len(out) == 0 { return in } return out } func buildVideoSampleTimes(durationSec float64, sampleCount int) []float64 { if durationSec <= 0 || sampleCount <= 0 { return nil } // Nicht exakt bei 0.0s und nicht exakt am Videoende sampeln. // Anfang/Ende sind häufiger schwarz, unscharf oder ffmpeg schlägt am Ende fehl. startPad := math.Min(1.0, durationSec*0.05) endPad := math.Min(1.0, durationSec*0.05) start := startPad end := durationSec - endPad if end <= start { start = 0 end = durationSec } if sampleCount == 1 { return []float64{(start + end) / 2} } out := make([]float64, 0, sampleCount) for i := 0; i < sampleCount; i++ { ratio := float64(i) / float64(sampleCount-1) t := start + ratio*(end-start) if t < 0 { t = 0 } if t > durationSec { t = durationSec } out = append(out, t) } return out } func inferredSpanSeconds(stepSeconds float64, fallback float64) float64 { if stepSeconds > 0 { return math.Max(2, stepSeconds*1.5) } return fallback } 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) }