// backend\assets_generate.go package main import ( "context" "errors" "fmt" "math" "os" "path/filepath" "strings" "time" ) func formatBytesSI(b int64) string { if b < 0 { b = 0 } const unit = 1024 if b < unit { return fmt.Sprintf("%d B", b) } div, exp := int64(unit), 0 for n := b / unit; n >= unit; n /= unit { div *= unit exp++ } suffix := []string{"KB", "MB", "GB", "TB", "PB"} v := float64(b) / float64(div) // 1 Nachkommastelle, außer sehr große ganze Zahlen if v >= 10 { return fmt.Sprintf("%.0f %s", v, suffix[exp]) } return fmt.Sprintf("%.1f %s", v, suffix[exp]) } func u64ToI64(x uint64) int64 { if x > uint64(maxInt64) { return maxInt64 } return int64(x) } func isFFmpegInputInvalidError(err error) bool { if err == nil { return false } s := strings.ToLower(err.Error()) // typische ffmpeg/mp4 Fehler bei unvollständigen Dateien return strings.Contains(s, "moov atom not found") || strings.Contains(s, "invalid data found when processing input") || strings.Contains(s, "error opening input file") || strings.Contains(s, "error opening input") } // ------------------------- // Asset layout helpers // ------------------------- // Asset "ID" = Dateiname ohne Endung (immer OHNE "HOT " Prefix) func assetIDFromVideoPath(videoPath string) string { return canonicalAssetIDFromName(videoPath) } func postWorkSortForVideoPath(videoPath string) (bucket int, sortName string) { clean := strings.TrimSpace(videoPath) if clean == "" { return 0, "" } p := strings.ToLower(filepath.ToSlash(clean)) if strings.Contains(p, "/keep/") { bucket = 1 } else { bucket = 0 } return bucket, strings.ToLower(filepath.Base(clean)) } // Liefert die standardisierten Pfade (preview.jpg / preview.mp4 / preview-sprite.jpg / meta.json) func assetPathsForID(id string) (assetDir, thumbPath, previewPath, spritePath, metaPath string, err error) { id = strings.TrimSpace(id) if id == "" { return "", "", "", "", "", appErrorf("empty id") } assetDir, err = ensureGeneratedDir(id) if err != nil || strings.TrimSpace(assetDir) == "" { return "", "", "", "", "", appErrorf("generated dir: %v", err) } thumbPath = filepath.Join(assetDir, "preview.jpg") previewPath = filepath.Join(assetDir, "preview.mp4") spritePath = filepath.Join(assetDir, "preview-sprite.jpg") metaPath, _ = metaJSONPathForAssetID(id) // bevorzugt generated/meta//meta.json if strings.TrimSpace(metaPath) == "" { metaPath = filepath.Join(assetDir, "meta.json") } return assetDir, thumbPath, previewPath, spritePath, metaPath, nil } func requiredAnalyzeGoals() []string { return []string{"nsfw", "highlights"} } func hasAIResultsForAllOutputGoals(outPath string, goals []string) bool { // Der Name bleibt für Kompatibilität, // aber semantisch heißt das jetzt: // "Sind alle Analysen gespeichert?" return hasAIAnalysisForAllOutputGoals(outPath, goals) } func ensureAnalyzeAllGoalsForVideoCtx( ctx context.Context, videoPath string, sourceURL string, ) (bool, error) { return ensureAnalyzeAllGoalsForVideoCtxForce(ctx, videoPath, sourceURL, false) } func ensureAnalyzeAllGoalsForVideoCtxForce( ctx context.Context, videoPath string, sourceURL string, force bool, ) (bool, error) { videoPath = strings.TrimSpace(videoPath) if videoPath == "" { return false, nil } // Normaler Asset-/Postwork-Pfad: // vorhandene Analyse behalten. if !force && hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals()) { return false, nil } fi, err := os.Stat(videoPath) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { return false, nil } id := assetIDFromVideoPath(videoPath) if id == "" { return false, nil } _, _, _, _, metaPath, perr := assetPathsForID(id) if perr != nil { return false, perr } meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) actx, cancel := context.WithTimeout(ctx, 30*time.Minute) defer cancel() durationSec := meta.durSec if durationSec <= 0 { durationSec, _ = durationSecondsForAnalyze(actx, videoPath) } if durationSec <= 0 { return false, appErrorf("videolänge konnte nicht bestimmt werden") } // NSFW + Highlights in EINEM Frame-Extraktionslauf analysieren. nsfwHits, highlightHits, err := analyzeVideoFromFramesForGoal( actx, videoPath, "all", ) if err != nil { return false, err } nsfwSegments := buildAnalyzeSegmentsForGoal(nsfwHits, durationSec, "nsfw") nsfwRating := computeNSFWRating(nsfwSegments, durationSec) nsfwAI := &aiAnalysisMeta{ Goal: "nsfw", Mode: "video", Hits: nsfwHits, Segments: nsfwSegments, Rating: nsfwRating, AnalyzedAtUnix: time.Now().Unix(), } if err := writeVideoAIForFile(actx, videoPath, meta.sourceURL, nsfwAI); err != nil { return false, err } highlightSegments := buildAnalyzeSegmentsForGoal(highlightHits, durationSec, "highlights") highlightRating := computeNSFWRating(highlightSegments, durationSec) highlightAI := &aiAnalysisMeta{ Goal: "highlights", Mode: "video", Hits: highlightHits, Segments: highlightSegments, Rating: highlightRating, AnalyzedAtUnix: time.Now().Unix(), } if err := writeVideoAIForFile(actx, videoPath, meta.sourceURL, highlightAI); err != nil { return false, err } return hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals()), nil } func assetsTruthForVideo(videoPath string) finishedPhaseTruth { videoPath = strings.TrimSpace(videoPath) var truth finishedPhaseTruth if videoPath == "" { return truth } id := assetIDFromVideoPath(videoPath) if id == "" { return truth } // Basiszustand aus bestehender Logik lesen truth = finishedPhaseTruthForID(id) _, thumbPath, previewPath, spritePath, metaPath, err := assetPathsForID(id) if err != nil { return truth } // Meta: nur ready, wenn Inhalt OK UND Datei wirklich vorhanden truth.MetaReady = truth.MetaReady && fileExistsNonEmpty(metaPath) // Dateibasierte Assets immer an echter Datei festmachen truth.ThumbReady = fileExistsNonEmpty(thumbPath) truth.TeaserReady = fileExistsNonEmpty(previewPath) truth.SpritesReady = fileExistsNonEmpty(spritePath) // Analyse für Standard-Postwork: NSFW-Rating truth.AnalyzeReady = truth.SpritesReady && hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals()) return truth } type ensuredMeta struct { durSec float64 vw, vh int fps float64 sourceURL string ok bool // ok = duration + props vorhanden } // Stellt sicher: // - Duration ist berechnet (cache ok) // - Props (w/h/fps) sind drin (ffprobe wenn nötig) // - meta.json wird (best-effort) geschrieben, inkl. sourceURL func ensureVideoMeta(ctx context.Context, videoPath, metaPath, sourceURL string, vfi os.FileInfo) (ensuredMeta, error) { out := ensuredMeta{sourceURL: strings.TrimSpace(sourceURL)} videoPath = strings.TrimSpace(videoPath) metaPath = strings.TrimSpace(metaPath) if videoPath == "" || metaPath == "" { return out, nil } if vfi == nil || vfi.IsDir() || vfi.Size() <= 0 { return out, nil } // 1) Try cache/meta first (inkl. w/h/fps) if d, mw, mh, mfps, ok := readVideoMeta(metaPath, vfi); ok { out.durSec, out.vw, out.vh, out.fps = d, mw, mh, mfps } else { // 2) Duration berechnen dctx, cancel := context.WithTimeout(ctx, 6*time.Second) d, derr := durationSecondsCached(dctx, videoPath) cancel() if derr == nil && d > 0 { out.durSec = d } } // 3) Props ggf. nachziehen (ffprobe) if out.durSec > 0 && (out.vw <= 0 || out.vh <= 0 || out.fps <= 0) { pctx, cancel := context.WithTimeout(ctx, 8*time.Second) defer cancel() if durSem != nil { if err := durSem.Acquire(pctx); err == nil { out.vw, out.vh, out.fps, _ = probeVideoProps(pctx, videoPath) durSem.Release() } } else { out.vw, out.vh, out.fps, _ = probeVideoProps(pctx, videoPath) } } // 4) meta.json schreiben/aktualisieren (best-effort) if out.durSec > 0 { _ = writeVideoMeta(metaPath, vfi, out.durSec, out.vw, out.vh, out.fps, out.sourceURL) } out.ok = out.durSec > 0 && out.vw > 0 && out.vh > 0 return out, nil } type EnsureAssetsResult struct { Skipped bool ThumbGenerated bool PreviewGenerated bool SpriteGenerated bool MetaOK bool } type EnsurePrimaryAssetsResult struct { Skipped bool ThumbGenerated bool PreviewGenerated bool MetaOK bool } // Public wrappers (kompatibel zu deinem bisherigen API) func ensureAssetsForVideo(videoPath string) error { // Default: keine SourceURL (für Covers egal) return ensureAssetsForVideoWithProgress(videoPath, "", nil) } func ensureAssetsForVideoWithSource(videoPath string, sourceURL string) error { return ensureAssetsForVideoWithProgress(videoPath, sourceURL, nil) } // onRatio: 0..1 (Assets-Gesamtfortschritt) func ensureAssetsForVideoWithProgress(videoPath string, sourceURL string, onRatio func(r float64)) error { ctx := context.Background() _, err := ensureAssetsForVideoWithProgressCtx(ctx, videoPath, sourceURL, onRatio) return err } // Task/Bulk sollte diesen Context-aware Call nutzen. func ensureAssetsForVideoWithProgressCtx(ctx context.Context, videoPath string, sourceURL string, onRatio func(r float64)) (EnsureAssetsResult, error) { res, err := ensureAssetsForVideoDetailed(ctx, videoPath, sourceURL, onRatio) return res, err } func ensurePrimaryAssetsForVideoWithProgressCtx( ctx context.Context, videoPath string, sourceURL string, onRatio func(r float64), ) (EnsurePrimaryAssetsResult, error) { var out EnsurePrimaryAssetsResult var sourceInputInvalid bool videoPath = strings.TrimSpace(videoPath) if videoPath == "" { return out, nil } fi, statErr := os.Stat(videoPath) if statErr != nil || fi.IsDir() || fi.Size() <= 0 { return out, nil } if age := time.Since(fi.ModTime()); age < 10*time.Second { wait := 10*time.Second - age if wait > 0 { t := time.NewTimer(wait) defer t.Stop() select { case <-t.C: case <-ctx.Done(): return out, ctx.Err() } } } id := assetIDFromVideoPath(videoPath) if id == "" { return out, nil } _, thumbPath, previewPath, _, metaPath, perr := assetPathsForID(id) if perr != nil { return out, perr } progress := func(r float64) { if onRatio == nil { return } if r < 0 { r = 0 } if r > 1 { r = 1 } onRatio(r) } thumbBefore := func() bool { if tfi, err := os.Stat(thumbPath); err == nil && !tfi.IsDir() && tfi.Size() > 0 { return true } return false }() previewBefore := func() bool { if pfi, err := os.Stat(previewPath); err == nil && !pfi.IsDir() && pfi.Size() > 0 { return true } return false }() meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) out.MetaOK = meta.ok if thumbBefore && previewBefore { out.Skipped = true progress(1) return out, nil } const ( thumbsW = 0.25 previewW = 0.75 ) progress(0) // ---------------- // Thumb // ---------------- if thumbBefore { progress(thumbsW) } else { progress(0.05) func() { genCtx, cancel := context.WithTimeout(ctx, 45*time.Second) defer cancel() if err := thumbSem.Acquire(genCtx); err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return } return } defer thumbSem.Release() progress(0.10) img, e1 := extractLastFrameJPG(videoPath) if e1 != nil || len(img) == 0 { if meta.durSec > 0 { t := meta.durSec - 0.25 if t < 0 { t = 0 } img, e1 = extractFrameAtTimeJPG(videoPath, t) } if e1 != nil || len(img) == 0 { img, e1 = extractFirstFrameJPGScaled(videoPath, 720, 75) } } progress(0.20) if e1 == nil && len(img) > 0 { if err := atomicWriteFile(thumbPath, img); err == nil { out.ThumbGenerated = true } else { appLogln("⚠️ thumb write:", err) } } }() progress(thumbsW) } // ---------------- // Preview / Teaser // ---------------- const ( previewClipLenSec = 0.75 previewMaxClips = 12 ) var computedPreviewClips []previewClip if previewBefore { progress(1) } else { func() { genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) defer cancel() progress(thumbsW + 0.02) if err := genSem.Acquire(genCtx); err != nil { return } defer genSem.Release() progress(thumbsW + 0.05) if err := generateTeaserClipsMP4WithProgress( genCtx, videoPath, previewPath, previewClipLenSec, previewMaxClips, func(r float64) { if r < 0 { r = 0 } if r > 1 { r = 1 } progress(thumbsW + r*previewW) }, ); err != nil { if isFFmpegInputInvalidError(err) { sourceInputInvalid = true return } return } out.PreviewGenerated = true if !(meta.durSec > 0) { return } opts := TeaserPreviewOptions{ Segments: previewMaxClips, SegmentDuration: previewClipLenSec, } starts, segDur, _ := computeTeaserStarts(meta.durSec, opts) clips := make([]previewClip, 0, len(starts)) for _, s := range starts { clips = append(clips, previewClip{ StartSeconds: math.Round(s*1000) / 1000, DurationSeconds: math.Round(segDur*1000) / 1000, }) } computedPreviewClips = clips }() progress(1) } // Meta mit Preview-Clips aktualisieren (best effort) if meta.durSec > 0 && !sourceInputInvalid { _ = writeVideoMetaWithPreviewClipsAndSprite( metaPath, fi, meta.durSec, meta.vw, meta.vh, meta.fps, meta.sourceURL, computedPreviewClips, nil, ) } return out, nil } func ensureMetaForVideoCtx( ctx context.Context, videoPath string, sourceURL string, ) (bool, error) { videoPath = strings.TrimSpace(videoPath) if videoPath == "" { return false, nil } fi, err := os.Stat(videoPath) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { return false, nil } id := assetIDFromVideoPath(videoPath) if id == "" { return false, nil } _, _, _, _, metaPath, perr := assetPathsForID(id) if perr != nil { return false, perr } meta, err := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) if err != nil { return false, err } return meta.ok, nil } func ensureThumbForVideoCtx( ctx context.Context, videoPath string, sourceURL string, ) (bool, error) { videoPath = strings.TrimSpace(videoPath) if videoPath == "" { return false, nil } fi, err := os.Stat(videoPath) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { return false, nil } id := assetIDFromVideoPath(videoPath) if id == "" { return false, nil } _, thumbPath, _, _, metaPath, perr := assetPathsForID(id) if perr != nil { return false, perr } // Schon vorhanden -> nichts zu tun if tfi, err := os.Stat(thumbPath); err == nil && tfi != nil && !tfi.IsDir() && tfi.Size() > 0 { return false, nil } // Meta best effort sicherstellen, damit wir Duration für den "kurz vor Ende"-Fallback haben meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) genCtx, cancel := context.WithTimeout(ctx, 45*time.Second) defer cancel() if err := thumbSem.Acquire(genCtx); err != nil { return false, err } defer thumbSem.Release() // Immer letztes Frame bevorzugen img, e1 := extractLastFrameJPG(videoPath) if e1 != nil || len(img) == 0 { // Fallback: kurz vor Ende if meta.durSec > 0 { t := meta.durSec - 0.25 if t < 0 { t = 0 } img, e1 = extractFrameAtTimeJPG(videoPath, t) } // Letzter Fallback: erstes Frame if e1 != nil || len(img) == 0 { img, e1 = extractFirstFrameJPGScaled(videoPath, 720, 75) } } if e1 != nil { if isFFmpegInputInvalidError(e1) { return false, nil } return false, e1 } if len(img) == 0 { return false, nil } if err := atomicWriteFile(thumbPath, img); err != nil { return false, err } return true, nil } func ensureTeaserForVideoCtx( ctx context.Context, videoPath string, sourceURL string, ) (bool, error) { videoPath = strings.TrimSpace(videoPath) if videoPath == "" { return false, nil } fi, err := os.Stat(videoPath) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { return false, nil } id := assetIDFromVideoPath(videoPath) if id == "" { return false, nil } _, _, previewPath, _, metaPath, perr := assetPathsForID(id) if perr != nil { return false, perr } if pfi, err := os.Stat(previewPath); err == nil && pfi != nil && !pfi.IsDir() && pfi.Size() > 0 { return false, nil } meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) const ( previewClipLenSec = 0.75 previewMaxClips = 12 ) genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) defer cancel() if err := genSem.Acquire(genCtx); err != nil { return false, err } defer genSem.Release() if err := generateTeaserClipsMP4WithProgress( genCtx, videoPath, previewPath, previewClipLenSec, previewMaxClips, nil, ); err != nil { if isFFmpegInputInvalidError(err) { return false, appErrorf("teaser ffmpeg input ungültig für %s: %w", videoPath, err) } return false, appErrorf("teaser generation fehlgeschlagen für %s: %w", videoPath, err) } // Zusätzliche Sicherheitsprüfung: Datei muss nach der Generierung wirklich existieren if pfi, err := os.Stat(previewPath); err != nil || pfi == nil || pfi.IsDir() || pfi.Size() <= 0 { return false, appErrorf("teaser wurde ohne gültige preview.mp4 beendet: %s", previewPath) } var computedPreviewClips []previewClip if meta.durSec > 0 { opts := TeaserPreviewOptions{ Segments: previewMaxClips, SegmentDuration: previewClipLenSec, } starts, segDur, _ := computeTeaserStarts(meta.durSec, opts) clips := make([]previewClip, 0, len(starts)) for _, s := range starts { clips = append(clips, previewClip{ StartSeconds: math.Round(s*1000) / 1000, DurationSeconds: math.Round(segDur*1000) / 1000, }) } computedPreviewClips = clips } var spriteMeta *previewSpriteMeta if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.Preview.Sprite != nil { spriteMeta = oldMeta.Preview.Sprite } if meta.durSec > 0 { _ = writeVideoMetaWithPreviewClipsAndSprite( metaPath, fi, meta.durSec, meta.vw, meta.vh, meta.fps, meta.sourceURL, computedPreviewClips, spriteMeta, ) } return true, nil } func ensureSpritesForVideoCtx( ctx context.Context, videoPath string, sourceURL string, ) (bool, error) { videoPath = strings.TrimSpace(videoPath) if videoPath == "" { return false, nil } fi, err := os.Stat(videoPath) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { return false, nil } id := assetIDFromVideoPath(videoPath) if id == "" { return false, nil } _, _, _, spritePath, metaPath, perr := assetPathsForID(id) if perr != nil { return false, perr } if sfi, err := os.Stat(spritePath); err == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 { return false, nil } meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) if !(meta.durSec > 0) { return false, nil } genCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) defer cancel() if err := genSem.Acquire(genCtx); err != nil { return false, err } defer genSem.Release() cols, rows, count, cellW, cellH := fixedPreviewSpriteLayout() stepSec := previewSpriteStepSeconds(meta.durSec) if err := generatePreviewSpriteJPG( genCtx, videoPath, spritePath, cols, rows, stepSec, cellW, cellH, ); err != nil { if sfi, statErr := os.Stat(spritePath); statErr == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 { return false, nil } return false, err } var computedPreviewClips []previewClip if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && len(oldMeta.Preview.Clips) > 0 { computedPreviewClips = oldMeta.Preview.Clips } spriteMeta := &previewSpriteMeta{ Path: fmt.Sprintf("/api/preview-sprite/%s", id), Count: count, Cols: cols, Rows: rows, StepSeconds: stepSec, CellWidth: cellW, CellHeight: cellH, } _ = writeVideoMetaWithPreviewClipsAndSprite( metaPath, fi, meta.durSec, meta.vw, meta.vh, meta.fps, meta.sourceURL, computedPreviewClips, spriteMeta, ) return true, nil } func ensureAnalyzeForVideoCtx( ctx context.Context, videoPath string, sourceURL string, goal string, ) (bool, error) { videoPath = strings.TrimSpace(videoPath) if videoPath == "" { return false, nil } goal = normalizeAIGoal(goal) if goal == "" { goal = "nsfw" } if hasAIAnalysisForOutputGoal(videoPath, goal) { return false, nil } fi, err := os.Stat(videoPath) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { return false, nil } id := assetIDFromVideoPath(videoPath) if id == "" { return false, nil } _, _, _, _, _, perr := assetPathsForID(id) if perr != nil { return false, perr } // Analyse läuft jetzt frame-basiert und braucht kein preview-sprite.jpg mehr. if _, err := ensureAnalyzeAllGoalsForVideoCtx(ctx, videoPath, sourceURL); err != nil { return false, err } return hasAIAnalysisForOutputGoal(videoPath, goal), nil } func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string, goal string) error { videoPath = strings.TrimSpace(videoPath) if videoPath == "" { return nil } fi, err := os.Stat(videoPath) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { return nil } id := assetIDFromVideoPath(videoPath) if id == "" { return nil } // Sprite weiterhin best-effort erzeugen. if _, err := ensureSpritesForVideoCtx(ctx, videoPath, sourceURL); err != nil { appLogln("⚠️ deferred sprite failed:", err) } // Analyse genau einmal für alle Standard-Ziele. if _, err := ensureAnalyzeAllGoalsForVideoCtx(ctx, videoPath, sourceURL); err != nil { return appErrorf("deferred analyze failed for %s: %w", videoPath, err) } goal = normalizeAIGoal(goal) if goal == "" { goal = "highlights" } if !hasAIAnalysisForOutputGoal(videoPath, goal) { return appErrorf("Analyse fehlt nach deferred analyze: %s", id) } return nil } func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string) bool { outPath = strings.TrimSpace(outPath) if outPath == "" { return false } fileName := filepath.Base(outPath) assetID := assetIDFromVideoPath(outPath) if assetID == "" { return false } requiredGoals := requiredAnalyzeGoals() truth := assetsTruthForVideo(outPath) needMeta := !truth.MetaReady needThumb := !truth.ThumbReady needTeaser := !truth.TeaserReady needSprites := !truth.SpritesReady needAnalyze := false for _, goal := range requiredGoals { if !hasAIAnalysisForOutputGoal(outPath, goal) { needAnalyze = true break } } // Nichts zu tun -> erfolgreich if !needMeta && !needThumb && !needTeaser && !needSprites && !needAnalyze { return true } // Nur fehlende Phasen als queued publishen if needMeta { publishDeferredPhase(fileName, assetID, "postwork", "meta", "queued", "Meta wartet", nil) } if needThumb { publishDeferredPhase(fileName, assetID, "postwork", "thumb", "queued", "Vorschaubild wartet", nil) } if needTeaser { publishDeferredPhase(fileName, assetID, "postwork", "teaser", "queued", "Teaser wartet", nil) } if needSprites { publishDeferredPhase(fileName, assetID, "postwork", "sprites", "queued", "Sprites warten", nil) } if needAnalyze { publishDeferredPhase(fileName, assetID, "enrich", "analyze", "queued", "Analysen warten", nil) } sortBucket, sortName := postWorkSortForVideoPath(outPath) ok := enrichQ.Enqueue(PostWorkTask{ Key: "enrich:" + assetID, Added: time.Now(), SortBucket: sortBucket, SortName: sortName, Run: func(ctx context.Context) error { if err := waitUntilEnrichMayRun(ctx); err != nil { return err } return runDeferredEnrichPipeline(ctx, outPath, sourceURL) }, }) if ok { return true } enqueueErr := appErrorf("enqueue failed") // queued-Zustände auf error drehen, aber nur für wirklich fehlende Phasen if needMeta { publishDeferredPhase(fileName, assetID, "postwork", "meta", "error", "Meta konnte nicht eingeplant werden", enqueueErr) } if needThumb { publishDeferredPhase(fileName, assetID, "postwork", "thumb", "error", "Vorschaubild konnte nicht eingeplant werden", enqueueErr) } if needTeaser { publishDeferredPhase(fileName, assetID, "postwork", "teaser", "error", "Teaser konnte nicht eingeplant werden", enqueueErr) } if needSprites { publishDeferredPhase(fileName, assetID, "postwork", "sprites", "error", "Sprites konnten nicht eingeplant werden", enqueueErr) } if needAnalyze { publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "Analysen konnten nicht eingeplant werden", enqueueErr) } return false } // Core: generiert thumbs/preview/sprite/meta und sagt zurück was passiert ist. func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceURL string, onRatio func(r float64)) (EnsureAssetsResult, error) { var out EnsureAssetsResult var sourceInputInvalid bool videoPath = strings.TrimSpace(videoPath) if videoPath == "" { return out, nil } fi, statErr := os.Stat(videoPath) if statErr != nil || fi.IsDir() || fi.Size() <= 0 { return out, nil } // 🔒 Schutz gegen Race: sehr frische Dateien sind evtl. noch nicht finalisiert/kopiert. // Statt direkt zu skippen: kurz warten und dann weitermachen (sonst gibt es keinen Retry). if age := time.Since(fi.ModTime()); age < 10*time.Second { wait := 10*time.Second - age // nicht ewig blocken, respektiere ctx if wait > 0 { t := time.NewTimer(wait) defer t.Stop() select { case <-t.C: // weiter case <-ctx.Done(): return out, ctx.Err() } } } id := assetIDFromVideoPath(videoPath) if id == "" { return out, nil } _, thumbPath, previewPath, spritePath, metaPath, perr := assetPathsForID(id) if perr != nil { return out, perr } progress := func(r float64) { if onRatio == nil { return } if r < 0 { r = 0 } if r > 1 { r = 1 } onRatio(r) } // Vorher-Checks (für Result) thumbBefore := func() bool { if tfi, err := os.Stat(thumbPath); err == nil && !tfi.IsDir() && tfi.Size() > 0 { return true } return false }() previewBefore := func() bool { if pfi, err := os.Stat(previewPath); err == nil && !pfi.IsDir() && pfi.Size() > 0 { return true } return false }() spriteBefore := func() bool { if sfi, err := os.Stat(spritePath); err == nil && !sfi.IsDir() && sfi.Size() > 0 { return true } return false }() // Meta sicherstellen (dedupliziert) meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) out.MetaOK = meta.ok // Wenn alles da ist: als skipped markieren, // aber NICHT sofort returnen, damit meta.json // (previewClips / previewSprite) trotzdem sauber geschrieben wird. metaHasSprite := false if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.Preview.Sprite != nil { metaHasSprite = true } metaHasAI := hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals()) // Nur dann wirklich komplett "fertig", wenn auch AI vorhanden ist if thumbBefore && previewBefore && spriteBefore && meta.ok && metaHasSprite && metaHasAI { out.Skipped = true progress(1) return out, nil } // Gewichte: thumbs klein, preview groß const ( thumbsW = 0.25 previewW = 0.75 ) progress(0) // ---------------- // Thumbs (JPG-only) // ---------------- if thumbBefore { progress(thumbsW) } else { progress(0.05) func() { genCtx, cancel := context.WithTimeout(ctx, 45*time.Second) defer cancel() // Acquire; wenn Context cancelled → Fehler zurück if err := thumbSem.Acquire(genCtx); err != nil { // wenn ctx cancelled -> hart zurück, sonst best-effort weiter if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return } return } defer thumbSem.Release() progress(0.10) // ✅ Immer letztes Frame bevorzugen (Preview soll “Endzustand” zeigen) img, e1 := extractLastFrameJPG(videoPath) if e1 != nil || len(img) == 0 { // Fallback: wenn wir Duration kennen, versuche kurz vor Ende if meta.durSec > 0 { t := meta.durSec - 0.25 if t < 0 { t = 0 } img, e1 = extractFrameAtTimeJPG(videoPath, t) } // Letzter Fallback: erstes Frame if e1 != nil || len(img) == 0 { img, e1 = extractFirstFrameJPGScaled(videoPath, 720, 75) } } progress(0.20) if e1 == nil && len(img) > 0 { if err := atomicWriteFile(thumbPath, img); err == nil { out.ThumbGenerated = true } else { appLogln("⚠️ thumb write:", err) } } }() progress(thumbsW) } // ---------------- // Preview (MP4 teaser clips) // ---------------- const ( previewClipLenSec = 0.75 previewMaxClips = 12 ) var computedPreviewClips []previewClip if previewBefore { // Preview ist schon da -> nicht returnen, weil Sprite evtl. noch fehlt progress(thumbsW + previewW) } else { func() { genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) defer cancel() progress(thumbsW + 0.02) if err := genSem.Acquire(genCtx); err != nil { return } defer genSem.Release() progress(thumbsW + 0.05) if err := generateTeaserClipsMP4WithProgress(genCtx, videoPath, previewPath, previewClipLenSec, previewMaxClips, func(r float64) { if r < 0 { r = 0 } if r > 1 { r = 1 } progress(thumbsW + r*previewW) }); err != nil { if isFFmpegInputInvalidError(err) { sourceInputInvalid = true return } return } out.PreviewGenerated = true // ✅ Preview-Clips berechnen (noch NICHT direkt schreiben) if !(meta.durSec > 0) { return } // exakt dieselben Werte wie beim tatsächlichen Preview-Rendern opts := TeaserPreviewOptions{ Segments: previewMaxClips, SegmentDuration: previewClipLenSec, } starts, segDur, _ := computeTeaserStarts(meta.durSec, opts) clips := make([]previewClip, 0, len(starts)) for _, s := range starts { clips = append(clips, previewClip{ StartSeconds: math.Round(s*1000) / 1000, DurationSeconds: math.Round(segDur*1000) / 1000, }) } // ✅ merken für finalen Meta-Write computedPreviewClips = clips }() } // ---------------- // Preview Sprite (festes Layout) // ---------------- var spriteMeta *previewSpriteMeta if meta.durSec > 0 { cols, rows, count, cellW, cellH := fixedPreviewSpriteLayout() stepSec := previewSpriteStepSeconds(meta.durSec) spriteMeta = &previewSpriteMeta{ Path: fmt.Sprintf("/api/preview-sprite/%s", id), Count: count, Cols: cols, Rows: rows, StepSeconds: stepSec, CellWidth: cellW, CellHeight: cellH, } } if !spriteBefore { func() { if sourceInputInvalid { return } if !(meta.durSec > 0) { return } genCtx, cancel := context.WithTimeout(ctx, 4*time.Minute) defer cancel() if err := genSem.Acquire(genCtx); err != nil { return } defer genSem.Release() cols, rows, _, cellW, cellH := fixedPreviewSpriteLayout() stepSec := previewSpriteStepSeconds(meta.durSec) if err := generatePreviewSpriteJPG( genCtx, videoPath, spritePath, cols, rows, stepSec, cellW, cellH, ); err != nil { if sfi, statErr := os.Stat(spritePath); statErr == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 { return } return } out.SpriteGenerated = true }() } // ✅ Final meta write: Clips + Sprite zusammen persistieren if meta.durSec > 0 { // Falls wir in diesem Lauf keine neuen Clips berechnet haben: // alte aus gültigem meta.json übernehmen (best effort) if len(computedPreviewClips) == 0 { if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && len(oldMeta.Preview.Clips) > 0 { computedPreviewClips = oldMeta.Preview.Clips } } // Falls wir in diesem Lauf kein neues spriteMeta gesetzt haben: // altes aus gültigem meta.json übernehmen if spriteMeta == nil { if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.Preview.Sprite != nil { spriteMeta = oldMeta.Preview.Sprite } } _ = writeVideoMetaWithPreviewClipsAndSprite( metaPath, fi, meta.durSec, meta.vw, meta.vh, meta.fps, meta.sourceURL, computedPreviewClips, spriteMeta, ) } progress(1) return out, nil } func hasAIResultsForOutputGoal(outPath string, goal string) bool { outPath = strings.TrimSpace(outPath) if outPath == "" { return false } id := assetIDFromVideoPath(outPath) if id == "" { return false } metaPath, err := generatedMetaFile(id) if err != nil || strings.TrimSpace(metaPath) == "" { return false } goal = normalizeAIGoal(goal) aiMap, ok := readVideoMetaAIForGoal(metaPath, goal) if !ok || aiMap == nil { return false } rawHits, hasHits := aiMap["hits"].([]any) rawSegs, hasSegs := aiMap["segments"].([]any) return (hasHits && len(rawHits) > 0) || (hasSegs && len(rawSegs) > 0) } func hasAIAnalysisForOutputGoal(outPath string, goal string) bool { outPath = strings.TrimSpace(outPath) if outPath == "" { return false } id := assetIDFromVideoPath(outPath) if id == "" { return false } metaPath, err := generatedMetaFile(id) if err != nil || strings.TrimSpace(metaPath) == "" { return false } goal = normalizeAIGoal(goal) aiMap, ok := readVideoMetaAIForGoal(metaPath, goal) if !ok || aiMap == nil { return false } // Wichtig: // Auch eine Analyse ohne Treffer ist fertig, solange sie gespeichert wurde. if rawGoal, ok := aiMap["goal"].(string); ok && normalizeAIGoal(rawGoal) == goal { return true } if rawMode, ok := aiMap["mode"].(string); ok && strings.TrimSpace(rawMode) != "" { return true } if _, ok := aiMap["hits"]; ok { return true } if _, ok := aiMap["segments"]; ok { return true } if _, ok := aiMap["analyzedAtUnix"]; ok { return true } return false } func hasAIAnalysisForAllOutputGoals(outPath string, goals []string) bool { outPath = strings.TrimSpace(outPath) if outPath == "" { return false } if len(goals) == 0 { goals = requiredAnalyzeGoals() } for _, goal := range goals { goal = normalizeAIGoal(goal) if !hasAIAnalysisForOutputGoal(outPath, goal) { return false } } return true } type PrepareSplitResult struct { AssetsReady bool AnalyzeReady bool SpriteReady bool MetaOK bool } func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string) (PrepareSplitResult, error) { var out PrepareSplitResult videoPath = strings.TrimSpace(videoPath) if videoPath == "" { return out, appErrorf("empty videoPath") } fi, err := os.Stat(videoPath) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { return out, appErrorf("video datei nicht gefunden") } // 1) Nur schnelle / primäre Assets sicherstellen: // - preview.jpg // - preview.mp4 // - basis-meta (duration / width / height / fps) primaryRes, err := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, videoPath, sourceURL, nil) if err != nil { return out, err } out.MetaOK = primaryRes.MetaOK id := assetIDFromVideoPath(videoPath) if id == "" { return out, appErrorf("konnte asset id nicht ableiten") } _, thumbPath, previewPath, spritePath, _, perr := assetPathsForID(id) if perr != nil { return out, perr } thumbExists := func() bool { if fi, err := os.Stat(thumbPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 { return true } return false }() previewExists := func() bool { if fi, err := os.Stat(previewPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 { return true } return false }() out.AssetsReady = thumbExists && previewExists goal = strings.ToLower(strings.TrimSpace(goal)) if goal == "" { goal = "highlights" } // 2) Für Split brauchen wir zusätzlich Sprite + AI. // Deshalb die langsamen Deferred-Schritte hier bei Bedarf synchron nachziehen. // (Im normalen Record-Flow laufen sie separat im Hintergrund.) if err := ensureDeferredAssetsAndAI(ctx, videoPath, sourceURL, goal); err != nil { // best effort: kein harter Fehler, Status unten nochmal aus Dateisystem/meta ableiten appLogln("⚠️ prepareVideoForSplit deferred enrich:", err) } spriteExists := func() bool { if fi, err := os.Stat(spritePath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 { return true } return false }() out.SpriteReady = spriteExists // 3) AI-Segmente prüfen if hasAIAnalysisForOutputGoal(videoPath, goal) { out.AnalyzeReady = true return out, nil } // 4) Fallback: AI direkt berechnen durationSec, _ := durationSecondsForAnalyze(ctx, videoPath) hits, aerr := analyzeVideoFromFrames(ctx, videoPath, goal) if aerr != nil { return out, nil } segments := buildAnalyzeSegmentsForGoal(hits, durationSec, goal) var rating *aiRatingMeta if goal == "nsfw" || goal == "highlights" { rating = computeNSFWRating(segments, durationSec) } ai := &aiAnalysisMeta{ Goal: goal, Mode: "sprite", Hits: hits, Segments: segments, Rating: rating, AnalyzedAtUnix: time.Now().Unix(), } if werr := writeVideoAIForFile(ctx, videoPath, sourceURL, ai); werr != nil { return out, nil } out.AnalyzeReady = hasAIAnalysisForOutputGoal(videoPath, goal) return out, nil }