nsfwapp/backend/assets_generate.go
2026-04-28 11:02:08 +02:00

1446 lines
33 KiB
Go

// 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 "", "", "", "", "", 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.jpg")
previewPath = filepath.Join(assetDir, "preview.mp4")
spritePath = filepath.Join(assetDir, "preview-sprite.jpg")
metaPath, _ = metaJSONPathForAssetID(id) // bevorzugt generated/meta/<id>/meta.json
if strings.TrimSpace(metaPath) == "" {
metaPath = filepath.Join(assetDir, "meta.json")
}
return assetDir, thumbPath, previewPath, spritePath, metaPath, 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 weiterhin inhaltlich prüfen
truth.AnalyzeReady = hasAIResultsForOutput(videoPath)
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 {
fmt.Println("⚠️ 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, fmt.Errorf("teaser ffmpeg input ungültig für %s: %w", videoPath, err)
}
return false, fmt.Errorf("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, fmt.Errorf("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
}
if hasAIResultsForOutput(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
}
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
if sfi, err := os.Stat(spritePath); err != nil || sfi == nil || sfi.IsDir() || sfi.Size() <= 0 {
return false, nil
}
goal = strings.ToLower(strings.TrimSpace(goal))
if goal == "" {
goal = "nsfw"
}
actx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
durationSec, _ := durationSecondsForAnalyze(actx, videoPath)
hits, aerr := analyzeVideoFromSprite(actx, videoPath, goal)
if aerr != nil {
return false, nil
}
segments := buildSegmentsFromAnalyzeHits(hits, durationSec)
var rating *aiRatingMeta
if goal == "nsfw" {
rating = computeNSFWRating(segments, durationSec)
}
ai := &aiAnalysisMeta{
Goal: goal,
Mode: "sprite",
Hits: hits,
Segments: segments,
Rating: rating,
AnalyzedAtUnix: time.Now().Unix(),
}
if werr := writeVideoAIForFile(actx, videoPath, meta.sourceURL, ai); werr != nil {
return false, nil
}
return true, nil
}
func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL 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
}
// NUR noch langsame Background-Schritte:
// 1) Sprite
if _, err := ensureSpritesForVideoCtx(ctx, videoPath, sourceURL); err != nil {
return fmt.Errorf("deferred sprite failed for %s: %w", videoPath, err)
}
spriteTruth := assetsTruthForVideo(videoPath)
if !spriteTruth.SpritesReady {
return fmt.Errorf("preview-sprite.jpg fehlt nach deferred generation: %s", id)
}
// 2) Analyse
analyzeReady, err := ensureAnalyzeForVideoCtx(ctx, videoPath, sourceURL, "nsfw")
if err != nil {
return fmt.Errorf("deferred analyze failed for %s: %w", videoPath, err)
}
analyzeTruth := assetsTruthForVideo(videoPath)
if !analyzeReady && !analyzeTruth.AnalyzeReady {
return fmt.Errorf("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
}
truth := assetsTruthForVideo(outPath)
needMeta := !truth.MetaReady
needThumb := !truth.ThumbReady
needTeaser := !truth.TeaserReady
needSprites := !truth.SpritesReady
needAnalyze := !truth.AnalyzeReady
// 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", "Analyse wartet", 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 := fmt.Errorf("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", "Analyse konnte 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 := 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 (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 {
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
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 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
}
aiMap, ok := readVideoMetaAI(metaPath)
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) 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, fmt.Errorf("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 = "nsfw"
}
// 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); err != nil {
// best effort: kein harter Fehler, Status unten nochmal aus Dateisystem/meta ableiten
fmt.Println("⚠️ 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 hasAIResultsForOutput(videoPath) {
out.AnalyzeReady = true
return out, nil
}
// 4) Falls trotz Deferred-Step noch kein Sprite da ist, können wir AI nicht ausführen
if !spriteExists {
return out, nil
}
// 5) Letzter Fallback: AI jetzt direkt berechnen
durationSec, _ := durationSecondsForAnalyze(ctx, videoPath)
hits, aerr := analyzeVideoFromSprite(ctx, videoPath, goal)
if aerr != nil {
return out, nil
}
segments := buildSegmentsFromAnalyzeHits(hits, durationSec)
var rating *aiRatingMeta
if goal == "nsfw" {
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 = len(segments) > 0
return out, nil
}