diff --git a/backend/analyze.go b/backend/analyze.go index 97cef87..288765b 100644 --- a/backend/analyze.go +++ b/backend/analyze.go @@ -613,13 +613,11 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit { for i := 1; i < len(cp); i++ { n := cp[i] - // Nur direkt aufeinanderfolgende Treffer mit gleichem Label zusammenfassen - const mergeGapSeconds = 1.0 - + // Direkt aufeinanderfolgende Treffer mit gleichem Label immer zusammenfassen. + // Sobald ein anderes Label dazwischen liegt, wird automatisch nicht gemergt. sameLabel := strings.EqualFold(cur.Label, n.Label) - touchesOrNear := n.Start <= cur.End+mergeGapSeconds - if sameLabel && touchesOrNear { + if sameLabel { if n.Start < cur.Start { cur.Start = n.Start } diff --git a/backend/assets_generate.go b/backend/assets_generate.go index fdc912f..1815882 100644 --- a/backend/assets_generate.go +++ b/backend/assets_generate.go @@ -932,7 +932,6 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU }); err != nil { if isFFmpegInputInvalidError(err) { sourceInputInvalid = true - fmt.Printf("⚠️ preview clips skipped (invalid/incomplete input): %s\n", videoPath) return } return diff --git a/backend/tasks_regenerate_assets.go b/backend/tasks_regenerate_assets.go index 1dc02df..5fa2e61 100644 --- a/backend/tasks_regenerate_assets.go +++ b/backend/tasks_regenerate_assets.go @@ -14,10 +14,35 @@ import ( "time" ) +const regenerateAssetsMaxParallel = 3 + type regenerateAssetsRequest struct { File string `json:"file"` } +type regenerateAssetsJob struct { + File string + VideoPath string + AssetID string + + Ctx context.Context + Cancel context.CancelFunc + + State string // queued | running | done | error | cancelled + Error string + + EnqueuedAt time.Time + StartedAt time.Time + EndedAt time.Time +} + +var ( + regenerateAssetsMu sync.Mutex + regenerateAssetsJobs = map[string]*regenerateAssetsJob{} // key = file + regenerateAssetsQueue []string // file names + regenerateAssetsWorkersStarted bool +) + func publishFinishedPostworkPhase( file string, assetID string, @@ -41,11 +66,274 @@ func publishFinishedPostworkPhase( } } -var ( - regenerateAssetsRunMu sync.Mutex - regenerateAssetsCancel context.CancelFunc - regenerateAssetsFile string -) +func ensureRegenerateAssetsWorkersStarted() { + regenerateAssetsMu.Lock() + defer regenerateAssetsMu.Unlock() + + if regenerateAssetsWorkersStarted { + return + } + + regenerateAssetsWorkersStarted = true + + for i := 0; i < regenerateAssetsMaxParallel; i++ { + go regenerateAssetsWorker() + } +} + +func enqueueRegenerateAssetsJob(job *regenerateAssetsJob) { + regenerateAssetsMu.Lock() + defer regenerateAssetsMu.Unlock() + + regenerateAssetsJobs[job.File] = job + regenerateAssetsQueue = append(regenerateAssetsQueue, job.File) +} + +func nextQueuedRegenerateAssetsJob() *regenerateAssetsJob { + regenerateAssetsMu.Lock() + defer regenerateAssetsMu.Unlock() + + for len(regenerateAssetsQueue) > 0 { + file := regenerateAssetsQueue[0] + regenerateAssetsQueue = regenerateAssetsQueue[1:] + + job := regenerateAssetsJobs[file] + if job == nil { + continue + } + if job.State != "queued" { + continue + } + + job.State = "running" + job.StartedAt = time.Now() + return job + } + + return nil +} + +func finishRegenerateAssetsJob(file string, state string, errMsg string) { + regenerateAssetsMu.Lock() + defer regenerateAssetsMu.Unlock() + + job := regenerateAssetsJobs[file] + if job == nil { + return + } + + job.State = state + job.Error = strings.TrimSpace(errMsg) + job.EndedAt = time.Now() +} + +func removeRegenerateAssetsJobLater(file string, after time.Duration) { + time.AfterFunc(after, func() { + regenerateAssetsMu.Lock() + defer regenerateAssetsMu.Unlock() + + delete(regenerateAssetsJobs, file) + }) +} + +func getRegenerateAssetsJobsSnapshot() []map[string]any { + regenerateAssetsMu.Lock() + defer regenerateAssetsMu.Unlock() + + out := make([]map[string]any, 0, len(regenerateAssetsJobs)) + for _, job := range regenerateAssetsJobs { + out = append(out, map[string]any{ + "file": job.File, + "assetId": job.AssetID, + "state": job.State, + "error": job.Error, + "enqueuedAt": job.EnqueuedAt.UnixMilli(), + "startedAt": func() int64 { + if job.StartedAt.IsZero() { + return 0 + } + return job.StartedAt.UnixMilli() + }(), + "endedAt": func() int64 { + if job.EndedAt.IsZero() { + return 0 + } + return job.EndedAt.UnixMilli() + }(), + }) + } + + return out +} + +func regenerateAssetsWorker() { + for { + job := nextQueuedRegenerateAssetsJob() + if job == nil { + time.Sleep(250 * time.Millisecond) + continue + } + + runRegenerateAssetsJob(job) + } +} + +func runRegenerateAssetsJob(job *regenerateAssetsJob) { + file := job.File + videoPath := job.VideoPath + id := job.AssetID + ctx := job.Ctx + + setRegenerateAssetsTaskRunning(file, file) + + cancelDeferredEnrichForFile(file) + + select { + case <-ctx.Done(): + finishRegenerateAssetsJob(file, "cancelled", "Abgebrochen") + setRegenerateAssetsTaskError(file, "Abgebrochen") + + publishFinishedPostworkPhase(file, id, "postwork", "meta", "missing", "") + publishFinishedPostworkPhase(file, id, "postwork", "teaser", "missing", "") + publishFinishedPostworkPhase(file, id, "postwork", "thumb", "missing", "") + publishFinishedPostworkPhase(file, id, "postwork", "sprites", "missing", "") + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "") + + removeRegenerateAssetsJobLater(file, 2*time.Second) + return + default: + } + + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "") + + publishFinishedPostworkPhase(file, id, "postwork", "meta", "running", "Meta") + publishFinishedPostworkPhase(file, id, "postwork", "teaser", "running", "Teaser") + publishFinishedPostworkPhase(file, id, "postwork", "thumb", "running", "Vorschaubild") + publishFinishedPostworkPhase(file, id, "postwork", "sprites", "running", "Sprites") + + if id != "" { + if assetDir, _, _, _, metaPath, err := assetPathsForID(id); err == nil { + _ = os.Remove(filepath.Join(assetDir, "preview.jpg")) + _ = os.Remove(filepath.Join(assetDir, "preview.mp4")) + _ = os.Remove(filepath.Join(assetDir, "preview-sprite.jpg")) + _ = os.Remove(metaPath) + } + } + + assetWatchDone := make(chan struct{}) + + go func(ctx context.Context, file string, id string) { + ticker := time.NewTicker(300 * time.Millisecond) + defer ticker.Stop() + + sentMetaDone := false + sentTeaserDone := false + sentThumbDone := false + sentSpritesDone := false + + checkAndEmit := func() { + truth := finishedPhaseTruthForID(id) + + if truth.MetaReady && !sentMetaDone { + sentMetaDone = true + publishFinishedPostworkPhase(file, id, "postwork", "meta", "done", "Meta") + } + if truth.TeaserReady && !sentTeaserDone { + sentTeaserDone = true + publishFinishedPostworkPhase(file, id, "postwork", "teaser", "done", "Teaser") + } + if truth.ThumbReady && !sentThumbDone { + sentThumbDone = true + publishFinishedPostworkPhase(file, id, "postwork", "thumb", "done", "Vorschaubild") + } + if truth.SpritesReady && !sentSpritesDone { + sentSpritesDone = true + publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites") + } + } + + for { + select { + case <-ctx.Done(): + return + case <-assetWatchDone: + checkAndEmit() + return + case <-ticker.C: + checkAndEmit() + } + } + }(ctx, file, id) + + _, errAssets := ensureAssetsForVideoWithProgressCtx(ctx, videoPath, "", nil) + close(assetWatchDone) + + if errors.Is(errAssets, context.Canceled) || ctx.Err() == context.Canceled { + finishRegenerateAssetsJob(file, "cancelled", "Abgebrochen") + setRegenerateAssetsTaskError(file, "Abgebrochen") + + publishFinishedPostworkPhase(file, id, "postwork", "meta", "missing", "") + publishFinishedPostworkPhase(file, id, "postwork", "teaser", "missing", "") + publishFinishedPostworkPhase(file, id, "postwork", "thumb", "missing", "") + publishFinishedPostworkPhase(file, id, "postwork", "sprites", "missing", "") + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "") + + removeRegenerateAssetsJobLater(file, 2*time.Second) + return + } + + if errAssets == nil { + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "KI-Analyse") + } + + errAI := ensureDeferredAssetsAndAI(ctx, videoPath, "") + + if errors.Is(errAI, context.Canceled) || ctx.Err() == context.Canceled { + finishRegenerateAssetsJob(file, "cancelled", "Abgebrochen") + setRegenerateAssetsTaskError(file, "Abgebrochen") + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "") + + removeRegenerateAssetsJobLater(file, 2*time.Second) + return + } + + if errAssets == nil && errAI == nil { + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "KI-Analyse") + } + + if errAssets != nil || errAI != nil { + errMsg := "" + if errAssets != nil { + errMsg = errAssets.Error() + } else { + errMsg = errAI.Error() + } + + finishRegenerateAssetsJob(file, "error", errMsg) + setRegenerateAssetsTaskError(file, errMsg) + + errEv := finishedPostworkEvent{ + Type: "finished_postwork", + File: file, + AssetID: id, + Queue: "enrich", + State: "error", + Phase: "assets", + Label: "Fehler", + TS: time.Now().UnixMilli(), + } + if b, err := json.Marshal(errEv); err == nil { + publishSSE("finishedPostwork", b) + } + + removeRegenerateAssetsJobLater(file, 5*time.Second) + return + } + + finishRegenerateAssetsJob(file, "done", "") + setRegenerateAssetsTaskDone(file, "Fertig") + removeRegenerateAssetsJobLater(file, 3*time.Second) +} func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost && r.Method != http.MethodDelete { @@ -54,42 +342,28 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) { } if r.Method == http.MethodDelete { - regenerateAssetsRunMu.Lock() - cancel := regenerateAssetsCancel - file := regenerateAssetsFile - regenerateAssetsCancel = nil - regenerateAssetsFile = "" - regenerateAssetsRunMu.Unlock() + var req regenerateAssetsRequest + _ = json.NewDecoder(r.Body).Decode(&req) - if cancel != nil { - cancel() + file := strings.TrimSpace(req.File) + if file == "" { + http.Error(w, "missing file", http.StatusBadRequest) + return } - if strings.TrimSpace(file) != "" { - setRegenerateAssetsTaskError(file, "Abgebrochen") + regenerateAssetsMu.Lock() + job := regenerateAssetsJobs[file] + regenerateAssetsMu.Unlock() - id := strings.TrimSpace(assetIDFromVideoPath(file)) - if id == "" { - id = stripHotPrefix(strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))) - } - - publishFinishedPostworkPhase(file, id, "postwork", "meta", "missing", "") - publishFinishedPostworkPhase(file, id, "postwork", "teaser", "missing", "") - publishFinishedPostworkPhase(file, id, "postwork", "thumb", "missing", "") - publishFinishedPostworkPhase(file, id, "postwork", "sprites", "missing", "") - publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "") - - time.AfterFunc(2*time.Second, func() { - clearRegenerateAssetsTaskState() - }) - } else { - clearRegenerateAssetsTaskState() + if job != nil && job.Cancel != nil { + job.Cancel() } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "ok": true, "cancelled": true, + "file": file, }) return } @@ -131,184 +405,51 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) { return } + ensureRegenerateAssetsWorkersStarted() + + regenerateAssetsMu.Lock() + if existing := regenerateAssetsJobs[file]; existing != nil { + regenerateAssetsMu.Unlock() + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "file": file, + "state": existing.State, + }) + return + } + regenerateAssetsMu.Unlock() + runCtx, runCancel := context.WithCancel(context.Background()) - regenerateAssetsRunMu.Lock() - if regenerateAssetsCancel != nil { - regenerateAssetsCancel() + id := assetIDFromVideoPath(videoPath) + if strings.TrimSpace(id) == "" { + id = stripHotPrefix(strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))) } - regenerateAssetsCancel = runCancel - regenerateAssetsFile = file - regenerateAssetsRunMu.Unlock() - setRegenerateAssetsTaskRunning(file, file) + job := ®enerateAssetsJob{ + File: file, + VideoPath: videoPath, + AssetID: id, + Ctx: runCtx, + Cancel: runCancel, + State: "queued", + EnqueuedAt: time.Now(), + } - go func(ctx context.Context, videoPath string, file string) { - defer func() { - regenerateAssetsRunMu.Lock() - if regenerateAssetsFile == file { - regenerateAssetsCancel = nil - regenerateAssetsFile = "" - } - regenerateAssetsRunMu.Unlock() - }() + enqueueRegenerateAssetsJob(job) - id := assetIDFromVideoPath(videoPath) - if strings.TrimSpace(id) == "" { - id = stripHotPrefix(strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))) - } - - cancelDeferredEnrichForFile(file) - - select { - case <-ctx.Done(): - setRegenerateAssetsTaskError(file, "Abgebrochen") - time.AfterFunc(2*time.Second, func() { - clearRegenerateAssetsTaskState() - }) - return - default: - } - - publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "") - - publishFinishedPostworkPhase(file, id, "postwork", "meta", "running", "Meta") - publishFinishedPostworkPhase(file, id, "postwork", "teaser", "running", "Teaser") - publishFinishedPostworkPhase(file, id, "postwork", "thumb", "running", "Vorschaubild") - publishFinishedPostworkPhase(file, id, "postwork", "sprites", "running", "Sprites") - - if id != "" { - if assetDir, _, _, _, metaPath, err := assetPathsForID(id); err == nil { - _ = os.Remove(filepath.Join(assetDir, "preview.jpg")) - _ = os.Remove(filepath.Join(assetDir, "preview.mp4")) - _ = os.Remove(filepath.Join(assetDir, "preview-sprite.jpg")) - _ = os.Remove(metaPath) - } - } - - assetWatchDone := make(chan struct{}) - - go func(ctx context.Context, file string, id string) { - ticker := time.NewTicker(300 * time.Millisecond) - defer ticker.Stop() - - sentMetaDone := false - sentTeaserDone := false - sentThumbDone := false - sentSpritesDone := false - - checkAndEmit := func() { - truth := finishedPhaseTruthForID(id) - - if truth.MetaReady && !sentMetaDone { - sentMetaDone = true - publishFinishedPostworkPhase(file, id, "postwork", "meta", "done", "Meta") - } - if truth.TeaserReady && !sentTeaserDone { - sentTeaserDone = true - publishFinishedPostworkPhase(file, id, "postwork", "teaser", "done", "Teaser") - } - if truth.ThumbReady && !sentThumbDone { - sentThumbDone = true - publishFinishedPostworkPhase(file, id, "postwork", "thumb", "done", "Vorschaubild") - } - if truth.SpritesReady && !sentSpritesDone { - sentSpritesDone = true - publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites") - } - } - - for { - select { - case <-ctx.Done(): - return - case <-assetWatchDone: - checkAndEmit() - return - case <-ticker.C: - checkAndEmit() - } - } - }(ctx, file, id) - - _, errAssets := ensureAssetsForVideoWithProgressCtx(ctx, videoPath, "", nil) - close(assetWatchDone) - - if errors.Is(errAssets, context.Canceled) || ctx.Err() == context.Canceled { - setRegenerateAssetsTaskError(file, "Abgebrochen") - - publishFinishedPostworkPhase(file, id, "postwork", "meta", "missing", "") - publishFinishedPostworkPhase(file, id, "postwork", "teaser", "missing", "") - publishFinishedPostworkPhase(file, id, "postwork", "thumb", "missing", "") - publishFinishedPostworkPhase(file, id, "postwork", "sprites", "missing", "") - publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "") - - time.AfterFunc(2*time.Second, func() { - clearRegenerateAssetsTaskState() - }) - return - } - - if errAssets == nil { - publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "KI-Analyse") - } - - errAI := ensureDeferredAssetsAndAI(ctx, videoPath, "") - - if errors.Is(errAI, context.Canceled) || ctx.Err() == context.Canceled { - setRegenerateAssetsTaskError(file, "Abgebrochen") - publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "") - - time.AfterFunc(2*time.Second, func() { - clearRegenerateAssetsTaskState() - }) - return - } - - if errAssets == nil && errAI == nil { - publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "KI-Analyse") - } - - if errAssets != nil || errAI != nil { - errMsg := "" - if errAssets != nil { - errMsg = errAssets.Error() - } else { - errMsg = errAI.Error() - } - - setRegenerateAssetsTaskError(file, errMsg) - - errEv := finishedPostworkEvent{ - Type: "finished_postwork", - File: file, - AssetID: id, - Queue: "enrich", - State: "error", - Phase: "assets", - Label: "Fehler", - TS: time.Now().UnixMilli(), - } - if b, err := json.Marshal(errEv); err == nil { - publishSSE("finishedPostwork", b) - } - - time.AfterFunc(5*time.Second, func() { - clearRegenerateAssetsTaskState() - }) - return - } - - setRegenerateAssetsTaskDone(file, "Fertig") - - time.AfterFunc(3*time.Second, func() { - clearRegenerateAssetsTaskState() - }) - }(runCtx, videoPath, file) + publishFinishedPostworkPhase(file, id, "postwork", "meta", "queued", "Meta") + publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser") + publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild") + publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites") + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse") w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ - "ok": true, - "file": file, + "ok": true, + "file": file, + "state": "queued", }) } diff --git a/backend/tasks_status.go b/backend/tasks_status.go index 1223c81..633026e 100644 --- a/backend/tasks_status.go +++ b/backend/tasks_status.go @@ -28,10 +28,11 @@ type RegenerateAssetsTaskState struct { } type TasksStatusResponse struct { - GenerateAssets AssetsTaskState `json:"generateAssets"` - RegenerateAssets RegenerateAssetsTaskState `json:"regenerateAssets"` - Cleanup CleanupTaskState `json:"cleanup"` - AnyRunning bool `json:"anyRunning"` + GenerateAssets AssetsTaskState `json:"generateAssets"` + RegenerateAssets RegenerateAssetsTaskState `json:"regenerateAssets"` + RegenerateAssetsJobs []map[string]any `json:"regenerateAssetsJobs"` + Cleanup CleanupTaskState `json:"cleanup"` + AnyRunning bool `json:"anyRunning"` } var ( @@ -47,6 +48,17 @@ func cleanupNowISO() *string { return &s } +func anyToString(v any) string { + switch x := v.(type) { + case string: + return x + case []byte: + return string(x) + default: + return "" + } +} + func snapshotRegenerateAssetsTaskState() RegenerateAssetsTaskState { regenerateAssetsTaskMu.Lock() st := regenerateAssetsTaskState @@ -175,13 +187,26 @@ func tasksStatusHandler(w http.ResponseWriter, r *http.Request) { assets := getGenerateAssetsTaskStatus() regenerate := snapshotRegenerateAssetsTaskState() + regenerateJobs := getRegenerateAssetsJobsSnapshot() cleanup := snapshotCleanupTaskState() + anyRegenerateRunning := regenerate.Running + if !anyRegenerateRunning { + for _, job := range regenerateJobs { + state := strings.TrimSpace(strings.ToLower(anyToString(job["state"]))) + if state == "queued" || state == "running" { + anyRegenerateRunning = true + break + } + } + } + resp := TasksStatusResponse{ - GenerateAssets: assets, - RegenerateAssets: regenerate, - Cleanup: cleanup, - AnyRunning: assets.Running || regenerate.Running || cleanup.Running, + GenerateAssets: assets, + RegenerateAssets: regenerate, + RegenerateAssetsJobs: regenerateJobs, + Cleanup: cleanup, + AnyRunning: assets.Running || anyRegenerateRunning || cleanup.Running, } w.Header().Set("Content-Type", "application/json") diff --git a/frontend/src/components/ui/FinishedDownloads.tsx b/frontend/src/components/ui/FinishedDownloads.tsx index 73b1ddc..33a8110 100644 --- a/frontend/src/components/ui/FinishedDownloads.tsx +++ b/frontend/src/components/ui/FinishedDownloads.tsx @@ -1477,89 +1477,90 @@ export default function FinishedDownloads({ const data = await res.json().catch(() => null) if (!alive || !data) return - const regen = data?.regenerateAssets - const file = String(regen?.file ?? '').trim() + const jobs = Array.isArray(data?.regenerateAssetsJobs) + ? data.regenerateAssetsJobs + : [] - if (!file) return + if (jobs.length > 0) { + setPostworkByFile((prev) => { + const next = { ...prev } - if (regen?.running) { - setPostworkByFile((prev) => ({ - ...prev, - [file]: [ - { - file, - assetId: fileStemFromOutput(file), - queue: 'enrich', - state: 'running', - phase: 'assets', - label: 'Vorschaubild', - ts: Date.now(), - }, - ], - })) - return - } + for (const item of jobs) { + const file = String(item?.file ?? '').trim() + if (!file) continue - if (regen?.error) { - setPostworkByFile((prev) => ({ - ...prev, - [file]: [ - { - file, - assetId: fileStemFromOutput(file), - queue: 'enrich', - state: 'error', - phase: 'assets', - label: 'Fehler', - ts: Date.now(), - }, - ], - })) - return - } + const rawState = String(item?.state ?? '').trim().toLowerCase() + const assetId = + String(item?.assetId ?? '').trim() || fileStemFromOutput(file) - const bulk = data?.generateAssets - const bulkFile = String(bulk?.currentFile ?? '').trim() - const bulkQueue = - String(bulk?.currentQueue ?? '').trim().toLowerCase() === 'enrich' - ? 'enrich' - : 'postwork' - const bulkPhase = String(bulk?.currentPhase ?? '').trim().toLowerCase() - const bulkLabel = String(bulk?.currentLabel ?? '').trim() + const state: FinishedPostworkState = + rawState === 'queued' || + rawState === 'running' || + rawState === 'done' || + rawState === 'error' || + rawState === 'missing' + ? rawState + : rawState === 'cancelled' + ? 'missing' + : 'missing' - if (bulk?.running && bulkFile && bulkPhase) { - setPostworkByFile((prev) => ({ - ...prev, - [bulkFile]: [ - { - file: bulkFile, - assetId: fileStemFromOutput(bulkFile), - queue: bulkQueue, - state: 'running', - phase: bulkPhase, - label: bulkLabel || undefined, - ts: Date.now(), - }, - ], - })) - } + // Nur aktive / noch relevante Jobs beim Initial-Load wiederherstellen. + // "done" kommt danach ohnehin sauber über Meta/SSE rein. + if (state !== 'queued' && state !== 'running' && state !== 'error') { + continue + } - // optional: wenn du "done" kurz nach Reload noch sichtbar haben willst - if (regen?.text) { - setPostworkByFile((prev) => ({ - ...prev, - [file]: [ - { - file, - assetId: fileStemFromOutput(file), - queue: 'enrich', - state: 'done', - phase: 'assets', - label: 'Fertig', - ts: Date.now(), - }, - ], - })) + next[file] = [ + { + file, + assetId, + queue: 'postwork', + state, + phase: 'meta', + label: 'Meta', + ts: Date.now(), + }, + { + file, + assetId, + queue: 'postwork', + state, + phase: 'teaser', + label: 'Teaser', + ts: Date.now(), + }, + { + file, + assetId, + queue: 'postwork', + state, + phase: 'thumb', + label: 'Vorschaubild', + ts: Date.now(), + }, + { + file, + assetId, + queue: 'postwork', + state, + phase: 'sprites', + label: 'Sprites', + ts: Date.now(), + }, + { + file, + assetId, + queue: 'enrich', + state, + phase: 'analyze', + label: 'KI-Analyse', + ts: Date.now(), + }, + ] + } + + return next + }) } } catch { // ignore @@ -2820,13 +2821,49 @@ export default function FinishedDownloads({ setPostworkByFile((prev) => ({ ...prev, [cleanFile]: [ + { + file: cleanFile, + assetId: fileStemFromOutput(cleanFile), + queue: 'postwork', + state: 'queued', + phase: 'meta', + label: 'Meta', + ts: Date.now(), + }, + { + file: cleanFile, + assetId: fileStemFromOutput(cleanFile), + queue: 'postwork', + state: 'queued', + phase: 'teaser', + label: 'Teaser', + ts: Date.now(), + }, + { + file: cleanFile, + assetId: fileStemFromOutput(cleanFile), + queue: 'postwork', + state: 'queued', + phase: 'thumb', + label: 'Vorschaubild', + ts: Date.now(), + }, + { + file: cleanFile, + assetId: fileStemFromOutput(cleanFile), + queue: 'postwork', + state: 'queued', + phase: 'sprites', + label: 'Sprites', + ts: Date.now(), + }, { file: cleanFile, assetId: fileStemFromOutput(cleanFile), queue: 'enrich', - state: 'running', - phase: 'assets', - label: 'Wird verarbeitet…', + state: 'queued', + phase: 'analyze', + label: 'KI-Analyse', ts: Date.now(), }, ], diff --git a/frontend/src/components/ui/FinishedVideoPreview.tsx b/frontend/src/components/ui/FinishedVideoPreview.tsx index c0c8259..8090c3a 100644 --- a/frontend/src/components/ui/FinishedVideoPreview.tsx +++ b/frontend/src/components/ui/FinishedVideoPreview.tsx @@ -151,47 +151,10 @@ export default function FinishedVideoPreview({ return m }, [job]) - // ✅ falls job.meta keine previewClips enthält: meta.json nachladen - const [fetchedMeta, setFetchedMeta] = useState(null) - - // ✅ verhindert mehrfachen fallback-fetch pro Datei in derselben Komponenteninstanz - const fetchedMetaFilesRef = useRef>(new Set()) - // ✅ merge statt "meta ?? fetchedMeta" // job.meta bleibt Basis, fetchedMeta ergänzt fehlende Felder (z.B. previewClips) - const metaForPreview = useMemo(() => { - if (!meta && !fetchedMeta) return null - if (!meta) return fetchedMeta - if (!fetchedMeta) return meta - return { ...meta, ...fetchedMeta } - }, [meta, fetchedMeta]) - - // ✅ stabiler Guard für den fallback-fetch (berücksichtigt auch bereits geholtes fetchedMeta) - const hasPreviewClipsInAnyMeta = useMemo(() => { - let pcs: any = (metaForPreview as any)?.previewClips - - if (typeof pcs === 'string') { - const s = pcs.trim() - if (s.length > 0) { - try { - const parsed = JSON.parse(s) - if (Array.isArray(parsed) && parsed.length > 0) return true - // falls string vorhanden aber nicht parsebar, behandeln wir ihn trotzdem als "vorhanden" - return true - } catch { - return true - } - } - } - - if (Array.isArray(pcs) && pcs.length > 0) return true - - const nested = (metaForPreview as any)?.preview?.clips - if (Array.isArray(nested) && nested.length > 0) return true - - return false - }, [metaForPreview]) - + const metaForPreview = meta + const [progressMountTick, setProgressMountTick] = useState(0) // previewClips mapping: preview.mp4 ist Concatenation von Segmenten @@ -587,7 +550,6 @@ export default function FinishedVideoPreview({ useEffect(() => { setTeaserReady(false) setTeaserOk(true) - setFetchedMeta(null) // ✅ neue Datei/Asset -> alten fallback-meta cache in state verwerfen // ✅ neue Datei/Asset -> Callback-Dedupe zurücksetzen emittedDurationRef.current = '' @@ -783,63 +745,6 @@ export default function FinishedVideoPreview({ if (did) setMetaLoaded(true) }, [file, job, onDuration, onResolution, hasDuration, hasResolution, effectiveDurationSec, effectiveW, effectiveH]) - - // ✅ meta fallback fetch: nur wenn previewClips fehlen (für teaser-sprünge) - // und pro Datei in dieser Komponenteninstanz nur 1x - useEffect(() => { - if (!previewId) return - if (!file) return - if (!animated || animatedMode !== 'teaser') return - if (!shouldPreloadAnimatedAssets) return - - // ✅ wenn wir schon previewClips aus job.meta ODER fetchedMeta haben -> kein Request - if (hasPreviewClipsInAnyMeta) return - - // ✅ gleicher file-Request in dieser Instanz schon gemacht -> nicht nochmal feuern - if (fetchedMetaFilesRef.current.has(file)) return - - let aborted = false - const ctrl = new AbortController() - - const tryFetch = async (url: string) => { - try { - const res = await fetch(url, { - signal: ctrl.signal, - cache: 'no-store', - credentials: 'include', - }) - if (!res.ok) return null - return await res.json() - } catch { - return null - } - } - - ;(async () => { - const byFile = await tryFetch(`/api/record/done/meta?file=${encodeURIComponent(file)}`) - if (aborted) return - - // ✅ als "versucht" markieren (auch wenn null), damit nearView/inView/hover nicht spammt - fetchedMetaFilesRef.current.add(file) - - if (byFile) { - setFetchedMeta(byFile) - } - })() - - return () => { - aborted = true - ctrl.abort() - } - }, [ - previewId, - file, - animated, - animatedMode, - shouldPreloadAnimatedAssets, - hasPreviewClipsInAnyMeta, - ]) - // ✅ Fallback: nur wenn wir wirklich noch nichts haben, über loadedmetadata nachladen const handleLoadedMetadata = (e: SyntheticEvent) => { setMetaLoaded(true) diff --git a/frontend/src/components/ui/VideoSplitModal.tsx b/frontend/src/components/ui/VideoSplitModal.tsx index 29fc787..7a1355d 100644 --- a/frontend/src/components/ui/VideoSplitModal.tsx +++ b/frontend/src/components/ui/VideoSplitModal.tsx @@ -942,10 +942,7 @@ export default function VideoSplitModal({ const clipMap = extractPreviewClipMap(data) const metaDuration = extractMetaDuration(data) - const resolvedDuration = - metaDuration > 0 - ? metaDuration - : duration + const resolvedDuration = metaDuration > 0 ? metaDuration : 0 const shouldMapPreviewAi = Boolean(shouldMapAiPreviewTimes(ai, clipMap, resolvedDuration) && clipMap) @@ -997,7 +994,7 @@ export default function VideoSplitModal({ return () => { cancelled = true } - }, [open, job?.id, job?.output, duration]) + }, [open, job?.id, job?.output]) const effectiveDuration = mediaDuration > 0 ? mediaDuration : duration