1614 lines
37 KiB
Go
1614 lines
37 KiB
Go
// 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 {
|
|
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/<id>/meta.json
|
|
if strings.TrimSpace(metaPath) == "" {
|
|
metaPath = filepath.Join(assetDir, "meta.json")
|
|
}
|
|
|
|
return assetDir, thumbPath, previewPath, spritePath, metaPath, nil
|
|
}
|
|
|
|
func requiredAnalyzeGoals() []string {
|
|
return []string{"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
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
if skip, reason, err := checkPreviewSpriteBeforeAnalysis(actx, videoPath); err != nil {
|
|
return false, err
|
|
} else if skip {
|
|
return false, skipAnalysisBecauseBadSpriteError(reason)
|
|
}
|
|
|
|
hits, err := analyzeVideoFromFrames(actx, videoPath)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
segments := buildAnalyzeSegmentsForGoal(hits, durationSec)
|
|
segments = prepareAIRatingSegments(segments)
|
|
|
|
rating := computeHighlightRatingForVideo(segments, durationSec, videoPath)
|
|
|
|
ai := &aiAnalysisMeta{
|
|
Goal: "highlights",
|
|
Mode: "video",
|
|
Completed: true,
|
|
Hits: hits,
|
|
Segments: segments,
|
|
Rating: rating,
|
|
AnalyzedAtUnix: time.Now().Unix(),
|
|
}
|
|
|
|
if err := writeVideoAIForFile(actx, videoPath, meta.sourceURL, ai); err != nil {
|
|
return false, err
|
|
}
|
|
|
|
analysisReady := hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals())
|
|
if !analysisReady {
|
|
return false, nil
|
|
}
|
|
|
|
// Direkt nach erfolgreicher Analyse löschen.
|
|
// Wichtig: hier rating übergeben, nicht nil.
|
|
autoDeleteLowRatedDownloadAfterAnalysis(actx, videoPath, rating)
|
|
|
|
return true, 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 läuft frame-basiert und braucht kein Sprite mehr.
|
|
truth.AnalyzeReady = 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 = "highlights"
|
|
|
|
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
|
|
}
|
|
|
|
analyzed, err := ensureAnalyzeAllGoalsForVideoCtx(ctx, videoPath, sourceURL)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if analyzed {
|
|
if _, statErr := os.Stat(videoPath); os.IsNotExist(statErr) {
|
|
// Analyse war erfolgreich, Datei wurde danach absichtlich durch AutoDelete gelöscht.
|
|
return true, nil
|
|
}
|
|
}
|
|
|
|
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.
|
|
analyzed, err := ensureAnalyzeAllGoalsForVideoCtx(ctx, videoPath, sourceURL)
|
|
if err != nil {
|
|
return appErrorf("deferred analyze failed for %s: %w", videoPath, err)
|
|
}
|
|
|
|
if analyzed {
|
|
if _, statErr := os.Stat(videoPath); os.IsNotExist(statErr) {
|
|
// Analyse war erfolgreich, Datei wurde danach absichtlich durch AutoDelete gelöscht.
|
|
return nil
|
|
}
|
|
}
|
|
|
|
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()
|
|
enrichEnabled := enrichPostworkEnabled()
|
|
|
|
truth := assetsTruthForVideo(outPath)
|
|
|
|
needMeta := !truth.MetaReady
|
|
needThumb := !truth.ThumbReady
|
|
needTeaser := !truth.TeaserReady
|
|
|
|
// Sprites + Analyse gehören zur langsamen Enrich-Nacharbeit.
|
|
// Wenn Enrich deaktiviert ist, dürfen sie nicht als Fehler oder "wartend" auftauchen.
|
|
needSprites := enrichEnabled && !truth.SpritesReady
|
|
|
|
needAnalyze := false
|
|
if enrichEnabled {
|
|
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", "Analyse wartet", nil)
|
|
}
|
|
|
|
sortBucket, sortName := postWorkSortForVideoPath(outPath)
|
|
|
|
ok := enqueueEnrichPostwork(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", "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 := 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 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
|
|
}
|
|
|
|
aiMap, ok := readVideoMetaAIForGoal(metaPath, "highlights")
|
|
if !ok || aiMap == nil {
|
|
return false
|
|
}
|
|
|
|
// Neuer sauberer Marker:
|
|
// Analyse wurde wirklich ausgeführt und abgeschlossen.
|
|
// Das gilt auch dann, wenn es keine Hits/Segmente gab.
|
|
if completed, ok := aiMap["completed"].(bool); ok && completed {
|
|
return true
|
|
}
|
|
|
|
// Treffer/Segmente sind ebenfalls ein sicherer Hinweis.
|
|
rawHits, hasHits := aiMap["hits"].([]any)
|
|
if hasHits && len(rawHits) > 0 {
|
|
return true
|
|
}
|
|
|
|
rawSegs, hasSegs := aiMap["segments"].([]any)
|
|
if hasSegs && len(rawSegs) > 0 {
|
|
return true
|
|
}
|
|
|
|
// Legacy-Fallback:
|
|
// Positive Rating-Werte zählen, aber stars alleine NICHT.
|
|
// stars=1 kann nur der Default sein.
|
|
rating, ok := aiMap["rating"].(map[string]any)
|
|
if !ok || rating == nil {
|
|
return false
|
|
}
|
|
|
|
if isPositiveJSONNumber(rating["segments"]) {
|
|
return true
|
|
}
|
|
if isPositiveJSONNumber(rating["score"]) {
|
|
return true
|
|
}
|
|
if isPositiveJSONNumber(rating["flaggedSeconds"]) {
|
|
return true
|
|
}
|
|
if isPositiveJSONNumber(rating["weightedFlaggedSeconds"]) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func jsonFieldExists(m map[string]any, key string) bool {
|
|
if m == nil {
|
|
return false
|
|
}
|
|
|
|
_, ok := m[key]
|
|
return ok
|
|
}
|
|
|
|
func jsonNumberPositive(v any) bool {
|
|
switch x := v.(type) {
|
|
case json.Number:
|
|
i, err := x.Int64()
|
|
return err == nil && i > 0
|
|
case float64:
|
|
return x > 0
|
|
case int64:
|
|
return x > 0
|
|
case int:
|
|
return x > 0
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isPositiveJSONNumber(v any) bool {
|
|
switch x := v.(type) {
|
|
case json.Number:
|
|
i, err := x.Int64()
|
|
return err == nil && i > 0
|
|
case float64:
|
|
return x > 0
|
|
case int64:
|
|
return x > 0
|
|
case int:
|
|
return x > 0
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func hasAIAnalysisForAllOutputGoals(outPath string, goals []string) bool {
|
|
return hasAIAnalysisForOutputGoal(outPath, "highlights")
|
|
}
|
|
|
|
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 = "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 {
|
|
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
|
|
|
|
if hasAIAnalysisForOutputGoal(videoPath, "highlights") {
|
|
out.AnalyzeReady = true
|
|
return out, nil
|
|
}
|
|
|
|
durationSec, _ := durationSecondsForAnalyze(ctx, videoPath)
|
|
if durationSec <= 0 {
|
|
return out, nil
|
|
}
|
|
|
|
hits, aerr := analyzeVideoFromFrames(ctx, videoPath)
|
|
if aerr != nil {
|
|
return out, nil
|
|
}
|
|
|
|
segments := buildAnalyzeSegmentsForGoal(hits, durationSec)
|
|
segments = prepareAIRatingSegments(segments)
|
|
|
|
rating := computeHighlightRatingForVideo(segments, durationSec, videoPath)
|
|
|
|
ai := &aiAnalysisMeta{
|
|
Goal: "highlights",
|
|
Mode: "video",
|
|
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, "highlights")
|
|
return out, nil
|
|
}
|