// backend\assets_generate.go package main import ( "context" "encoding/json" "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 { base := filepath.Base(strings.TrimSpace(videoPath)) if base == "" { return "" } id := strings.TrimSuffix(base, filepath.Ext(base)) id = stripHotPrefix(id) return strings.TrimSpace(id) } // Liefert die standardisierten Pfade (preview.webp / preview.mp4 / preview-sprite.webp / meta.json) func assetPathsForID(id string) (assetDir, thumbPath, previewPath, spritePath, metaPath string, err error) { id = strings.TrimSpace(id) if id == "" { return "", "", "", "", "", fmt.Errorf("empty id") } assetDir, err = ensureGeneratedDir(id) if err != nil || strings.TrimSpace(assetDir) == "" { return "", "", "", "", "", fmt.Errorf("generated dir: %v", err) } thumbPath = filepath.Join(assetDir, "preview.webp") previewPath = filepath.Join(assetDir, "preview.mp4") spritePath = filepath.Join(assetDir, "preview-sprite.webp") 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 } 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 } // 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 } // 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.PreviewSprite != nil { metaHasSprite = true } metaHasAI := hasAIResultsForOutput(videoPath) // 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 (WebP-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 := extractLastFrameWebP(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 = extractFrameAtTimeWebP(videoPath, t) } // Letzter Fallback: erstes Frame if e1 != nil || len(img) == 0 { img, e1 = extractFirstFrameWebPScaled(videoPath, 720, 75) } } progress(0.20) if e1 == nil && len(img) > 0 { if err := atomicWriteFile(thumbPath, img); err == nil { out.ThumbGenerated = true } else { fmt.Println("⚠️ 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 fmt.Printf("⚠️ preview clips skipped (invalid/incomplete input): %s\n", videoPath) return } fmt.Println("⚠️ preview clips:", err) 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, 2*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 := generatePreviewSpriteWebP( 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 { // Sprite existiert am Ende trotzdem -> Warnung unterdrücken return } // Optional: nur kurze Meldung statt voller ffmpeg-Ausgabe //fmt.Printf("⚠️ preview sprite failed for %s\n", videoPath) 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.PreviewClips) > 0 { computedPreviewClips = oldMeta.PreviewClips } } // 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.PreviewSprite != nil { spriteMeta = oldMeta.PreviewSprite } } _ = writeVideoMetaWithPreviewClipsAndSprite( metaPath, fi, meta.durSec, meta.vw, meta.vh, meta.fps, meta.sourceURL, computedPreviewClips, spriteMeta, ) } progress(1) return out, nil } func hasAIResultsForOutput(outPath 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 } b, err := os.ReadFile(metaPath) if err != nil || len(b) == 0 { return false } var m map[string]any dec := json.NewDecoder(strings.NewReader(string(b))) dec.UseNumber() if err := dec.Decode(&m); err != nil { return false } aiMap, ok := m["ai"].(map[string]any) 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) } 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, fmt.Errorf("empty videoPath") } fi, err := os.Stat(videoPath) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { return out, fmt.Errorf("video datei nicht gefunden") } // 1) Assets sicherstellen (preview.webp / preview.mp4 / preview-sprite.webp / meta.json) assetsRes, err := ensureAssetsForVideoDetailed(ctx, videoPath, sourceURL, nil) if err != nil { return out, err } _ = assetsRes id := assetIDFromVideoPath(videoPath) if id == "" { return out, fmt.Errorf("konnte asset id nicht ableiten") } ps := previewSpriteTruthForID(id) out.SpriteReady = ps.Exists out.AssetsReady = ps.Exists out.MetaOK = true // 2) AI-Segmente prüfen if hasAIResultsForOutput(videoPath) { out.AnalyzeReady = true return out, nil } goal = strings.ToLower(strings.TrimSpace(goal)) if goal == "" { goal = "nsfw" } // 3) AI nur ausführen, wenn Sprite vorhanden ist if !ps.Exists { return out, nil } durationSec, _ := durationSecondsForAnalyze(ctx, videoPath) hits, aerr := analyzeVideoFromSprite(ctx, videoPath, goal) if aerr != nil { return out, nil } segments := buildSegmentsFromAnalyzeHits(hits, durationSec) ai := &aiAnalysisMeta{ Goal: goal, Mode: "sprite", Hits: hits, Segments: segments, AnalyzedAtUnix: time.Now().Unix(), } if werr := writeVideoAIForFile(ctx, videoPath, sourceURL, ai); werr != nil { return out, nil } out.AnalyzeReady = len(segments) > 0 return out, nil }