diff --git a/backend/analyze.go b/backend/analyze.go index 7ea4985..97cef87 100644 --- a/backend/analyze.go +++ b/backend/analyze.go @@ -498,8 +498,8 @@ func analyzeVideoFromFrames(ctx context.Context, outPath, goal string) ([]analyz Time: t, Label: bestLabel, Score: bestScore, - Start: math.Max(0, t-4), - End: t + 4, + Start: t, + End: t, }) } @@ -545,16 +545,12 @@ func analyzeSpriteCandidatesWithAI( continue } - span := inferredSpanSeconds(ps.StepSeconds, 8) - start := math.Max(0, c.Time-(span/2)) - end := c.Time + (span / 2) - hits = append(hits, analyzeHit{ Time: c.Time, Label: bestLabel, Score: bestScore, - Start: start, - End: end, + Start: c.Time, + End: c.Time, }) } @@ -579,14 +575,14 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit { start := h.Start end := h.End - if start <= 0 && end <= 0 { + if start < 0 && end < 0 { start = h.Time end = h.Time } else { - if start <= 0 { + if start < 0 { start = h.Time } - if end <= 0 { + if end < 0 { end = h.Time } } @@ -660,18 +656,17 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme start := hit.Start end := hit.End - if start <= 0 && end <= 0 { + if start < 0 && end < 0 { start = hit.Time end = hit.Time } else { - if start <= 0 { + if start < 0 { start = hit.Time } - if end <= 0 { + if end < 0 { end = hit.Time } } - if start > end { start, end = end, start } diff --git a/backend/assets_generate.go b/backend/assets_generate.go index c56dbe1..1202e6d 100644 --- a/backend/assets_generate.go +++ b/backend/assets_generate.go @@ -3,7 +3,6 @@ package main import ( "context" - "encoding/json" "errors" "fmt" "math" @@ -324,87 +323,99 @@ func ensurePrimaryAssetsForVideoWithProgressCtx( return out, nil } -func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string) error { +func ensureMetaForVideoCtx( + ctx context.Context, + videoPath string, + sourceURL string, +) (bool, error) { videoPath = strings.TrimSpace(videoPath) if videoPath == "" { - return nil + return false, nil } fi, err := os.Stat(videoPath) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { - return nil + return false, nil } id := assetIDFromVideoPath(videoPath) if id == "" { - return nil + return false, nil } - _, _, previewPath, spritePath, metaPath, perr := assetPathsForID(id) + _, _, _, _, metaPath, perr := assetPathsForID(id) if perr != nil { - return perr + return false, perr + } + + meta, err := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) + if err != nil { + return false, err + } + + return meta.ok, 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) - // Preview erzeugen, wenn noch nicht vorhanden - previewExists := false - if pfi, err := os.Stat(previewPath); err == nil && pfi != nil && !pfi.IsDir() && pfi.Size() > 0 { - previewExists = true + 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 !previewExists { - const ( - previewClipLenSec = 0.75 - previewMaxClips = 12 - ) - - genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) - defer cancel() - - if err := genSem.Acquire(genCtx); err == nil { - func() { - defer genSem.Release() - - if err := generateTeaserClipsMP4WithProgress( - genCtx, - videoPath, - previewPath, - previewClipLenSec, - previewMaxClips, - nil, - ); err != nil { - if isFFmpegInputInvalidError(err) { - fmt.Printf("⚠️ preview clips skipped (invalid/incomplete input): %s\n", videoPath) - return - } - fmt.Printf("⚠️ deferred preview failed for %s: %v\n", videoPath, err) - return - } - }() + if err := generateTeaserClipsMP4WithProgress( + genCtx, + videoPath, + previewPath, + previewClipLenSec, + previewMaxClips, + nil, + ); err != nil { + if isFFmpegInputInvalidError(err) { + return false, nil } + return false, err } - // vorhandene previewClips / spriteMeta aus bestehender meta.json übernehmen var computedPreviewClips []previewClip - var spriteMeta *previewSpriteMeta - - if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil { - if len(oldMeta.PreviewClips) > 0 { - computedPreviewClips = oldMeta.PreviewClips - } - if oldMeta.PreviewSprite != nil { - spriteMeta = oldMeta.PreviewSprite - } - } - - // previewClips aus Dauer ableiten, falls noch nicht vorhanden - if len(computedPreviewClips) == 0 && meta.durSec > 0 { - const ( - previewClipLenSec = 0.75 - previewMaxClips = 12 - ) - + if meta.durSec > 0 { opts := TeaserPreviewOptions{ Segments: previewMaxClips, SegmentDuration: previewClipLenSec, @@ -422,69 +433,11 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string) computedPreviewClips = clips } - // Sprite erzeugen, wenn noch nicht vorhanden - spriteExists := false - if sfi, err := os.Stat(spritePath); err == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 { - spriteExists = true + var spriteMeta *previewSpriteMeta + if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.Preview.Sprite != nil { + spriteMeta = oldMeta.Preview.Sprite } - if !spriteExists && meta.durSec > 0 { - genCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) - defer cancel() - - if err := genSem.Acquire(genCtx); err == nil { - func() { - 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 { - fmt.Printf("⚠️ preview sprite failed for %s: %v\n", videoPath) - return - } - - spriteMeta = &previewSpriteMeta{ - Path: fmt.Sprintf("/api/preview-sprite/%s", id), - Count: count, - Cols: cols, - Rows: rows, - StepSeconds: stepSec, - CellWidth: cellW, - CellHeight: cellH, - } - }() - } - } - - // Falls Sprite existiert, aber spriteMeta noch fehlt - if spriteMeta == nil { - if sfi, err := os.Stat(spritePath); err == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 && 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, - } - } - } - - // meta.json mit previewClips + sprite ergänzen if meta.durSec > 0 { _ = writeVideoMetaWithPreviewClipsAndSprite( metaPath, @@ -499,33 +452,198 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string) ) } - // AI erst NACH Sprite - if !hasAIResultsForOutput(videoPath) { - if spriteMeta != nil { - actx, cancel := context.WithTimeout(ctx, 45*time.Second) - defer cancel() + return true, nil +} - durationSec, _ := durationSecondsForAnalyze(actx, videoPath) - hits, aerr := analyzeVideoFromSprite(actx, videoPath, "nsfw") - if aerr != nil { - fmt.Println("⚠️ deferred analyze:", aerr) - return nil - } +func ensureSpritesForVideoCtx( + ctx context.Context, + videoPath string, + sourceURL string, +) (bool, error) { + videoPath = strings.TrimSpace(videoPath) + if videoPath == "" { + return false, nil + } - segments := buildSegmentsFromAnalyzeHits(hits, durationSec) + fi, err := os.Stat(videoPath) + if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { + return false, nil + } - ai := &aiAnalysisMeta{ - Goal: "nsfw", - Mode: "sprite", - Hits: hits, - Segments: segments, - AnalyzedAtUnix: time.Now().Unix(), - } + id := assetIDFromVideoPath(videoPath) + if id == "" { + return false, nil + } - if werr := writeVideoAIForFile(actx, videoPath, sourceURL, ai); werr != nil { - fmt.Println("⚠️ writeVideoAIForFile:", werr) - } + _, _, _, 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, 2*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, 45*time.Second) + defer cancel() + + durationSec, _ := durationSecondsForAnalyze(actx, videoPath) + hits, aerr := analyzeVideoFromSprite(actx, videoPath, goal) + if aerr != nil { + return false, nil + } + + segments := buildSegmentsFromAnalyzeHits(hits, durationSec) + + ai := &aiAnalysisMeta{ + Goal: goal, + Mode: "sprite", + Hits: hits, + Segments: segments, + 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 + } + + if _, err := ensureTeaserForVideoCtx(ctx, videoPath, sourceURL); err != nil { + return fmt.Errorf("deferred teaser failed for %s: %w", videoPath, err) + } + + if _, err := ensureSpritesForVideoCtx(ctx, videoPath, sourceURL); err != nil { + return fmt.Errorf("deferred sprite failed for %s: %w", videoPath, err) + } + + truth := finishedPhaseTruthForID(id) + if !truth.SpritesReady { + return fmt.Errorf("preview-sprite.jpg fehlt nach deferred generation: %s", id) + } + + if _, err := ensureAnalyzeForVideoCtx(ctx, videoPath, sourceURL, "nsfw"); err != nil { + return fmt.Errorf("deferred analyze failed for %s: %w", videoPath, err) } return nil @@ -700,7 +818,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU // aber NICHT sofort returnen, damit meta.json // (previewClips / previewSprite) trotzdem sauber geschrieben wird. metaHasSprite := false - if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.PreviewSprite != nil { + if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.Preview.Sprite != nil { metaHasSprite = true } @@ -905,7 +1023,6 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU return } - fmt.Printf("⚠️ preview sprite failed for %s: %v\n", videoPath) return } @@ -918,16 +1035,16 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU // Falls wir in diesem Lauf keine neuen Clips berechnet haben: // alte aus gültigem meta.json übernehmen (best effort) if len(computedPreviewClips) == 0 { - if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && len(oldMeta.PreviewClips) > 0 { - computedPreviewClips = oldMeta.PreviewClips + 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.PreviewSprite != nil { - spriteMeta = oldMeta.PreviewSprite + if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.Preview.Sprite != nil { + spriteMeta = oldMeta.Preview.Sprite } } @@ -964,19 +1081,7 @@ func hasAIResultsForOutput(outPath string) bool { return false } - b, err := os.ReadFile(metaPath) - if err != nil || len(b) == 0 { - return false - } - - var m map[string]any - dec := json.NewDecoder(strings.NewReader(string(b))) - dec.UseNumber() - if err := dec.Decode(&m); err != nil { - return false - } - - aiMap, ok := m["ai"].(map[string]any) + aiMap, ok := readVideoMetaAI(metaPath) if !ok || aiMap == nil { return false } diff --git a/backend/meta.go b/backend/meta.go index c89e657..f270c08 100644 --- a/backend/meta.go +++ b/backend/meta.go @@ -1,4 +1,5 @@ -// backend/meta.go +// backend\meta.go + package main import ( @@ -25,28 +26,78 @@ type previewSpriteMeta struct { Count int `json:"count"` // Anzahl Frames im Sprite Cols int `json:"cols"` // Spalten im Tile Rows int `json:"rows"` // Zeilen im Tile - StepSeconds float64 `json:"stepSeconds"` // Zeitabstand zwischen Frames (z.B. 5) + StepSeconds float64 `json:"stepSeconds"` // Zeitabstand zwischen Frames CellWidth int `json:"cellWidth,omitempty"` CellHeight int `json:"cellHeight,omitempty"` } +type videoFileMeta struct { + Size int64 `json:"size"` + ModUnix int64 `json:"modUnix"` + SourceURL string `json:"sourceUrl,omitempty"` +} + +type videoStreamMeta struct { + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + FPS float64 `json:"fps,omitempty"` + Resolution string `json:"resolution,omitempty"` +} + +type videoMediaMeta struct { + DurationSeconds float64 `json:"durationSeconds"` + Video videoStreamMeta `json:"video"` +} + +type previewThumbMeta struct { + Path string `json:"path,omitempty"` + Exists bool `json:"exists,omitempty"` +} + +type previewMeta struct { + Clips []previewClip `json:"clips,omitempty"` + Thumb *previewThumbMeta `json:"thumb,omitempty"` + Sprite *previewSpriteMeta `json:"sprite,omitempty"` +} + +type analysisMeta struct { + AI *aiAnalysisMeta `json:"ai,omitempty"` +} + +type postworkHistoryEntry struct { + Queue string `json:"queue"` + Phase string `json:"phase"` + State string `json:"state"` + Label string `json:"label,omitempty"` + Position int `json:"position,omitempty"` + Waiting int `json:"waiting,omitempty"` + Running int `json:"running,omitempty"` + MaxParallel int `json:"maxParallel,omitempty"` + TS int64 `json:"ts"` +} + +type postworkCompletedMeta struct { + Meta bool `json:"meta,omitempty"` + Teaser bool `json:"teaser,omitempty"` + Thumb bool `json:"thumb,omitempty"` + Sprites bool `json:"sprites,omitempty"` + Analyze bool `json:"analyze,omitempty"` +} + +type postworkMeta struct { + Completed *postworkCompletedMeta `json:"completed,omitempty"` + History []postworkHistoryEntry `json:"history,omitempty"` +} + type videoMeta struct { - Version int `json:"version"` - DurationSeconds float64 `json:"durationSeconds"` - FileSize int64 `json:"fileSize"` - FileModUnix int64 `json:"fileModUnix"` - - VideoWidth int `json:"videoWidth,omitempty"` - VideoHeight int `json:"videoHeight,omitempty"` - FPS float64 `json:"fps,omitempty"` - Resolution string `json:"resolution,omitempty"` - - SourceURL string `json:"sourceUrl,omitempty"` - PreviewClips []previewClip `json:"previewClips,omitempty"` - PreviewSprite *previewSpriteMeta `json:"previewSprite,omitempty"` - AI *aiAnalysisMeta `json:"ai,omitempty"` - + Version int `json:"version"` UpdatedAtUnix int64 `json:"updatedAtUnix"` + + File videoFileMeta `json:"file"` + Media videoMediaMeta `json:"media"` + Preview previewMeta `json:"preview,omitempty"` + Analysis analysisMeta `json:"analysis,omitempty"` + Postwork postworkMeta `json:"postwork,omitempty"` } type aiSegmentMeta struct { @@ -66,54 +117,20 @@ type aiAnalysisMeta struct { AnalyzedAtUnix int64 `json:"analyzedAtUnix,omitempty"` } -// liest Meta (v2 ODER altes v1) und validiert gegen fi (Size/ModTime) func readVideoMeta(metaPath string, fi os.FileInfo) (dur float64, w int, h int, fps float64, ok bool) { - b, err := os.ReadFile(metaPath) - if err != nil || len(b) == 0 { + m, ok := readVideoMetaIfValid(metaPath, fi) + if !ok || m == nil { return 0, 0, 0, 0, false } - - // 1) Neues Format (oder v1 mit gleichen Feldern) - var m videoMeta - if err := json.Unmarshal(b, &m); err == nil && (m.Version == 2 || m.Version == 1) { - if m.FileSize != fi.Size() || m.FileModUnix != fi.ModTime().Unix() { - return 0, 0, 0, 0, false - } - if m.DurationSeconds <= 0 { - return 0, 0, 0, 0, false - } - return m.DurationSeconds, m.VideoWidth, m.VideoHeight, m.FPS, true - } - - // 2) Fallback: ganz altes v1-Format (nur Duration etc.) - var m1 struct { - Version int `json:"version"` - DurationSeconds float64 `json:"durationSeconds"` - FileSize int64 `json:"fileSize"` - FileModUnix int64 `json:"fileModUnix"` - UpdatedAtUnix int64 `json:"updatedAtUnix"` - } - if err := json.Unmarshal(b, &m1); err != nil { - return 0, 0, 0, 0, false - } - if m1.Version != 1 { - return 0, 0, 0, 0, false - } - if m1.FileSize != fi.Size() || m1.FileModUnix != fi.ModTime().Unix() { - return 0, 0, 0, 0, false - } - if m1.DurationSeconds <= 0 { - return 0, 0, 0, 0, false - } - return m1.DurationSeconds, 0, 0, 0, true + return m.Media.DurationSeconds, m.Media.Video.Width, m.Media.Video.Height, m.Media.Video.FPS, true } func readVideoMetaDuration(metaPath string, fi os.FileInfo) (float64, bool) { m, ok := readVideoMetaIfValid(metaPath, fi) - if !ok || m == nil || m.DurationSeconds <= 0 { + if !ok || m == nil || m.Media.DurationSeconds <= 0 { return 0, false } - return m.DurationSeconds, true + return m.Media.DurationSeconds, true } func metaJSONPathForAssetID(assetID string) (string, error) { @@ -128,14 +145,12 @@ func metaJSONPathForAssetID(assetID string) (string, error) { } func ensureVideoMetaForFile(ctx context.Context, fullPath string, fi os.FileInfo, sourceURL string) (*videoMeta, bool) { - // assetID aus Dateiname stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath)) assetID := stripHotPrefix(strings.TrimSpace(stem)) if assetID == "" { return nil, false } - // sanitize wie bei deinen generated Ordnern var err error assetID, err = sanitizeID(assetID) if err != nil || assetID == "" { @@ -147,12 +162,10 @@ func ensureVideoMetaForFile(ctx context.Context, fullPath string, fi os.FileInfo return nil, false } - // 1) valid meta vorhanden? if m, ok := readVideoMetaIfValid(metaPath, fi); ok { return m, true } - // 2) sonst neu erzeugen (mit Concurrency-Limit) if ctx == nil { ctx = context.Background() } @@ -166,38 +179,26 @@ func ensureVideoMetaForFile(ctx context.Context, fullPath string, fi os.FileInfo defer durSem.Release() } - // Dauer dur, derr := durationSecondsCached(cctx, fullPath) if derr != nil || dur <= 0 { return nil, false } - // Video props w, h, fps, perr := probeVideoProps(cctx, fullPath) if perr != nil { - // width/height/fps dürfen 0 bleiben, duration ist aber trotzdem nützlich w, h, fps = 0, 0, 0 } - // meta dir anlegen _ = os.MkdirAll(filepath.Dir(metaPath), 0o755) - m := &videoMeta{ - Version: 2, - DurationSeconds: dur, - FileSize: fi.Size(), - FileModUnix: fi.ModTime().Unix(), - VideoWidth: w, - VideoHeight: h, - FPS: fps, - Resolution: formatResolution(w, h), - SourceURL: strings.TrimSpace(sourceURL), - UpdatedAtUnix: time.Now().Unix(), + if err := writeVideoMeta(metaPath, fi, dur, w, h, fps, sourceURL); err != nil { + return nil, false } - b, _ := json.MarshalIndent(m, "", " ") - b = append(b, '\n') - _ = atomicWriteFile(metaPath, b) // best effort + m, ok := readVideoMetaLoose(metaPath) + if !ok || m == nil { + return nil, false + } return m, true } @@ -211,45 +212,41 @@ func attachMetaToJobBestEffort(ctx context.Context, job *RecordJob, fullPath str return } - // Stat fi, err := os.Stat(fullPath) if err != nil || fi == nil || fi.IsDir() { return } - // Größe immer mitgeben (macht Sort/Anzeige einfacher) if job.SizeBytes <= 0 { job.SizeBytes = fi.Size() } - // Meta.json lesen/erzeugen (best effort) m, ok := ensureVideoMetaForFileBestEffort(ctx, fullPath, job.SourceURL) if !ok || m == nil { return } - // Optional: komplettes Meta mitsenden job.Meta = m - // Und zusätzlich die "Top-Level" Felder befüllen (für Frontend bequem) - if job.DurationSeconds <= 0 && m.DurationSeconds > 0 { - job.DurationSeconds = m.DurationSeconds + if strings.Contains(strings.TrimSpace(fullPath), "alice_kosmos_03_24_2026__03-48-34") { + b, _ := json.MarshalIndent(m, "", " ") + fmt.Println("ATTACH META SNAPSHOT:", string(b)) } - if job.VideoWidth <= 0 && m.VideoWidth > 0 { - job.VideoWidth = m.VideoWidth + + if job.DurationSeconds <= 0 && m.Media.DurationSeconds > 0 { + job.DurationSeconds = m.Media.DurationSeconds } - if job.VideoHeight <= 0 && m.VideoHeight > 0 { - job.VideoHeight = m.VideoHeight + if job.VideoWidth <= 0 && m.Media.Video.Width > 0 { + job.VideoWidth = m.Media.Video.Width } - if job.FPS <= 0 && m.FPS > 0 { - job.FPS = m.FPS + if job.VideoHeight <= 0 && m.Media.Video.Height > 0 { + job.VideoHeight = m.Media.Video.Height + } + if job.FPS <= 0 && m.Media.Video.FPS > 0 { + job.FPS = m.Media.Video.FPS } } -// ensureVideoMetaForFileBestEffort: -// - versucht zuerst echtes Generieren (ffprobe/ffmpeg) via ensureVideoMetaForFile -// - wenn das fehlschlägt, aber durationSecondsCacheOnly schon was weiß: -// schreibt eine Duration-only meta.json, damit wir künftig "aus meta.json" lesen können. func ensureVideoMetaForFileBestEffort(ctx context.Context, fullPath string, sourceURL string) (*videoMeta, bool) { fullPath = strings.TrimSpace(fullPath) if fullPath == "" { @@ -261,23 +258,21 @@ func ensureVideoMetaForFileBestEffort(ctx context.Context, fullPath string, sour return nil, false } - // 1) Normaler Weg: meta erzeugen/lesen (ffprobe/ffmpeg) if m, ok := ensureVideoMetaForFile(ctx, fullPath, fi, sourceURL); ok && m != nil { return m, true } - // 2) Fallback: wenn wir Duration schon im RAM-Cache haben -> meta.json (Duration-only) persistieren dur := durationSecondsCacheOnly(fullPath, fi) if dur <= 0 { return nil, false } stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath)) - assetID := stripHotPrefix(strings.TrimSpace(stem)) if assetID == "" { return nil, false } + assetID, err = sanitizeID(assetID) if err != nil || assetID == "" { return nil, false @@ -291,7 +286,6 @@ func ensureVideoMetaForFileBestEffort(ctx context.Context, fullPath string, sour _ = os.MkdirAll(filepath.Dir(metaPath), 0o755) _ = writeVideoMetaDuration(metaPath, fi, dur, sourceURL) - // nochmal lesen/validieren if m, ok := readVideoMetaIfValid(metaPath, fi); ok && m != nil { return m, true } @@ -309,63 +303,162 @@ func readVideoMetaIfValid(metaPath string, fi os.FileInfo) (*videoMeta, bool) { if err := json.Unmarshal(b, &m); err != nil { return nil, false } - if m.Version != 1 && m.Version != 2 { + if m.Version != 3 { return nil, false } - if m.FileSize != fi.Size() || m.FileModUnix != fi.ModTime().Unix() { + if m.File.Size != fi.Size() || m.File.ModUnix != fi.ModTime().Unix() { return nil, false } - if m.DurationSeconds <= 0 { + if m.Media.DurationSeconds <= 0 { return nil, false } return &m, true } +func readVideoMetaLoose(metaPath string) (*videoMeta, bool) { + b, err := os.ReadFile(metaPath) + if err != nil || len(b) == 0 { + return nil, false + } + + var m videoMeta + if err := json.Unmarshal(b, &m); err != nil { + return nil, false + } + + if m.Version == 0 { + m.Version = 3 + } + + return &m, true +} + +func mergePreviewSpriteMeta(existing, incoming *previewSpriteMeta) *previewSpriteMeta { + if existing == nil && incoming == nil { + return nil + } + if existing == nil { + cp := *incoming + return &cp + } + if incoming == nil { + cp := *existing + return &cp + } + + out := *incoming + + if strings.TrimSpace(out.Path) == "" { + out.Path = existing.Path + } + if out.Count <= 0 { + out.Count = existing.Count + } + if out.Cols <= 0 { + out.Cols = existing.Cols + } + if out.Rows <= 0 { + out.Rows = existing.Rows + } + if out.StepSeconds <= 0 { + out.StepSeconds = existing.StepSeconds + } + if out.CellWidth <= 0 { + out.CellWidth = existing.CellWidth + } + if out.CellHeight <= 0 { + out.CellHeight = existing.CellHeight + } + + return &out +} + +func hasAIAnalysisMeta(ai *aiAnalysisMeta) bool { + if ai == nil { + return false + } + if strings.TrimSpace(ai.Goal) != "" { + return true + } + if strings.TrimSpace(ai.Mode) != "" { + return true + } + if len(ai.Hits) > 0 { + return true + } + if len(ai.Segments) > 0 { + return true + } + if ai.AnalyzedAtUnix > 0 { + return true + } + return false +} + func readVideoMetaSourceURL(metaPath string, fi os.FileInfo) (string, bool) { m, ok := readVideoMetaIfValid(metaPath, fi) if !ok || m == nil { return "", false } - u := strings.TrimSpace(m.SourceURL) + u := strings.TrimSpace(m.File.SourceURL) if u == "" { return "", false } return u, true } -// Voll-Write (wenn du dur + props schon hast) func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64, w int, h int, fps float64, sourceURL string) error { if strings.TrimSpace(metaPath) == "" || dur <= 0 { return nil } + var existing *videoMeta - if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil { + if old, ok := readVideoMetaLoose(metaPath); ok && old != nil { existing = old } m := videoMeta{ - Version: 2, - DurationSeconds: dur, - FileSize: fi.Size(), - FileModUnix: fi.ModTime().Unix(), - VideoWidth: w, - VideoHeight: h, - FPS: fps, - Resolution: formatResolution(w, h), - SourceURL: strings.TrimSpace(sourceURL), - UpdatedAtUnix: time.Now().Unix(), - - PreviewClips: nil, - PreviewSprite: nil, - AI: nil, + Version: 3, + UpdatedAtUnix: time.Now().Unix(), + File: videoFileMeta{ + Size: fi.Size(), + ModUnix: fi.ModTime().Unix(), + SourceURL: strings.TrimSpace(sourceURL), + }, + Media: videoMediaMeta{ + DurationSeconds: dur, + Video: videoStreamMeta{ + Width: w, + Height: h, + FPS: fps, + Resolution: formatResolution(w, h), + }, + }, } if existing != nil { - m.PreviewClips = existing.PreviewClips - m.PreviewSprite = existing.PreviewSprite - m.AI = existing.AI + m.Preview = existing.Preview + m.Analysis = existing.Analysis + m.Postwork = existing.Postwork + + if m.File.SourceURL == "" { + m.File.SourceURL = existing.File.SourceURL + } + if m.Media.Video.Width <= 0 { + m.Media.Video.Width = existing.Media.Video.Width + } + if m.Media.Video.Height <= 0 { + m.Media.Video.Height = existing.Media.Video.Height + } + if m.Media.Video.FPS <= 0 { + m.Media.Video.FPS = existing.Media.Video.FPS + } + if m.Media.Video.Resolution == "" { + m.Media.Video.Resolution = existing.Media.Video.Resolution + } } + buf, err := json.Marshal(m) if err != nil { return err @@ -378,33 +471,63 @@ func writeVideoMetaWithPreviewClips(metaPath string, fi os.FileInfo, dur float64 if strings.TrimSpace(metaPath) == "" || dur <= 0 { return nil } + assetID := strings.TrimSpace(filepath.Base(filepath.Dir(metaPath))) + var existing *videoMeta - if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil { + if old, ok := readVideoMetaLoose(metaPath); ok && old != nil { existing = old } m := videoMeta{ - Version: 2, - DurationSeconds: dur, - FileSize: fi.Size(), - FileModUnix: fi.ModTime().Unix(), - VideoWidth: w, - VideoHeight: h, - FPS: fps, - Resolution: formatResolution(w, h), - SourceURL: strings.TrimSpace(sourceURL), - PreviewClips: clips, - UpdatedAtUnix: time.Now().Unix(), + Version: 3, + UpdatedAtUnix: time.Now().Unix(), + File: videoFileMeta{ + Size: fi.Size(), + ModUnix: fi.ModTime().Unix(), + SourceURL: strings.TrimSpace(sourceURL), + }, + Media: videoMediaMeta{ + DurationSeconds: dur, + Video: videoStreamMeta{ + Width: w, + Height: h, + FPS: fps, + Resolution: formatResolution(w, h), + }, + }, + Preview: previewMeta{ + Clips: clips, + Thumb: buildPreviewThumbMeta(assetID), + }, } if existing != nil { - m.AI = existing.AI + m.Analysis = existing.Analysis + m.Postwork = existing.Postwork + + if existing.Preview.Sprite != nil && m.Preview.Sprite == nil { + m.Preview.Sprite = existing.Preview.Sprite + } + if m.Preview.Thumb == nil && existing.Preview.Thumb != nil { + m.Preview.Thumb = existing.Preview.Thumb + } + if m.File.SourceURL == "" { + m.File.SourceURL = existing.File.SourceURL + } + if m.Media.Video.Width <= 0 { + m.Media.Video.Width = existing.Media.Video.Width + } + if m.Media.Video.Height <= 0 { + m.Media.Video.Height = existing.Media.Video.Height + } + if m.Media.Video.FPS <= 0 { + m.Media.Video.FPS = existing.Media.Video.FPS + } + if m.Media.Video.Resolution == "" { + m.Media.Video.Resolution = existing.Media.Video.Resolution + } } - // ✅ vorhandenes Sprite (inkl. stepSeconds) nicht wegwerfen - if existing != nil && existing.PreviewSprite != nil { - m.PreviewSprite = existing.PreviewSprite - } buf, err := json.Marshal(m) if err != nil { return err @@ -427,35 +550,67 @@ func writeVideoMetaWithPreviewClipsAndSprite( if strings.TrimSpace(metaPath) == "" || dur <= 0 { return nil } + assetID := strings.TrimSpace(filepath.Base(filepath.Dir(metaPath))) + + var existing *videoMeta + if old, ok := readVideoMetaLoose(metaPath); ok && old != nil { + existing = old + } m := videoMeta{ - Version: 2, - DurationSeconds: dur, - FileSize: fi.Size(), - FileModUnix: fi.ModTime().Unix(), - VideoWidth: w, - VideoHeight: h, - FPS: fps, - Resolution: formatResolution(w, h), - SourceURL: strings.TrimSpace(sourceURL), - PreviewClips: clips, - PreviewSprite: sprite, - UpdatedAtUnix: time.Now().Unix(), + Version: 3, + UpdatedAtUnix: time.Now().Unix(), + File: videoFileMeta{ + Size: fi.Size(), + ModUnix: fi.ModTime().Unix(), + SourceURL: strings.TrimSpace(sourceURL), + }, + Media: videoMediaMeta{ + DurationSeconds: dur, + Video: videoStreamMeta{ + Width: w, + Height: h, + FPS: fps, + Resolution: formatResolution(w, h), + }, + }, + Preview: previewMeta{ + Clips: clips, + Thumb: buildPreviewThumbMeta(assetID), + Sprite: sprite, + }, } - if sprite == nil { - if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil && old.PreviewSprite != nil { - m.PreviewSprite = old.PreviewSprite - } - } - if len(clips) == 0 { - if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil && len(old.PreviewClips) > 0 { - m.PreviewClips = old.PreviewClips - } - } + if existing != nil { + m.Analysis = existing.Analysis + m.Postwork = existing.Postwork - if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil && old.AI != nil { - m.AI = old.AI + m.Preview.Sprite = mergePreviewSpriteMeta(existing.Preview.Sprite, m.Preview.Sprite) + + if m.Preview.Thumb == nil && existing.Preview.Thumb != nil { + m.Preview.Thumb = existing.Preview.Thumb + } + if len(m.Preview.Clips) == 0 && len(existing.Preview.Clips) > 0 { + m.Preview.Clips = existing.Preview.Clips + } + if m.File.SourceURL == "" { + m.File.SourceURL = existing.File.SourceURL + } + if m.Media.Video.Width <= 0 { + m.Media.Video.Width = existing.Media.Video.Width + } + if m.Media.Video.Height <= 0 { + m.Media.Video.Height = existing.Media.Video.Height + } + if m.Media.Video.FPS <= 0 { + m.Media.Video.FPS = existing.Media.Video.FPS + } + if m.Media.Video.Resolution == "" { + m.Media.Video.Resolution = existing.Media.Video.Resolution + } + if m.Media.DurationSeconds <= 0 { + m.Media.DurationSeconds = existing.Media.DurationSeconds + } } buf, err := json.Marshal(m) @@ -466,7 +621,6 @@ func writeVideoMetaWithPreviewClipsAndSprite( return atomicWriteFile(metaPath, buf) } -// Duration-only Write (ohne props) func writeVideoMetaDuration(metaPath string, fi os.FileInfo, dur float64, sourceURL string) error { return writeVideoMeta(metaPath, fi, dur, 0, 0, 0, sourceURL) } @@ -477,7 +631,6 @@ func generatedMetaFile(assetID string) (string, error) { return "", fmt.Errorf("empty assetID") } - // exakt wie beim Schreiben normalisieren id, err := sanitizeID(assetID) if err != nil || id == "" { return "", fmt.Errorf("invalid assetID: %w", err) @@ -486,7 +639,6 @@ func generatedMetaFile(assetID string) (string, error) { return metaJSONPathForAssetID(id) } -// ✅ Neu: /generated/meta//... func generatedDirForID(id string) (string, error) { id, err := sanitizeID(id) if err != nil { @@ -544,6 +696,27 @@ func generatedPreviewSpriteFile(id string) (string, error) { return filepath.Join(dir, "preview-sprite.jpg"), nil } +func buildPreviewThumbMeta(assetID string) *previewThumbMeta { + assetID = stripHotPrefix(strings.TrimSpace(assetID)) + if assetID == "" { + return nil + } + + thumbPath, err := generatedThumbFile(assetID) + if err != nil || strings.TrimSpace(thumbPath) == "" { + return nil + } + + if _, err := os.Stat(thumbPath); err != nil { + return nil + } + + return &previewThumbMeta{ + Path: "/api/generated/cover?id=" + assetID, + Exists: true, + } +} + func ensureGeneratedDirs() error { root, err := generatedMetaRoot() if err != nil { @@ -581,44 +754,57 @@ func writeVideoMetaAI( } var existing *videoMeta - if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil { + if old, ok := readVideoMetaLoose(metaPath); ok && old != nil { existing = old } m := videoMeta{ - Version: 2, - DurationSeconds: dur, - FileSize: fi.Size(), - FileModUnix: fi.ModTime().Unix(), - VideoWidth: w, - VideoHeight: h, - FPS: fps, - Resolution: formatResolution(w, h), - SourceURL: strings.TrimSpace(sourceURL), - UpdatedAtUnix: time.Now().Unix(), - - PreviewClips: nil, - PreviewSprite: nil, - AI: ai, + Version: 3, + UpdatedAtUnix: time.Now().Unix(), + File: videoFileMeta{ + Size: fi.Size(), + ModUnix: fi.ModTime().Unix(), + SourceURL: strings.TrimSpace(sourceURL), + }, + Media: videoMediaMeta{ + DurationSeconds: dur, + Video: videoStreamMeta{ + Width: w, + Height: h, + FPS: fps, + Resolution: formatResolution(w, h), + }, + }, + Analysis: analysisMeta{ + AI: ai, + }, } if existing != nil { - m.PreviewClips = existing.PreviewClips - m.PreviewSprite = existing.PreviewSprite - if m.VideoWidth <= 0 { - m.VideoWidth = existing.VideoWidth + m.Preview = existing.Preview + m.Postwork = existing.Postwork + + if !hasAIAnalysisMeta(ai) && hasAIAnalysisMeta(existing.Analysis.AI) { + m.Analysis = existing.Analysis } - if m.VideoHeight <= 0 { - m.VideoHeight = existing.VideoHeight + + if m.Media.Video.Width <= 0 { + m.Media.Video.Width = existing.Media.Video.Width } - if m.FPS <= 0 { - m.FPS = existing.FPS + if m.Media.Video.Height <= 0 { + m.Media.Video.Height = existing.Media.Video.Height } - if m.Resolution == "" { - m.Resolution = existing.Resolution + if m.Media.Video.FPS <= 0 { + m.Media.Video.FPS = existing.Media.Video.FPS } - if m.SourceURL == "" { - m.SourceURL = existing.SourceURL + if m.Media.Video.Resolution == "" { + m.Media.Video.Resolution = existing.Media.Video.Resolution + } + if m.File.SourceURL == "" { + m.File.SourceURL = existing.File.SourceURL + } + if m.Media.DurationSeconds <= 0 { + m.Media.DurationSeconds = existing.Media.DurationSeconds } } @@ -670,15 +856,103 @@ func writeVideoAIForFile( return writeVideoMetaAI( metaPath, fi, - m.DurationSeconds, - m.VideoWidth, - m.VideoHeight, - m.FPS, + m.Media.DurationSeconds, + m.Media.Video.Width, + m.Media.Video.Height, + m.Media.Video.FPS, sourceURL, ai, ) } +func writeVideoMetaPostworkEntry( + metaPath string, + fi os.FileInfo, + entry postworkHistoryEntry, +) error { + if strings.TrimSpace(metaPath) == "" { + return nil + } + + var existing videoMeta + if old, ok := readVideoMetaLoose(metaPath); ok && old != nil { + existing = *old + } + + // Datei-Metadaten immer aktualisieren + existing.Version = 3 + existing.UpdatedAtUnix = time.Now().Unix() + existing.File.Size = fi.Size() + existing.File.ModUnix = fi.ModTime().Unix() + + queue := strings.TrimSpace(strings.ToLower(entry.Queue)) + phase := strings.TrimSpace(strings.ToLower(entry.Phase)) + if queue == "" { + queue = "postwork" + } + if phase == "" { + phase = "postwork" + } + entry.Queue = queue + entry.Phase = phase + if entry.TS <= 0 { + entry.TS = time.Now().Unix() + } + + history := existing.Postwork.History + replaced := false + + for i := range history { + if strings.EqualFold(history[i].Queue, entry.Queue) && + strings.EqualFold(history[i].Phase, entry.Phase) { + // nur ersetzen, wenn neuer oder gleich neu + if history[i].TS <= entry.TS { + history[i] = entry + } + replaced = true + break + } + } + + if !replaced { + history = append(history, entry) + } + + existing.Postwork.History = history + + if existing.Postwork.Completed == nil { + existing.Postwork.Completed = &postworkCompletedMeta{} + } + + if strings.EqualFold(entry.State, "done") { + switch queue { + case "postwork": + switch phase { + case "postwork", "probe", "remuxing", "moving", "meta": + existing.Postwork.Completed.Meta = true + case "assets", "teaser": + existing.Postwork.Completed.Teaser = true + case "thumb": + existing.Postwork.Completed.Thumb = true + case "sprites", "sprite": + existing.Postwork.Completed.Sprites = true + } + case "enrich": + switch phase { + case "analyze", "analysis", "ai": + existing.Postwork.Completed.Analyze = true + } + } + } + + buf, err := json.MarshalIndent(existing, "", " ") + if err != nil { + return err + } + buf = append(buf, '\n') + return atomicWriteFile(metaPath, buf) +} + func readVideoMetaAI(metaPath string) (map[string]any, bool) { b, err := os.ReadFile(metaPath) if err != nil || len(b) == 0 { @@ -692,10 +966,70 @@ func readVideoMetaAI(metaPath string) (map[string]any, bool) { return nil, false } - ai, ok := m["ai"].(map[string]any) + rawAnalysis, ok := m["analysis"].(map[string]any) + if !ok || rawAnalysis == nil { + return nil, false + } + + ai, ok := rawAnalysis["ai"].(map[string]any) if !ok || ai == nil { return nil, false } return ai, true } + +func writePostworkEntryForFile( + fullPath string, + queue string, + phase string, + state string, + label string, + position int, + waiting int, + running int, + maxParallel int, +) error { + fullPath = strings.TrimSpace(fullPath) + if fullPath == "" { + return fmt.Errorf("fullPath fehlt") + } + + fi, err := os.Stat(fullPath) + if err != nil || fi == nil || fi.IsDir() { + return fmt.Errorf("datei nicht gefunden") + } + + stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath)) + assetID := stripHotPrefix(strings.TrimSpace(stem)) + if assetID == "" { + return fmt.Errorf("asset id fehlt") + } + + assetID, err = sanitizeID(assetID) + if err != nil { + return err + } + + metaPath, err := metaJSONPathForAssetID(assetID) + if err != nil { + return err + } + + // sorgt dafür, dass meta.json grundsätzlich existiert + if _, ok := ensureVideoMetaForFileBestEffort(context.Background(), fullPath, ""); !ok { + return fmt.Errorf("meta konnte nicht erzeugt werden") + } + + return writeVideoMetaPostworkEntry(metaPath, fi, postworkHistoryEntry{ + Queue: queue, + Phase: phase, + State: state, + Label: label, + Position: position, + Waiting: waiting, + Running: running, + MaxParallel: maxParallel, + TS: time.Now().Unix(), + }) +} diff --git a/backend/record.go b/backend/record.go index 2475ed1..d5dc801 100644 --- a/backend/record.go +++ b/backend/record.go @@ -57,17 +57,19 @@ type previewSpriteMetaResp struct { StepSeconds float64 `json:"stepSeconds,omitempty"` } +type finishedPhaseTruth struct { + MetaReady bool `json:"metaReady"` + TeaserReady bool `json:"teaserReady"` + ThumbReady bool `json:"thumbReady"` + SpritesReady bool `json:"spritesReady"` + AnalyzeReady bool `json:"analyzeReady"` +} + type doneMetaFileResp struct { - File string `json:"file"` - MetaExists bool `json:"metaExists"` - DurationSeconds float64 `json:"durationSeconds,omitempty"` - Width int `json:"width,omitempty"` - Height int `json:"height,omitempty"` - FPS float64 `json:"fps,omitempty"` - SourceURL string `json:"sourceUrl,omitempty"` - PreviewSprite previewSpriteMetaResp `json:"previewSprite"` - AI any `json:"ai,omitempty"` - Error string `json:"error,omitempty"` + File string `json:"file"` + MetaExists bool `json:"metaExists"` + Meta *videoMeta `json:"meta,omitempty"` + Error string `json:"error,omitempty"` } type doneMetaResp struct { @@ -168,6 +170,29 @@ type regenerateAssetsRequest struct { File string `json:"file"` } +func publishFinishedPostworkPhase( + file string, + assetID string, + queue string, + phase string, + state string, + label string, +) { + ev := finishedPostworkEvent{ + Type: "finished_postwork", + File: file, + AssetID: assetID, + Queue: queue, + State: state, + Phase: phase, + Label: label, + TS: time.Now().UnixMilli(), + } + if b, err := json.Marshal(ev); err == nil { + publishSSE("finishedPostwork", b) + } +} + func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -219,20 +244,14 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) { // 1) Vorher evtl. laufende enrich-Jobs abbrechen cancelDeferredEnrichForFile(file) + // ✅ alten Analyze-Status aktiv löschen + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "") + // 2) SOFORT running an UI senden - startEv := finishedPostworkEvent{ - Type: "finished_postwork", - File: file, - AssetID: id, - Queue: "enrich", - State: "running", - Phase: "assets", - Label: "Wird verarbeitet", - TS: time.Now().UnixMilli(), - } - if b, err := json.Marshal(startEv); err == nil { - publishSSE("finishedPostwork", b) - } + 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") notifyDoneChanged() @@ -246,10 +265,63 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) { } } + assetWatchDone := make(chan struct{}) + + go func() { + 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 <-assetWatchDone: + checkAndEmit() + return + case <-ticker.C: + checkAndEmit() + } + } + }() + // 4) Regeneration _, errAssets := ensureAssetsForVideoWithProgressCtx(context.Background(), videoPath, "", nil) + close(assetWatchDone) + + if errAssets == nil { + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "KI-Analyse") + } + errAI := ensureDeferredAssetsAndAI(context.Background(), videoPath, "") + if errAssets == nil && errAI == nil { + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "KI-Analyse") + } + // 5) Ergebnis zurück an UI if errAssets != nil || errAI != nil { errMsg := "" @@ -259,6 +331,8 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) { errMsg = errAI.Error() } + setRegenerateAssetsTaskError(file, errMsg) + errEv := finishedPostworkEvent{ Type: "finished_postwork", File: file, @@ -273,25 +347,23 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) { publishSSE("finishedPostwork", b) } + notifyDoneChanged() + + time.AfterFunc(5*time.Second, func() { + clearRegenerateAssetsTaskState() + }) + fmt.Println("[regenerate-assets] failed:", file, errMsg) return } - doneEv := finishedPostworkEvent{ - Type: "finished_postwork", - File: file, - AssetID: id, - Queue: "enrich", - State: "done", - Phase: "assets", - Label: "Fertig", - TS: time.Now().UnixMilli(), - } - if b, err := json.Marshal(doneEv); err == nil { - publishSSE("finishedPostwork", b) - } + setRegenerateAssetsTaskDone(file, "Fertig") notifyDoneChanged() + + time.AfterFunc(3*time.Second, func() { + clearRegenerateAssetsTaskState() + }) }(videoPath, file) w.Header().Set("Content-Type", "application/json") @@ -623,7 +695,12 @@ func readPreviewSpriteMetaFromMetaFile(metaPath string) (previewSpriteMetaFileIn return out, false } - ps, ok := m["previewSprite"].(map[string]any) + previewMap, ok := m["preview"].(map[string]any) + if !ok || previewMap == nil { + return out, false + } + + ps, ok := previewMap["sprite"].(map[string]any) if !ok || ps == nil { return out, false } @@ -752,14 +829,11 @@ func previewSpriteTruthForID(id string) previewSpriteMetaResp { return out } - metaPath, err := generatedMetaFile(id) - if err != nil || strings.TrimSpace(metaPath) == "" { + _, _, _, spriteFile, metaPath, err := assetPathsForID(id) + if err != nil { return out } - genDir := filepath.Dir(metaPath) - spriteFile := filepath.Join(genDir, "preview-sprite.jpg") - fi, err := os.Stat(spriteFile) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { return out @@ -786,11 +860,154 @@ func previewSpriteTruthForID(id string) previewSpriteMetaResp { return out } +func numberFromAny(v any) (float64, bool) { + switch x := v.(type) { + case float32: + return float64(x), true + case float64: + return x, true + case int: + return float64(x), true + case int8: + return float64(x), true + case int16: + return float64(x), true + case int32: + return float64(x), true + case int64: + return float64(x), true + case uint: + return float64(x), true + case uint8: + return float64(x), true + case uint16: + return float64(x), true + case uint32: + return float64(x), true + case uint64: + return float64(x), true + case json.Number: + if f, err := x.Float64(); err == nil { + return f, true + } + case string: + s := strings.TrimSpace(x) + if s == "" { + return 0, false + } + if f, err := strconv.ParseFloat(s, 64); err == nil { + return f, true + } + } + return 0, false +} + +func finishedPhaseTruthForID(id string) finishedPhaseTruth { + var out finishedPhaseTruth + + id = strings.TrimSpace(id) + if id == "" || strings.Contains(id, "/") || strings.Contains(id, "\\") { + return out + } + + _, thumbPath, previewPath, spritePath, metaPath, err := assetPathsForID(id) + if err != nil || strings.TrimSpace(metaPath) == "" { + return out + } + + b, err := os.ReadFile(metaPath) + if err == nil && len(b) > 0 { + var m map[string]any + dec := json.NewDecoder(strings.NewReader(string(b))) + dec.UseNumber() + if err := dec.Decode(&m); err == nil { + media, _ := m["media"].(map[string]any) + if media != nil { + if d, ok := numberFromAny(media["durationSeconds"]); ok && d > 0 { + video, _ := media["video"].(map[string]any) + if video != nil { + if w, wok := numberFromAny(video["width"]); wok && w > 0 { + if h, hok := numberFromAny(video["height"]); hok && h > 0 { + out.MetaReady = true + } + } + } + } + } + + preview, _ := m["preview"].(map[string]any) + if preview != nil { + if clips, ok := preview["clips"].([]any); ok && len(clips) > 0 { + out.TeaserReady = true + } + + if thumb, ok := preview["thumb"].(map[string]any); ok && thumb != nil { + if exists, ok := thumb["exists"].(bool); ok && exists { + out.ThumbReady = true + } + if p, ok := thumb["path"].(string); ok && strings.TrimSpace(p) != "" { + out.ThumbReady = true + } + } + + if sprite, ok := preview["sprite"].(map[string]any); ok && sprite != nil { + if p, ok := sprite["path"].(string); ok && strings.TrimSpace(p) != "" { + out.SpritesReady = true + } + } + } + + analysis, _ := m["analysis"].(map[string]any) + if analysis != nil { + if ai, ok := analysis["ai"].(map[string]any); ok && ai != nil && len(ai) > 0 { + out.AnalyzeReady = true + } + } + } + } + + if !out.ThumbReady { + if fi, err := os.Stat(thumbPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 { + out.ThumbReady = true + } + } + + if !out.SpritesReady { + if fi, err := os.Stat(spritePath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 { + out.SpritesReady = true + } + } + + if !out.TeaserReady { + if fi, err := os.Stat(previewPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 { + out.TeaserReady = true + } + } + + return out +} + func applyPreviewSpriteTruthToDoneMetaResp(id string, resp *doneMetaFileResp) { if resp == nil { return } - resp.PreviewSprite = previewSpriteTruthForID(id) + + ps := previewSpriteTruthForID(id) + if !ps.Exists { + return + } + + if resp.Meta == nil { + resp.Meta = &videoMeta{} + } + + resp.Meta.Preview.Sprite = &previewSpriteMeta{ + Path: ps.Path, + Count: ps.Count, + Cols: ps.Cols, + Rows: ps.Rows, + StepSeconds: ps.StepSeconds, + } } // robust meta setter into RecordJob.Meta (any/string/[]byte/typed map) @@ -940,9 +1157,6 @@ func applyPreviewSpriteTruthToRecordJobMeta(j *RecordJob) { meta = map[string]any{} } - delete(meta, "previewScrubberPath") - delete(meta, "previewScrubberCount") - psMap := map[string]any{"exists": ps.Exists} if ps.Exists { psMap["path"] = ps.Path @@ -959,8 +1173,81 @@ func applyPreviewSpriteTruthToRecordJobMeta(j *RecordJob) { psMap["stepSeconds"] = ps.StepSeconds } } - meta["previewSprite"] = psMap + previewMap, _ := meta["preview"].(map[string]any) + if previewMap == nil { + previewMap = map[string]any{} + } + previewMap["sprite"] = psMap + meta["preview"] = previewMap + + setStructFieldJSONMap(fv, meta) +} + +func applyFinishedPhaseTruthToRecordJobMeta(j *RecordJob) { + if j == nil { + return + } + + outPath := strings.TrimSpace(j.Output) + if outPath == "" { + return + } + + base := filepath.Base(outPath) + id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base))) + id = strings.TrimSpace(id) + if id == "" { + return + } + + truth := finishedPhaseTruthForID(id) + + rv := reflect.ValueOf(j) + if rv.Kind() != reflect.Pointer || rv.IsNil() { + return + } + sv := rv.Elem() + if !sv.IsValid() || sv.Kind() != reflect.Struct { + return + } + + fv := sv.FieldByName("Meta") + if !fv.IsValid() || !fv.CanSet() { + return + } + + var raw any + switch fv.Kind() { + case reflect.Interface: + if fv.IsNil() { + raw = nil + } else { + raw = fv.Interface() + } + default: + raw = fv.Interface() + } + + meta := metaMapFromAny(raw) + if meta == nil { + meta = map[string]any{} + } + + postworkMap, _ := meta["postwork"].(map[string]any) + if postworkMap == nil { + postworkMap = map[string]any{} + } + + postworkMap["completed"] = map[string]any{ + "meta": truth.MetaReady, + "teaser": truth.TeaserReady, + "thumb": truth.ThumbReady, + "sprites": truth.SpritesReady, + "analyze": truth.AnalyzeReady, + } + + meta["postwork"] = postworkMap setStructFieldJSONMap(fv, meta) } @@ -1699,35 +1986,31 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) { resp := doneMetaFileResp{File: filepath.Base(outPath)} id := stripHotPrefix(strings.TrimSuffix(filepath.Base(outPath), filepath.Ext(outPath))) - applyPreviewSpriteTruthToDoneMetaResp(id, &resp) if strings.TrimSpace(id) != "" { if mp, merr := generatedMetaFile(id); merr == nil && strings.TrimSpace(mp) != "" { if mfi, serr := os.Stat(mp); serr == nil && mfi != nil && !mfi.IsDir() && mfi.Size() > 0 { - resp.MetaExists = true - - if dur, w2, h2, fps2, ok := readVideoMeta(mp, fi); ok { - resp.DurationSeconds = dur - resp.Width = w2 - resp.Height = h2 - resp.FPS = fps2 - } - if u, ok := readVideoMetaSourceURL(mp, fi); ok { - resp.SourceURL = u - } - - if ai, ok := readVideoMetaAI(mp); ok { - resp.AI = ai + if meta, ok := readVideoMetaIfValid(mp, fi); ok && meta != nil { + resp.MetaExists = true + resp.Meta = meta } } } } - if resp.DurationSeconds <= 0 { + // Sprite-Wahrheit aus Dateisystem/meta ggf. ergänzen/überschreiben + applyPreviewSpriteTruthToDoneMetaResp(id, &resp) + + if resp.Meta == nil { + resp.Meta = &videoMeta{} + } + + // Fallback Duration, falls Meta fehlt oder unvollständig + if resp.Meta.Media.DurationSeconds <= 0 { pctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() if d, derr := durationSecondsCached(pctx, outPath); derr == nil && d > 0 { - resp.DurationSeconds = d + resp.Meta.Media.DurationSeconds = d } } @@ -2153,6 +2436,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) { } applyPreviewSpriteTruthToRecordJobMeta(&c) + applyFinishedPhaseTruthToRecordJobMeta(&c) out = append(out, &c) } diff --git a/backend/recorder.go b/backend/recorder.go index 4dc2262..ea73e02 100644 --- a/backend/recorder.go +++ b/backend/recorder.go @@ -412,10 +412,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { err = errors.New("unsupported provider") } - if err != nil && shouldLogRecordError(err, provider, req) { - fmt.Println("❌ [record]", provider, job.SourceURL, "->", err) - } - end := time.Now() target := JobFinished diff --git a/backend/tasks_assets.go b/backend/tasks_assets.go index d3608c5..d22f655 100644 --- a/backend/tasks_assets.go +++ b/backend/tasks_assets.go @@ -3,7 +3,6 @@ package main import ( "context" - "encoding/json" "errors" "fmt" "net/http" @@ -29,6 +28,9 @@ type AssetsTaskState struct { FinishedAt *time.Time `json:"finishedAt,omitempty"` Error string `json:"error,omitempty"` CurrentFile string `json:"currentFile,omitempty"` + CurrentQueue string `json:"currentQueue,omitempty"` + CurrentPhase string `json:"currentPhase,omitempty"` + CurrentLabel string `json:"currentLabel,omitempty"` } var assetsTaskMu sync.Mutex @@ -91,11 +93,14 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) { FinishedAt: nil, Error: "", CurrentFile: "", + CurrentQueue: "", + CurrentPhase: "", + CurrentLabel: "", } st := assetsTaskState assetsTaskMu.Unlock() - // ✅ SSE: Start pushen + // SSE: Start pushen notifyAssetsChanged() go runGenerateMissingAssets(ctx) @@ -134,7 +139,7 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) { } } -func publishAssetsTaskFinishedPostworkState(fileName string, state string, label string) { +func publishAssetsTaskPhase(fileName string, queue string, phase string, state string, label string) { fileName = strings.TrimSpace(fileName) if fileName == "" { return @@ -143,52 +148,31 @@ func publishAssetsTaskFinishedPostworkState(fileName string, state string, label base := strings.TrimSuffix(fileName, filepath.Ext(fileName)) assetID := stripHotPrefix(base) - ev := finishedPostworkEvent{ - Type: "finished_postwork", - File: fileName, - AssetID: assetID, - Queue: "enrich", - State: state, // "queued" | "running" | "done" | "error" | "missing" - Phase: "assets", - Label: label, - TS: time.Now().UnixMilli(), - } - - b, err := json.Marshal(ev) - if err != nil { - return - } - - // Für FinishedDownloads reicht der globale Kanal. - publishSSE("finishedPostwork", b) + publishFinishedPostworkPhase( + fileName, + assetID, + queue, + phase, + state, + label, + ) } -func clearAssetsTaskFinishedPostworkState(fileName string) { - fileName = strings.TrimSpace(fileName) - if fileName == "" { - return +func clearAssetsTaskFinishedPostworkStates(fileName string) { + defs := []struct { + queue string + phase string + }{ + {queue: "postwork", phase: "meta"}, + {queue: "postwork", phase: "thumb"}, + {queue: "postwork", phase: "teaser"}, + {queue: "postwork", phase: "sprites"}, + {queue: "enrich", phase: "analyze"}, } - base := strings.TrimSuffix(fileName, filepath.Ext(fileName)) - assetID := stripHotPrefix(base) - - ev := finishedPostworkEvent{ - Type: "finished_postwork", - File: fileName, - AssetID: assetID, - Queue: "enrich", - State: "missing", - Phase: "assets", - Label: "", - TS: time.Now().UnixMilli(), + for _, def := range defs { + publishAssetsTaskPhase(fileName, def.queue, def.phase, "missing", "") } - - b, err := json.Marshal(ev) - if err != nil { - return - } - - publishSSE("finishedPostwork", b) } func runGenerateMissingAssets(ctx context.Context) { @@ -206,14 +190,15 @@ func runGenerateMissingAssets(ctx context.Context) { st.Running = false st.FinishedAt = &now st.CurrentFile = "" + st.CurrentQueue = "" + st.CurrentPhase = "" + st.CurrentLabel = "" if err == nil { - // Erfolg: Error leeren st.Error = "" return } - // stabiler Text für UI if errors.Is(err, context.Canceled) { st.Error = "abgebrochen" } else { @@ -234,77 +219,48 @@ func runGenerateMissingAssets(ctx context.Context) { path string } - // .trash niemals verarbeiten - isTrashPath := func(full string) bool { - p := strings.ToLower(strings.ReplaceAll(full, "\\", "/")) - return strings.Contains(p, "/.trash/") || strings.HasSuffix(p, "/.trash") - } + indexedItems, sortedIdx := buildDoneIndex(doneAbs) + + // gleiche Grundreihenfolge wie FinishedDownloads-Default: + // includeKeep = true, sort = completed_desc + order := sortedIdx["1|completed_desc"] seen := map[string]struct{}{} - items := make([]item, 0, 512) + items := make([]item, 0, len(order)) - addIfVideo := func(full string) { - if isTrashPath(full) { - return + for _, idx := range order { + if idx < 0 || idx >= len(indexedItems) { + continue } - name := filepath.Base(full) - low := strings.ToLower(name) - if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") { - return + j := indexedItems[idx].job + if j == nil { + continue } - ext := strings.ToLower(filepath.Ext(name)) - if ext != ".mp4" && ext != ".ts" { - return + + full := strings.TrimSpace(j.Output) + if full == "" { + continue } if _, ok := seen[full]; ok { - return + continue } seen[full] = struct{}{} - items = append(items, item{name: name, path: full}) + + items = append(items, item{ + name: filepath.Base(full), + path: full, + }) } - scanOneLevel := func(dir string) { - ents, err := os.ReadDir(dir) - if err != nil { - return - } - for _, e := range ents { - if e.IsDir() && strings.EqualFold(e.Name(), ".trash") { - continue - } - - full := filepath.Join(dir, e.Name()) - if e.IsDir() { - sub, err := os.ReadDir(full) - if err != nil { - continue - } - for _, se := range sub { - if se.IsDir() { - continue - } - addIfVideo(filepath.Join(full, se.Name())) - } - continue - } - addIfVideo(full) - } - } - - // done + done// + done/keep + done/keep// - scanOneLevel(doneAbs) - scanOneLevel(filepath.Join(doneAbs, "keep")) - - // ✅ Initialisierung: Total etc. + SSE Push + // Initialisierung: Total etc. + SSE Push updateAssetsState(func(st *AssetsTaskState) { st.Total = len(items) st.Done = 0 st.GeneratedThumbs = 0 st.GeneratedPreviews = 0 st.Skipped = 0 - // Start hat Error schon geleert — hier nur sicherheitshalber: st.Error = "" }) @@ -314,22 +270,16 @@ func runGenerateMissingAssets(ctx context.Context) { return } - // ✅ aktuellen Dateinamen für UI setzen + // aktuellen Dateinamen für UI setzen updateAssetsState(func(st *AssetsTaskState) { st.CurrentFile = it.name }) - publishAssetsTaskFinishedPostworkState( - it.name, - "running", - "Erstelle Teaser / Vorschau / Thumbnails…", - ) - // ID aus Dateiname base := strings.TrimSuffix(it.name, filepath.Ext(it.name)) id := stripHotPrefix(base) if strings.TrimSpace(id) == "" { - clearAssetsTaskFinishedPostworkState(it.name) + clearAssetsTaskFinishedPostworkStates(it.name) updateAssetsState(func(st *AssetsTaskState) { st.Done = i + 1 @@ -340,7 +290,7 @@ func runGenerateMissingAssets(ctx context.Context) { // Datei-Info (validieren) vfi, verr := os.Stat(it.path) if verr != nil || vfi.IsDir() || vfi.Size() <= 0 { - clearAssetsTaskFinishedPostworkState(it.name) + clearAssetsTaskFinishedPostworkStates(it.name) updateAssetsState(func(st *AssetsTaskState) { st.Done = i + 1 @@ -348,17 +298,46 @@ func runGenerateMissingAssets(ctx context.Context) { continue } + // Vorher-Wahrheit ermitteln: was fehlt wirklich? + beforeTruth := finishedPhaseTruthForID(id) + + needMeta := !beforeTruth.MetaReady + needThumb := !beforeTruth.ThumbReady + needTeaser := !beforeTruth.TeaserReady + needSprites := !beforeTruth.SpritesReady + needAnalyze := !beforeTruth.AnalyzeReady + + // Wenn gar nichts fehlt -> alte evtl. Live-States wegräumen und skippen + if !needMeta && !needThumb && !needTeaser && !needSprites && !needAnalyze { + clearAssetsTaskFinishedPostworkStates(it.name) + + updateAssetsState(func(st *AssetsTaskState) { + st.Skipped++ + st.Done = i + 1 + }) + continue + } + // Pfade einmalig über zentralen Helper _, _, _, _, metaPath, perr := assetPathsForID(id) if perr != nil { - publishAssetsTaskFinishedPostworkState( - it.name, - "error", - "Assets fehlgeschlagen", - ) + if needMeta { + publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen") + } + if needThumb { + publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen") + } + if needTeaser { + publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen") + } + if needSprites { + publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen") + } + if needAnalyze { + publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen") + } updateAssetsState(func(st *AssetsTaskState) { - // UI bekommt stabilen Hinweis, aber Task läuft weiter st.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)" st.Done = i + 1 }) @@ -372,43 +351,133 @@ func runGenerateMissingAssets(ctx context.Context) { sourceURL = u } - // Generate/Ensure (einheitliche Core-Funktion) - res, e := ensureAssetsForVideoWithProgressCtx(ctx, it.path, sourceURL, nil) - if e != nil { - publishAssetsTaskFinishedPostworkState( - it.name, - "error", - "Assets fehlgeschlagen", - ) - finishWithErr(e) - return - } + thumbGenerated := false + previewGenerated := false - if _, aerr := prepareVideoForSplit(ctx, it.path, sourceURL, "nsfw"); aerr != nil { + // ---------------- + // META + // ---------------- + if needMeta { updateAssetsState(func(st *AssetsTaskState) { - st.Error = "mindestens ein Eintrag konnte nicht vollständig analysiert werden (siehe Logs)" + st.CurrentFile = it.name + st.CurrentQueue = "postwork" + st.CurrentPhase = "meta" + st.CurrentLabel = "Meta" }) - //fmt.Println("⚠️ tasks generate assets analyze:", aerr) + + publishAssetsTaskPhase(it.name, "postwork", "meta", "running", "Meta") + + okMeta, merr := ensureMetaForVideoCtx(ctx, it.path, sourceURL) + if merr != nil || !okMeta { + publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen") + } else { + publishAssetsTaskPhase(it.name, "postwork", "meta", "done", "Meta") + } } - if res.Skipped { - clearAssetsTaskFinishedPostworkState(it.name) - } else { - publishAssetsTaskFinishedPostworkState( - it.name, - "done", - "Teaser / Vorschau / Thumbnails fertig", - ) + // ---------------- + // THUMB + // ---------------- + if needThumb { + updateAssetsState(func(st *AssetsTaskState) { + st.CurrentFile = it.name + st.CurrentQueue = "postwork" + st.CurrentPhase = "thumb" + st.CurrentLabel = "Vorschaubild" + }) + + publishAssetsTaskPhase(it.name, "postwork", "thumb", "running", "Vorschaubild") + + resThumb, terr := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, it.path, sourceURL, nil) + if terr != nil { + publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen") + } else if finishedPhaseTruthForID(id).ThumbReady { + if resThumb.ThumbGenerated { + thumbGenerated = true + } + publishAssetsTaskPhase(it.name, "postwork", "thumb", "done", "Vorschaubild") + } else { + publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen") + } + } + + // ---------------- + // TEASER + // ---------------- + if needTeaser { + updateAssetsState(func(st *AssetsTaskState) { + st.CurrentFile = it.name + st.CurrentQueue = "postwork" + st.CurrentPhase = "teaser" + st.CurrentLabel = "Teaser" + }) + + publishAssetsTaskPhase(it.name, "postwork", "teaser", "running", "Teaser") + + genTeaser, terr := ensureTeaserForVideoCtx(ctx, it.path, sourceURL) + if terr != nil { + publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen") + } else if finishedPhaseTruthForID(id).TeaserReady { + if genTeaser { + previewGenerated = true + } + publishAssetsTaskPhase(it.name, "postwork", "teaser", "done", "Teaser") + } else { + publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen") + } + } + + // ---------------- + // SPRITES + // ---------------- + if needSprites { + updateAssetsState(func(st *AssetsTaskState) { + st.CurrentFile = it.name + st.CurrentQueue = "postwork" + st.CurrentPhase = "sprites" + st.CurrentLabel = "Sprites" + }) + + publishAssetsTaskPhase(it.name, "postwork", "sprites", "running", "Sprites") + + _, serr := ensureSpritesForVideoCtx(ctx, it.path, sourceURL) + if serr != nil { + publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen") + } else if finishedPhaseTruthForID(id).SpritesReady { + publishAssetsTaskPhase(it.name, "postwork", "sprites", "done", "Sprites") + } else { + publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen") + } + } + + // ---------------- + // ANALYZE + // ---------------- + if needAnalyze { + updateAssetsState(func(st *AssetsTaskState) { + st.CurrentFile = it.name + st.CurrentQueue = "enrich" + st.CurrentPhase = "analyze" + st.CurrentLabel = "KI-Analyse" + }) + + publishAssetsTaskPhase(it.name, "enrich", "analyze", "running", "KI-Analyse") + + _, aerr := ensureAnalyzeForVideoCtx(ctx, it.path, sourceURL, "nsfw") + if aerr != nil { + publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen") + } else if finishedPhaseTruthForID(id).AnalyzeReady { + publishAssetsTaskPhase(it.name, "enrich", "analyze", "done", "KI-Analyse") + } else { + publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen") + } } updateAssetsState(func(st *AssetsTaskState) { - if res.Skipped { - st.Skipped++ - } - if res.ThumbGenerated { + if thumbGenerated { st.GeneratedThumbs++ } - if res.PreviewGenerated { + if previewGenerated { st.GeneratedPreviews++ } st.Done = i + 1 diff --git a/backend/tasks_cleanup.go b/backend/tasks_cleanup.go index 74e201e..a5fd6b4 100644 --- a/backend/tasks_cleanup.go +++ b/backend/tasks_cleanup.go @@ -14,6 +14,12 @@ import ( "time" ) +type cleanupCandidate struct { + path string + name string + size int64 +} + type cleanupResp struct { // Small downloads cleanup ScannedFiles int `json:"scannedFiles"` @@ -103,8 +109,9 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) { return ext == ".mp4" || ext == ".ts" } - // scan: doneAbs + 1-level subdirs, "keep" wird übersprungen - scanDir := func(dir string, allowSubdirs bool) { + candidates := make([]cleanupCandidate, 0, 1024) + + collectCandidates := func(dir string, allowSubdirs bool) { ents, err := os.ReadDir(dir) if err != nil { return @@ -125,10 +132,12 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) { if err != nil { continue } + for _, se := range sub { if se.IsDir() { continue } + name := se.Name() if !isCandidate(name) { resp.SkippedFiles++ @@ -142,31 +151,15 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) { continue } - resp.ScannedFiles++ - - if fi.Size() < threshold { - base := strings.TrimSuffix(filepath.Base(p), filepath.Ext(p)) - id := stripHotPrefix(base) - - if derr := removeWithRetry(p); derr == nil || os.IsNotExist(derr) { - resp.DeletedFiles++ - resp.DeletedBytes += fi.Size() - - // generated + legacy cleanup (best effort) - if strings.TrimSpace(id) != "" { - removeGeneratedForID(id) - } - - purgeDurationCacheForPath(p) - } else { - resp.ErrorCount++ - } - } + candidates = append(candidates, cleanupCandidate{ + path: p, + name: name, + size: fi.Size(), + }) } continue } - // root-level file name := e.Name() if !isCandidate(name) { resp.SkippedFiles++ @@ -179,29 +172,43 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) { continue } - resp.ScannedFiles++ - - if fi.Size() < threshold { - base := strings.TrimSuffix(filepath.Base(full), filepath.Ext(full)) - id := stripHotPrefix(base) - - if derr := removeWithRetry(full); derr == nil || os.IsNotExist(derr) { - resp.DeletedFiles++ - resp.DeletedBytes += fi.Size() - - if strings.TrimSpace(id) != "" { - removeGeneratedForID(id) - } - - purgeDurationCacheForPath(full) - } else { - resp.ErrorCount++ - } - } + candidates = append(candidates, cleanupCandidate{ + path: full, + name: name, + size: fi.Size(), + }) } } - scanDir(doneAbs, true) + collectCandidates(doneAbs, true) + + resp.ScannedFiles = len(candidates) + + setCleanupTaskProgress(0, resp.ScannedFiles, "", "Prüfe Dateien…") + + for i, c := range candidates { + setCleanupTaskProgress(i+1, resp.ScannedFiles, c.name, fmt.Sprintf("Prüfe %s", c.name)) + + if c.size >= threshold { + continue + } + + base := strings.TrimSuffix(filepath.Base(c.path), filepath.Ext(c.path)) + id := stripHotPrefix(base) + + if derr := removeWithRetry(c.path); derr == nil || os.IsNotExist(derr) { + resp.DeletedFiles++ + resp.DeletedBytes += c.size + + if strings.TrimSpace(id) != "" { + removeGeneratedForID(id) + } + + purgeDurationCacheForPath(c.path) + } else { + resp.ErrorCount++ + } + } } var generatedGCRunning int32 diff --git a/backend/tasks_status.go b/backend/tasks_status.go index 1a21e2d..1ecd716 100644 --- a/backend/tasks_status.go +++ b/backend/tasks_status.go @@ -7,12 +7,17 @@ import ( "net/http" "strings" "sync" + "time" ) type CleanupTaskState struct { - Running bool `json:"running"` - Text string `json:"text,omitempty"` - Error string `json:"error,omitempty"` + Running bool `json:"running"` + Done int `json:"done"` + Total int `json:"total"` + Text string `json:"text,omitempty"` + CurrentFile string `json:"currentFile,omitempty"` + Error string `json:"error,omitempty"` + FinishedAt *string `json:"finishedAt,omitempty"` } type RegenerateAssetsTaskState struct { @@ -37,6 +42,11 @@ var ( regenerateAssetsTaskState RegenerateAssetsTaskState ) +func cleanupNowISO() *string { + s := time.Now().Format(time.RFC3339) + return &s +} + func snapshotRegenerateAssetsTaskState() RegenerateAssetsTaskState { regenerateAssetsTaskMu.Lock() st := regenerateAssetsTaskState @@ -77,6 +87,12 @@ func setRegenerateAssetsTaskError(file, errText string) { regenerateAssetsTaskMu.Unlock() } +func clearRegenerateAssetsTaskState() { + regenerateAssetsTaskMu.Lock() + regenerateAssetsTaskState = RegenerateAssetsTaskState{} + regenerateAssetsTaskMu.Unlock() +} + func snapshotCleanupTaskState() CleanupTaskState { cleanupTaskMu.Lock() st := cleanupTaskState @@ -87,30 +103,50 @@ func snapshotCleanupTaskState() CleanupTaskState { func setCleanupTaskRunning(text string) { cleanupTaskMu.Lock() cleanupTaskState = CleanupTaskState{ - Running: true, - Text: strings.TrimSpace(text), - Error: "", + Running: true, + Done: 0, + Total: 0, + Text: strings.TrimSpace(text), + CurrentFile: "", + Error: "", + FinishedAt: nil, } cleanupTaskMu.Unlock() } +func setCleanupTaskProgress(done, total int, currentFile, text string) { + cleanupTaskMu.Lock() + cleanupTaskState.Running = true + cleanupTaskState.Done = done + cleanupTaskState.Total = total + cleanupTaskState.CurrentFile = strings.TrimSpace(currentFile) + cleanupTaskState.Text = strings.TrimSpace(text) + cleanupTaskState.Error = "" + cleanupTaskState.FinishedAt = nil + cleanupTaskMu.Unlock() +} + func setCleanupTaskDone(text string) { cleanupTaskMu.Lock() - cleanupTaskState = CleanupTaskState{ - Running: false, - Text: strings.TrimSpace(text), - Error: "", + cleanupTaskState.Running = false + cleanupTaskState.Text = strings.TrimSpace(text) + cleanupTaskState.CurrentFile = "" + cleanupTaskState.Error = "" + cleanupTaskState.FinishedAt = cleanupNowISO() + + if cleanupTaskState.Total > 0 { + cleanupTaskState.Done = cleanupTaskState.Total } + cleanupTaskMu.Unlock() } func setCleanupTaskError(errText string) { cleanupTaskMu.Lock() - cleanupTaskState = CleanupTaskState{ - Running: false, - Text: "", - Error: strings.TrimSpace(errText), - } + cleanupTaskState.Running = false + cleanupTaskState.Error = strings.TrimSpace(errText) + cleanupTaskState.CurrentFile = "" + cleanupTaskState.FinishedAt = cleanupNowISO() cleanupTaskMu.Unlock() } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c67c514..c2723ab 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -69,7 +69,7 @@ type RecorderSettingsState = { } type JobEvent = { - type?: 'job_upsert' | 'job_remove' | 'finished_postwork' + type?: 'job_upsert' | 'job_remove' model?: string jobId?: string status?: string @@ -604,17 +604,33 @@ export default function App() { const loadSettingsTaskState = async () => { try { - const res = await fetch('/api/tasks/generate-assets', { cache: 'no-store' as any }) - if (!res.ok) { - if (!cancelled) setSettingsTaskRunning(false) - return + const [genRes, statusRes] = await Promise.all([ + fetch('/api/tasks/generate-assets', { cache: 'no-store' as any }), + fetch('/api/tasks/status', { cache: 'no-store' as any }), + ]) + + let generateAssetsRunning = false + let regenerateAssetsRunning = false + let cleanupRunning = false + + if (genRes.ok) { + const genData = await genRes.json().catch(() => null) + generateAssetsRunning = Boolean(genData?.running) + } + + if (statusRes.ok) { + const statusData = await statusRes.json().catch(() => null) + regenerateAssetsRunning = Boolean(statusData?.regenerateAssets?.running) + cleanupRunning = Boolean(statusData?.cleanup?.running) } - const data = await res.json().catch(() => null) if (cancelled) return - const running = Boolean(data?.running) - setSettingsTaskRunning(running) + setSettingsTaskRunning( + generateAssetsRunning || + regenerateAssetsRunning || + cleanupRunning + ) } catch { if (!cancelled) setSettingsTaskRunning(false) } @@ -1250,56 +1266,6 @@ export default function App() { const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent const modelKey = String(msg?.model ?? '').trim().toLowerCase() - // ✅ finished_postwork direkt an FinishedDownloads weiterreichen - if (msg?.type === 'finished_postwork') { - const file = String(msg?.file ?? '').trim() - if (file) { - window.dispatchEvent( - new CustomEvent('finished-downloads:postwork', { - detail: { - file, - assetId: String(msg?.assetId ?? '').trim() || undefined, - queue: - msg?.queue === 'enrich' - ? 'enrich' - : 'postwork', - state: - msg?.state === 'queued' || - msg?.state === 'running' || - msg?.state === 'done' || - msg?.state === 'error' || - msg?.state === 'missing' - ? msg.state - : 'missing', - phase: String(msg?.phase ?? '').trim() || undefined, - label: String(msg?.label ?? '').trim() || undefined, - position: - typeof msg?.position === 'number' && Number.isFinite(msg.position) - ? msg.position - : undefined, - waiting: - typeof msg?.waiting === 'number' && Number.isFinite(msg.waiting) - ? msg.waiting - : undefined, - running: - typeof msg?.running === 'number' && Number.isFinite(msg.running) - ? msg.running - : undefined, - maxParallel: - typeof msg?.maxParallel === 'number' && Number.isFinite(msg.maxParallel) - ? msg.maxParallel - : undefined, - ts: - typeof msg?.ts === 'number' && Number.isFinite(msg.ts) - ? msg.ts - : Date.now(), - }, - }) - ) - } - return - } - if (msg?.type === 'job_upsert') { if (modelKey) { const roomStatus = String(msg?.roomStatus ?? '').trim().toLowerCase() @@ -2453,34 +2419,6 @@ export default function App() { }, document.hidden ? 60000 : 5000) } - const lastDoneFireRef = { t: 0 } - let doneCoalesceTimer: number | null = null - - const requestDoneRefresh = () => { - const now = Date.now() - const since = now - lastDoneFireRef.t - - if (since < 800) { - if (doneCoalesceTimer != null) return - doneCoalesceTimer = window.setTimeout(() => { - doneCoalesceTimer = null - lastDoneFireRef.t = Date.now() - - void loadDoneCount() - if (selectedTabRef.current === 'finished') { - requestFinishedReload('doneChanged coalesced') - } - }, 900) - return - } - - lastDoneFireRef.t = now - void loadDoneCount() - if (selectedTabRef.current === 'finished') { - requestFinishedReload('doneChanged') - } - } - const applyAutostartState = (data: unknown) => { const d = (data ?? {}) as any setAutostartState({ @@ -2490,11 +2428,64 @@ export default function App() { }) } - // Initialdaten + const onDoneChanged = () => { + void loadDoneCount() + } + + const onAutostart = (ev: MessageEvent) => { + try { + applyAutostartState(JSON.parse(String(ev.data ?? 'null'))) + } catch {} + } + + const onFinishedPostwork = (ev: MessageEvent) => { + try { + const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent + const file = String(msg?.file ?? '').trim() + if (!file) return + + const state = + msg?.state === 'queued' || + msg?.state === 'running' || + msg?.state === 'done' || + msg?.state === 'error' || + msg?.state === 'missing' + ? msg.state + : 'missing' + + window.dispatchEvent( + new CustomEvent('finished-downloads:postwork', { + detail: { + file, + assetId: String(msg?.assetId ?? '').trim() || undefined, + queue: msg?.queue === 'enrich' ? 'enrich' : 'postwork', + state, + phase: String(msg?.phase ?? '').trim() || undefined, + label: String(msg?.label ?? '').trim() || undefined, + position: typeof msg?.position === 'number' && Number.isFinite(msg.position) ? msg.position : undefined, + waiting: typeof msg?.waiting === 'number' && Number.isFinite(msg.waiting) ? msg.waiting : undefined, + running: typeof msg?.running === 'number' && Number.isFinite(msg.running) ? msg.running : undefined, + maxParallel: typeof msg?.maxParallel === 'number' && Number.isFinite(msg.maxParallel) ? msg.maxParallel : undefined, + ts: typeof msg?.ts === 'number' && Number.isFinite(msg.ts) ? msg.ts : Date.now(), + }, + }) + ) + + // ✅ Wenn Assets fertig / fehlerhaft / entfernt sind, Preview-/Sprite-Cache neu ziehen + if (state === 'done' || state === 'error' || state === 'missing') { + bumpAssets() + } + + // ✅ Optional: Finished-Ansicht sichtbar? Dann Liste leicht anstoßen + if (selectedTabRef.current === 'finished') { + requestFinishedReload(`finishedPostwork:${state}`) + } + } catch {} + } + void loadJobs() void loadDoneCount() - // Optional initialer Fallback für autostart, bis erstes SSE kommt void apiJSON('/api/autostart/state', { cache: 'no-store' as any }) .then((s) => { setAutostartState({ @@ -2517,102 +2508,10 @@ export default function App() { startFallbackPoll() } - const onDoneChanged = () => { - requestDoneRefresh() - } - - const onAutostart = (ev: MessageEvent) => { - try { - applyAutostartState(JSON.parse(String(ev.data ?? 'null'))) - } catch { - // ignore - } - } - - const onFinishedPostwork = (ev: MessageEvent) => { - try { - const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent - const file = String(msg?.file ?? '').trim() - if (!file) return - - window.dispatchEvent( - new CustomEvent('finished-downloads:postwork', { - detail: { - file, - assetId: String(msg?.assetId ?? '').trim() || undefined, - queue: - msg?.queue === 'enrich' - ? 'enrich' - : 'postwork', - state: - msg?.state === 'queued' || - msg?.state === 'running' || - msg?.state === 'done' || - msg?.state === 'error' || - msg?.state === 'missing' - ? msg.state - : 'missing', - phase: String(msg?.phase ?? '').trim() || undefined, - label: String(msg?.label ?? '').trim() || undefined, - position: - typeof msg?.position === 'number' && Number.isFinite(msg.position) - ? msg.position - : undefined, - waiting: - typeof msg?.waiting === 'number' && Number.isFinite(msg.waiting) - ? msg.waiting - : undefined, - running: - typeof msg?.running === 'number' && Number.isFinite(msg.running) - ? msg.running - : undefined, - maxParallel: - typeof msg?.maxParallel === 'number' && Number.isFinite(msg.maxParallel) - ? msg.maxParallel - : undefined, - ts: - typeof msg?.ts === 'number' && Number.isFinite(msg.ts) - ? msg.ts - : Date.now(), - }, - }) - ) - } catch { - // ignore - } - } - es.addEventListener('doneChanged', onDoneChanged as any) es.addEventListener('autostart', onAutostart as any) es.addEventListener('finishedPostwork', onFinishedPostwork as any) - // initial nur Listener für wirklich relevante Jobs / Queue setzen - - // 1) sichtbare Downloads + sichtbare Nacharbeiten - for (const j of jobsRef.current) { - if (!isVisibleDownloadJobForSSE(j) && !isVisiblePostworkJobForSSE(j)) continue - - const key = modelEventKeyFromJob(j) - if (key) ensureModelEventListener(key) - } - - // 2) sichtbare Finished-Jobs der aktuellen Seite - for (const j of doneJobs) { - const key = modelEventKeyFromDoneJob(j) - if (key) ensureModelEventListener(key) - } - - // 3) sichtbare Wartenden-Einträge - for (const p of pendingWatchedRooms) { - const k = String(p?.modelKey ?? '').trim().toLowerCase() - if (k) ensureModelEventListener(k) - } - - // 4) watched Models ebenfalls abonnieren - for (const key of watchedModelKeysLower) { - if (key) ensureModelEventListener(key) - } - const onVis = () => { if (document.hidden) return void loadJobs() @@ -2626,11 +2525,6 @@ export default function App() { return () => { document.removeEventListener('visibilitychange', onVis) - - if (doneCoalesceTimer != null) { - window.clearTimeout(doneCoalesceTimer) - } - stopFallbackPoll() if (es) { @@ -2650,18 +2544,8 @@ export default function App() { eventSourceRef.current = null modelEventNamesRef.current = new Set() - es = null } - }, [ - authed, - loadJobs, - loadDoneCount, - requestFinishedReload, - ensureModelEventListener, - doneJobs, - pendingWatchedRooms, - watchedModelKeysLower, - ]) + }, [authed, loadJobs, loadDoneCount, requestFinishedReload]) useEffect(() => { const desired = new Set() @@ -3648,6 +3532,7 @@ export default function App() { blurPreviews={Boolean(recSettings.blurPreviews)} teaserPlayback={recSettings.teaserPlayback ?? 'hover'} teaserAudio={Boolean(recSettings.teaserAudio)} + pauseTeasers={Boolean(detailsModelKey) || splitModalOpen} assetNonce={assetNonce} sortMode={doneSort} onSortModeChange={(m) => { diff --git a/frontend/src/components/ui/FinishedDownloads.tsx b/frontend/src/components/ui/FinishedDownloads.tsx index 9655f5d..381ad8f 100644 --- a/frontend/src/components/ui/FinishedDownloads.tsx +++ b/frontend/src/components/ui/FinishedDownloads.tsx @@ -5,7 +5,21 @@ import * as React from 'react' import { useMemo, useEffect, useCallback } from 'react' import Card from './Card' -import type { RecordJob } from '../../types' +import type { + ViewMode, + SelectedItem, + UndoAction, + PersistedUndoState, + RunFileMutationOptions, + RecordJob, + RecordJobMeta, + StoredModelFlags, + FinishedPostworkBadge, + FinishedPostworkState, + FinishedPostworkStepSummary, + FinishedPostworkSummary, + DoneSortMode, +} from '../../types' import ButtonGroup from './ButtonGroup' import { TableCellsIcon, @@ -34,32 +48,9 @@ import { isHotName, stripHotPrefix } from './hotName' import LabeledSwitch from './LabeledSwitch' import Switch from './Switch' import LoadingSpinner from './LoadingSpinner' +import HoverPopover from './HoverPopover' import type { FinishedDownloadsTeaserState, TeaserPlaybackMode } from './teaserPlayback' -type SortMode = - | 'completed_desc' - | 'completed_asc' - | 'file_asc' - | 'file_desc' - | 'duration_desc' - | 'duration_asc' - | 'size_desc' - | 'size_asc' - -type FinishedPostworkBadge = { - file: string - assetId?: string - queue: 'postwork' | 'enrich' - state: 'queued' | 'running' | 'done' | 'error' | 'missing' - phase?: string - label?: string - position?: number - waiting?: number - running?: number - maxParallel?: number - ts: number -} - type Props = { jobs: RecordJob[] doneJobs: RecordJob[] @@ -67,6 +58,7 @@ type Props = { blurPreviews?: boolean teaserPlayback?: TeaserPlaybackMode teaserAudio?: boolean + pauseTeasers?: boolean onOpenPlayer: (job: RecordJob, startAtSec?: number) => void onDeleteJob?: ( job: RecordJob @@ -85,8 +77,8 @@ type Props = { pageSize: number onPageChange: (page: number) => void assetNonce?: number - sortMode: SortMode - onSortModeChange: (m: SortMode) => void + sortMode: DoneSortMode + onSortModeChange: (m: DoneSortMode) => void loadMode?: 'paged' | 'all' } @@ -205,15 +197,6 @@ const modelNameFromOutput = (output?: string) => { return i > 0 ? stem.slice(0, i) : stem } -type StoredModelFlags = { - id: string - modelKey: string - favorite?: boolean - liked?: boolean | null - watching?: boolean | null - tags?: string -} - const lower = (s: string) => (s || '').trim().toLowerCase() // Tags kommen aus dem ModelStore als String (meist komma-/semicolon-getrennt) @@ -289,42 +272,154 @@ function fileStemFromOutput(output?: string): string { return stripHotPrefix(file.replace(/\.[^.]+$/, '')) } -function jobHasFinishedAssets(job: RecordJob): boolean { - const anyJob = job as any - const meta = anyJob?.meta +function getPostworkSummaryForOutput( + output: string | undefined, + summaries: Record +): FinishedPostworkSummary | undefined { + const fileRaw = baseName(output || '') + if (!fileRaw) return undefined - const previewSpriteExists = - meta?.previewSprite?.exists === true || - typeof meta?.previewSprite?.path === 'string' + const filePlain = stripHotPrefix(fileRaw) - const hasDuration = - typeof anyJob?.durationSeconds === 'number' && - Number.isFinite(anyJob.durationSeconds) && - anyJob.durationSeconds > 0 - - const hasResolution = - (typeof anyJob?.videoWidth === 'number' && - Number.isFinite(anyJob.videoWidth) && - anyJob.videoWidth > 0 && - typeof anyJob?.videoHeight === 'number' && - Number.isFinite(anyJob.videoHeight) && - anyJob.videoHeight > 0) || - (typeof anyJob?.width === 'number' && - Number.isFinite(anyJob.width) && - anyJob.width > 0 && - typeof anyJob?.height === 'number' && - Number.isFinite(anyJob.height) && - anyJob.height > 0) - - // pragmatisch: - // - duration + resolution => meta/probe war da - // - previewSprite => späte Assets waren da - // Das ist ein guter Indikator für "alles Wesentliche fertig" - return hasDuration && hasResolution && previewSpriteExists + return summaries[fileRaw] ?? summaries[filePlain] ?? undefined } -function postworkBadgeTone(badge?: FinishedPostworkBadge): string { - const state = String(badge?.state ?? '').toLowerCase() +function parseMetaObject(metaRaw: unknown): RecordJobMeta | null { + if (!metaRaw) return null + + if (typeof metaRaw === 'string') { + try { + return JSON.parse(metaRaw) as RecordJobMeta + } catch { + return null + } + } + + return typeof metaRaw === 'object' ? (metaRaw as RecordJobMeta) : null +} + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function mergeMetaPreferDone( + existingMetaRaw: string | RecordJobMeta | undefined, + incomingMetaRaw: string | RecordJobMeta | undefined +): string | RecordJobMeta | undefined { + const existingMeta = parseMetaObject(existingMetaRaw) + const incomingMeta = parseMetaObject(incomingMetaRaw) + + if (!existingMeta && !incomingMeta) return existingMetaRaw ?? incomingMetaRaw + if (!existingMeta) return incomingMetaRaw + if (!incomingMeta) return existingMetaRaw + + const merged: RecordJobMeta = { + ...(incomingMeta as Record), + ...(existingMeta as Record), + } as RecordJobMeta + + const incomingPostwork = isPlainObject(incomingMeta.postwork) ? incomingMeta.postwork : {} + const existingPostwork = isPlainObject(existingMeta.postwork) ? existingMeta.postwork : {} + + const incomingCompleted = isPlainObject(incomingPostwork.completed) + ? incomingPostwork.completed + : {} + + const existingCompleted = isPlainObject(existingPostwork.completed) + ? existingPostwork.completed + : {} + + merged.postwork = { + ...incomingPostwork, + ...existingPostwork, + completed: { + ...incomingCompleted, + ...existingCompleted, + }, + history: + Array.isArray(existingPostwork.history) && existingPostwork.history.length > 0 + ? existingPostwork.history + : Array.isArray(incomingPostwork.history) + ? incomingPostwork.history + : undefined, + } as any + + const incomingPreview = isPlainObject(incomingMeta.preview) ? incomingMeta.preview : {} + const existingPreview = isPlainObject(existingMeta.preview) ? existingMeta.preview : {} + + merged.preview = { + ...incomingPreview, + ...existingPreview, + clips: + Array.isArray(existingPreview.clips) && existingPreview.clips.length > 0 + ? existingPreview.clips + : Array.isArray(incomingPreview.clips) + ? incomingPreview.clips + : undefined, + thumb: { + ...(isPlainObject(incomingPreview.thumb) ? incomingPreview.thumb : {}), + ...(isPlainObject(existingPreview.thumb) ? existingPreview.thumb : {}), + }, + sprite: { + ...(isPlainObject(incomingPreview.sprite) ? incomingPreview.sprite : {}), + ...(isPlainObject(existingPreview.sprite) ? existingPreview.sprite : {}), + }, + } as any + + const incomingMedia = isPlainObject(incomingMeta.media) ? incomingMeta.media : {} + const existingMedia = isPlainObject(existingMeta.media) ? existingMeta.media : {} + + merged.media = { + ...incomingMedia, + ...existingMedia, + durationSeconds: + typeof existingMedia.durationSeconds === 'number' && existingMedia.durationSeconds > 0 + ? existingMedia.durationSeconds + : typeof incomingMedia.durationSeconds === 'number' && incomingMedia.durationSeconds > 0 + ? incomingMedia.durationSeconds + : undefined, + video: { + ...(isPlainObject(incomingMedia.video) ? incomingMedia.video : {}), + ...(isPlainObject(existingMedia.video) ? existingMedia.video : {}), + }, + } as any + + const incomingAnalysis = isPlainObject(incomingMeta.analysis) ? incomingMeta.analysis : {} + const existingAnalysis = isPlainObject(existingMeta.analysis) ? existingMeta.analysis : {} + + merged.analysis = { + ...incomingAnalysis, + ...existingAnalysis, + ai: + isPlainObject(existingAnalysis.ai) && Object.keys(existingAnalysis.ai).length > 0 + ? { + ...(isPlainObject(incomingAnalysis.ai) ? incomingAnalysis.ai : {}), + ...(isPlainObject(existingAnalysis.ai) ? existingAnalysis.ai : {}), + } + : isPlainObject(incomingAnalysis.ai) + ? incomingAnalysis.ai + : undefined, + } as any + + return merged +} + +function metaDurationSeconds(meta: any): number | undefined { + const v = + meta?.media?.durationSeconds ?? + meta?.file?.durationSeconds + + return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined +} + +function postworkBadgeTone(summary?: FinishedPostworkSummary): string { + const state = String(summary?.state ?? '').toLowerCase() + const label = String(summary?.label ?? '').toLowerCase() + const isPartialDone = state === 'done' && label.startsWith('teilweise fertig') + + if (isPartialDone) { + return 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-500/30' + } switch (state) { case 'running': @@ -340,84 +435,814 @@ function postworkBadgeTone(badge?: FinishedPostworkBadge): string { } } +function labelForStep(queue: 'postwork' | 'enrich', phase?: string): string { + const p = String(phase ?? '').trim().toLowerCase() + + switch (`${queue}:${p}`) { + case 'postwork:meta': + return 'Meta' + case 'postwork:teaser': + return 'Teaser' + case 'postwork:thumb': + return 'Vorschaubild' + case 'postwork:sprites': + return 'Sprites' + case 'enrich:analyze': + return 'KI-Analyse' + default: + return p || (queue === 'enrich' ? 'KI-Analyse' : 'Meta') + } +} + +function shortRunningLabel(summary?: FinishedPostworkSummary): string { + if (!summary) return 'Läuft' + + const runningStep = summary.steps.find((s) => s.state === 'running') + if (runningStep) return runningStep.label + + const queuedStep = summary.steps.find((s) => s.state === 'queued') + if (queuedStep) return queuedStep.label + + return 'Läuft' +} + +function expectedStepsForBadges(): Array<{ key: string; queue: 'postwork' | 'enrich'; phase: string }> { + return defaultPostworkStepDefs({ + includeAnalyze: true, + }) +} + +function defaultPostworkStepDefs(opts?: { includeAnalyze?: boolean }): Array<{ + key: string + queue: 'postwork' | 'enrich' + phase: string +}> { + const includeAnalyze = opts?.includeAnalyze ?? true + + const steps: Array<{ + key: string + queue: 'postwork' | 'enrich' + phase: string + }> = [ + { key: 'postwork:meta', queue: 'postwork', phase: 'meta' }, + { key: 'postwork:thumb', queue: 'postwork', phase: 'thumb' }, + { key: 'postwork:teaser', queue: 'postwork', phase: 'teaser' }, + { key: 'postwork:sprites', queue: 'postwork', phase: 'sprites' }, + ] + + if (includeAnalyze) { + steps.push({ key: 'enrich:analyze', queue: 'enrich', phase: 'analyze' }) + } + + return steps +} + +function buildMissingPostworkSummary( + file: string, + assetId?: string, + label = 'Kein Status' +): FinishedPostworkSummary | null { + if (!file) return null + + const steps: FinishedPostworkStepSummary[] = defaultPostworkStepDefs({ + includeAnalyze: true, + }).map((stepDef) => ({ + key: stepDef.key, + queue: stepDef.queue, + phase: stepDef.phase, + label: labelForStep(stepDef.queue, stepDef.phase), + state: 'missing', + ts: 0, + })) + + return { + file, + assetId, + state: 'missing', + label, + steps, + ts: 0, + } +} + +function hasNonEmptyObject(value: unknown): boolean { + return !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record).length > 0 +} + +function firstNonEmptyPath(...values: unknown[]): string { + for (const v of values) { + if (typeof v === 'string') { + const s = v.trim() + if (s) return s + } + } + return '' +} + +function readMetaVideoWidth(meta: any): number | undefined { + const v = + meta?.media?.video?.width ?? + meta?.media?.video?.w ?? + meta?.media?.width ?? + meta?.file?.video?.width ?? + meta?.file?.video?.w ?? + meta?.file?.width ?? + meta?.video?.width ?? + meta?.video?.w ?? + meta?.width + + return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined +} + +function readMetaVideoHeight(meta: any): number | undefined { + const v = + meta?.media?.video?.height ?? + meta?.media?.video?.h ?? + meta?.media?.height ?? + meta?.file?.video?.height ?? + meta?.file?.video?.h ?? + meta?.file?.height ?? + meta?.video?.height ?? + meta?.video?.h ?? + meta?.height + + return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined +} + +function readMetaDurationSeconds(meta: any): number | undefined { + const v = + meta?.media?.durationSeconds ?? + meta?.media?.durationSec ?? + meta?.media?.duration ?? + meta?.file?.durationSeconds ?? + meta?.file?.durationSec ?? + meta?.file?.duration ?? + meta?.durationSeconds ?? + meta?.durationSec ?? + meta?.duration + + return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined +} + +function readCompletedFlag(meta: any, ...keys: string[]): boolean { + const completed = meta?.postwork?.completed + + for (const key of keys) { + const v = + completed?.[key] ?? + meta?.postwork?.[key] ?? + meta?.preview?.[key] ?? + meta?.[key] + + if (v === true) return true + } + + return false +} + +function hasDoneHistoryForPhase( + meta: any, + queue: 'postwork' | 'enrich', + phase: string +): boolean { + const rawHistory = Array.isArray(meta?.postwork?.history) ? meta.postwork.history : [] + + return rawHistory.some((entry: any) => { + const queueRaw = String(entry?.queue ?? '').trim().toLowerCase() + const phaseRaw = normalizeFinishedPhase( + queueRaw === 'enrich' ? 'enrich' : 'postwork', + String(entry?.phase ?? '').trim().toLowerCase() + ) + const stateRaw = String(entry?.state ?? '').trim().toLowerCase() + + return queueRaw === queue && phaseRaw === phase && stateRaw === 'done' + }) +} + +function hasNonEmptyArray(value: unknown): boolean { + return Array.isArray(value) && value.length > 0 +} + +function buildCompletedPostworkSummaryFromMeta( + file: string, + assetId: string | undefined, + meta: any +): FinishedPostworkSummary | null { + if (!file || !meta || typeof meta !== 'object') return null + + const durationSeconds = readMetaDurationSeconds(meta) + const videoWidth = readMetaVideoWidth(meta) + const videoHeight = readMetaVideoHeight(meta) + + const thumbPath = firstNonEmptyPath( + meta?.preview?.thumb?.path, + meta?.preview?.thumb?.url, + meta?.preview?.thumb?.src, + meta?.preview?.thumbPath, + meta?.previewThumb?.path, + meta?.previewThumb?.url, + meta?.previewThumbPath, + meta?.thumbPath, + meta?.thumbUrl, + meta?.coverPath, + meta?.coverUrl + ) + + const spritePath = firstNonEmptyPath( + meta?.preview?.sprite?.path, + meta?.preview?.sprite?.url, + meta?.preview?.sprite?.src, + meta?.preview?.spritePath, + meta?.previewSprite?.path, + meta?.previewSprite?.url, + meta?.previewSpritePath, + meta?.spritePath, + meta?.spriteUrl + ) + + const clips = + Array.isArray(meta?.preview?.clips) ? meta.preview.clips + : Array.isArray(meta?.preview?.teaserClips) ? meta.preview.teaserClips + : Array.isArray(meta?.preview?.segments) ? meta.preview.segments + : Array.isArray(meta?.preview?.teaser?.clips) ? meta.preview.teaser.clips + : Array.isArray(meta?.previewClips) ? meta.previewClips + : Array.isArray(meta?.teaserClips) ? meta.teaserClips + : [] + + const teaserPath = firstNonEmptyPath( + meta?.preview?.teaser?.path, + meta?.preview?.teaser?.url, + meta?.preview?.teaser?.src, + meta?.preview?.teaserPath, + meta?.previewTeaser?.path, + meta?.previewTeaser?.url, + meta?.previewTeaserPath, + meta?.teaserPath, + meta?.teaserUrl + ) + + const aiObject = + meta?.analysis?.ai ?? + meta?.ai + + const hasAiHits = + Array.isArray(aiObject?.hits) && aiObject.hits.length > 0 + + const hasAiSegments = + Array.isArray(aiObject?.segments) && aiObject.segments.length > 0 + + const hasMeta = + readCompletedFlag(meta, 'meta') || + hasDoneHistoryForPhase(meta, 'postwork', 'meta') || + typeof durationSeconds === 'number' || + (typeof videoWidth === 'number' && typeof videoHeight === 'number') || + (typeof meta?.media?.video?.resolution === 'string' && meta.media.video.resolution.trim() !== '') || + (typeof meta?.file?.video?.resolution === 'string' && meta.file.video.resolution.trim() !== '') + + const hasTeaser = + readCompletedFlag(meta, 'teaser', 'assets') || + hasDoneHistoryForPhase(meta, 'postwork', 'teaser') || + teaserPath !== '' || + hasNonEmptyArray(clips) + + const hasThumb = + readCompletedFlag(meta, 'thumb', 'thumbnail', 'thumbnails') || + hasDoneHistoryForPhase(meta, 'postwork', 'thumb') || + meta?.preview?.thumb?.exists === true || + thumbPath !== '' + + const hasSprites = + readCompletedFlag(meta, 'sprites', 'sprite') || + hasDoneHistoryForPhase(meta, 'postwork', 'sprites') || + meta?.preview?.sprite?.exists === true || + spritePath !== '' || + ( + typeof meta?.preview?.sprite?.count === 'number' && + Number.isFinite(meta.preview.sprite.count) && + meta.preview.sprite.count > 0 + ) + + const hasAiAnalysis = + readCompletedFlag(meta, 'analyze', 'analysis', 'ai') || + hasDoneHistoryForPhase(meta, 'enrich', 'analyze') || + hasNonEmptyObject(aiObject) || + hasAiHits || + hasAiSegments + + const stepDefs = defaultPostworkStepDefs({ + includeAnalyze: true, + }) + + const steps: FinishedPostworkStepSummary[] = stepDefs.map((stepDef) => { + let state: FinishedPostworkState = 'missing' + + if (stepDef.key === 'postwork:meta') { + state = hasMeta ? 'done' : 'missing' + } else if (stepDef.key === 'postwork:teaser') { + state = hasTeaser ? 'done' : 'missing' + } else if (stepDef.key === 'postwork:thumb') { + state = hasThumb ? 'done' : 'missing' + } else if (stepDef.key === 'postwork:sprites') { + state = hasSprites ? 'done' : 'missing' + } else if (stepDef.key === 'enrich:analyze') { + state = hasAiAnalysis ? 'done' : 'missing' + } + + return { + key: stepDef.key, + queue: stepDef.queue, + phase: stepDef.phase, + label: labelForStep(stepDef.queue, stepDef.phase), + state, + ts: Number(meta?.updatedAtUnix ?? 0), + } + }) + + const doneCount = steps.filter((s) => s.state === 'done').length + const allDone = steps.length > 0 && steps.every((s) => s.state === 'done') + + return { + file, + assetId, + state: allDone ? 'done' : doneCount > 0 ? 'done' : 'missing', + label: allDone + ? 'Fertig' + : doneCount > 0 + ? `Teilweise fertig (${doneCount}/${steps.length})` + : 'Kein Status', + steps, + ts: Number(meta?.updatedAtUnix ?? 0), + } +} + +function buildPostworkSummaryFromMetaHistory( + file: string, + assetId: string | undefined, + meta: any +): FinishedPostworkSummary | null { + if (!file || !meta || typeof meta !== 'object') return null + + const rawHistory = Array.isArray(meta?.postwork?.history) ? meta.postwork.history : [] + if (rawHistory.length === 0) return null + + const badges: FinishedPostworkBadge[] = rawHistory + .map((entry: any): FinishedPostworkBadge | null => { + const queueRaw = String(entry?.queue ?? '').trim().toLowerCase() + const phase = normalizeFinishedPhase( + (queueRaw === 'enrich' ? 'enrich' : 'postwork'), + String(entry?.phase ?? '').trim().toLowerCase() + ) + const state = String(entry?.state ?? '').trim().toLowerCase() + + if ((queueRaw !== 'postwork' && queueRaw !== 'enrich') || !phase) return null + if ( + state !== 'queued' && + state !== 'running' && + state !== 'done' && + state !== 'error' && + state !== 'missing' + ) { + return null + } + + return { + file, + assetId, + queue: queueRaw as 'postwork' | 'enrich', + phase, + state: state as FinishedPostworkBadge['state'], + label: typeof entry?.label === 'string' ? entry.label : undefined, + position: typeof entry?.position === 'number' ? entry.position : undefined, + waiting: typeof entry?.waiting === 'number' ? entry.waiting : undefined, + running: typeof entry?.running === 'number' ? entry.running : undefined, + maxParallel: typeof entry?.maxParallel === 'number' ? entry.maxParallel : undefined, + ts: typeof entry?.ts === 'number' ? entry.ts : 0, + } + }) + .filter(Boolean) as FinishedPostworkBadge[] + + if (badges.length === 0) return null + return summarizePostworkSteps(file, badges) +} + +function isAggregateAssetsPhase( + queue: 'postwork' | 'enrich', + phase?: string +): boolean { + return queue === 'enrich' && String(phase ?? '').trim().toLowerCase() === 'assets' +} + +function normalizeFinishedPhase( + queue: 'postwork' | 'enrich', + phase?: string +): string { + const p = String(phase ?? '').trim().toLowerCase() + + if (queue === 'enrich') { + if (p === 'analyze') return 'analyze' + return p || 'analyze' + } + + switch (p) { + case 'postwork': + case 'probe': + case 'remuxing': + case 'moving': + return 'meta' + case 'assets': + return 'teaser' + case 'thumb': + return 'thumb' + case 'sprites': + return 'sprites' + default: + return p || 'meta' + } +} + +function summarizePostworkSteps( + file: string, + badges: FinishedPostworkBadge[] +): FinishedPostworkSummary | null { + if (!file) return null + + const realBadges = badges.filter((b) => b && b.state !== 'missing') + if (realBadges.length === 0) return null + + const expectedSteps = expectedStepsForBadges() + const byKey = new Map() + + let aggregateAssetsBadge: FinishedPostworkBadge | null = null + + for (const badge of realBadges) { + const queue = badge.queue + const rawPhase = String(badge.phase ?? '').trim().toLowerCase() + + // ✅ Sammelstatus "enrich:assets" NICHT als eigene Extra-Zeile anzeigen + // sondern später auf die bekannten Schritte verteilen. + if (isAggregateAssetsPhase(queue, rawPhase)) { + if (!aggregateAssetsBadge || (aggregateAssetsBadge.ts ?? 0) <= (badge.ts ?? 0)) { + aggregateAssetsBadge = badge + } + continue + } + + const phase = normalizeFinishedPhase(queue, rawPhase) + const key = `${queue}:${phase}` + + const prev = byKey.get(key) + if (prev && prev.ts > badge.ts) continue + + byKey.set(key, { + key, + queue, + phase, + label: labelForStep(queue, phase), + state: badge.state, + ts: badge.ts, + position: badge.position, + waiting: badge.waiting, + running: badge.running, + maxParallel: badge.maxParallel, + }) + } + + // ✅ Erst alle erwarteten Schritte anlegen + for (const stepDef of expectedSteps) { + if (byKey.has(stepDef.key)) continue + + byKey.set(stepDef.key, { + key: stepDef.key, + queue: stepDef.queue, + phase: stepDef.phase, + label: labelForStep(stepDef.queue, stepDef.phase), + state: 'missing', + ts: 0, + }) + } + + // ✅ Dann ggf. den Sammelstatus "assets" auf alle noch nicht fertigen Schritte abbilden + if (aggregateAssetsBadge) { + for (const stepDef of expectedSteps) { + const cur = byKey.get(stepDef.key) + if (!cur) continue + + // Bereits fertige Schritte beibehalten + if (cur.state === 'done') continue + + byKey.set(stepDef.key, { + ...cur, + state: aggregateAssetsBadge.state, + ts: Math.max(cur.ts || 0, aggregateAssetsBadge.ts || 0), + position: aggregateAssetsBadge.position, + waiting: aggregateAssetsBadge.waiting, + running: aggregateAssetsBadge.running, + maxParallel: aggregateAssetsBadge.maxParallel, + }) + } + } + + const order = expectedSteps.map((s) => s.key) + + const steps = Array.from(byKey.values()).sort((a, b) => { + const ai = order.indexOf(a.key) + const bi = order.indexOf(b.key) + + if (ai >= 0 && bi >= 0) return ai - bi + if (ai >= 0) return -1 + if (bi >= 0) return 1 + return a.key.localeCompare(b.key) + }) + + if (steps.length === 0) return null + + const visibleSteps = steps.filter((s) => s.state !== 'missing') + + const hasError = visibleSteps.some((s) => s.state === 'error') + const hasRunning = visibleSteps.some((s) => s.state === 'running') + const hasQueued = visibleSteps.some((s) => s.state === 'queued') + + const doneCount = steps.filter((s) => s.state === 'done').length + const allDone = steps.length > 0 && steps.every((s) => s.state === 'done') + const partiallyDone = doneCount > 0 && !allDone && !hasRunning && !hasQueued && !hasError + + const state: FinishedPostworkSummary['state'] = + hasError ? 'error' + : hasRunning ? 'running' + : hasQueued ? 'queued' + : allDone ? 'done' + : partiallyDone ? 'done' + : 'missing' + + const label = + hasError + ? 'Fehler' + : hasRunning + ? shortRunningLabel({ file, steps, state: 'running', label: '', ts: 0 }) + : hasQueued + ? shortRunningLabel({ file, steps, state: 'queued', label: '', ts: 0 }) + : allDone + ? 'Fertig' + : partiallyDone + ? `Teilweise fertig (${doneCount}/${steps.length})` + : 'Kein Status' + + const ts = Math.max(0, ...steps.map((s) => s.ts || 0)) + + return { + file, + assetId: realBadges.find((b) => b.assetId)?.assetId, + state, + label, + steps, + ts, + } +} + +function mergePostworkSummaries( + historySummary: FinishedPostworkSummary | null | undefined, + completedSummary: FinishedPostworkSummary | null | undefined, + fallbackFile: string, + fallbackAssetId?: string +): FinishedPostworkSummary | null { + if (!historySummary && !completedSummary) return null + if (!historySummary) return completedSummary ?? null + if (!completedSummary) return historySummary + + const stepMap = new Map() + + for (const step of completedSummary.steps) { + stepMap.set(step.key, step) + } + + for (const step of historySummary.steps) { + const prev = stepMap.get(step.key) + + if (!prev) { + stepMap.set(step.key, step) + continue + } + + // Live-/History-Zustände mit Aktivität/Fehler sollen sichtbar bleiben + if ( + step.state === 'running' || + step.state === 'queued' || + step.state === 'error' + ) { + stepMap.set(step.key, step) + continue + } + + // "done" aus History ist ok, aber nur wenn Completed nicht schon auch done liefert. + // Wenn Completed mehr Informationen hat, füllt es die restlichen Steps ohnehin. + if (step.state === 'done' && prev.state === 'missing') { + stepMap.set(step.key, step) + continue + } + + // missing aus History darf nie ein echtes Completed-Ergebnis überschreiben + } + + const orderedSteps = defaultPostworkStepDefs({ includeAnalyze: true }).map((def) => { + return ( + stepMap.get(def.key) ?? { + key: def.key, + queue: def.queue, + phase: def.phase, + label: labelForStep(def.queue, def.phase), + state: 'missing' as FinishedPostworkState, + ts: 0, + } + ) + }) + + const visibleSteps = orderedSteps.filter((s) => s.state !== 'missing') + const hasError = visibleSteps.some((s) => s.state === 'error') + const hasRunning = visibleSteps.some((s) => s.state === 'running') + const hasQueued = visibleSteps.some((s) => s.state === 'queued') + + const doneCount = orderedSteps.filter((s) => s.state === 'done').length + const allDone = orderedSteps.length > 0 && orderedSteps.every((s) => s.state === 'done') + const partiallyDone = doneCount > 0 && !allDone && !hasRunning && !hasQueued && !hasError + + const state: FinishedPostworkSummary['state'] = + hasError ? 'error' + : hasRunning ? 'running' + : hasQueued ? 'queued' + : allDone ? 'done' + : partiallyDone ? 'done' + : 'missing' + + const label = + hasError + ? 'Fehler' + : hasRunning + ? shortRunningLabel({ + file: historySummary.file || completedSummary.file || fallbackFile, + steps: orderedSteps, + state: 'running', + label: '', + ts: 0, + }) + : hasQueued + ? shortRunningLabel({ + file: historySummary.file || completedSummary.file || fallbackFile, + steps: orderedSteps, + state: 'queued', + label: '', + ts: 0, + }) + : allDone + ? 'Fertig' + : partiallyDone + ? `Teilweise fertig (${doneCount}/${orderedSteps.length})` + : 'Kein Status' + + const ts = Math.max( + historySummary.ts || 0, + completedSummary.ts || 0, + ...orderedSteps.map((s) => s.ts || 0) + ) + + return { + file: historySummary.file || completedSummary.file || fallbackFile, + assetId: historySummary.assetId || completedSummary.assetId || fallbackAssetId, + state, + label, + steps: orderedSteps, + ts, + } +} + export function renderFinishedPostworkBadge( - badge?: FinishedPostworkBadge, + summary?: FinishedPostworkSummary, opts?: { onClick?: () => void clickable?: boolean + disablePopover?: boolean } ): React.ReactNode { - if (!badge || badge.state === 'missing') return null - - const label = - String(badge.label ?? '').trim() || - (badge.state === 'running' - ? 'Wird verarbeitet' - : badge.state === 'queued' - ? 'Wartet' - : badge.state === 'done' - ? 'Fertig' - : badge.state === 'error' - ? 'Fehler' - : 'Status') + if (!summary) return null const clickable = Boolean(opts?.clickable && opts?.onClick) const className = [ 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1', - postworkBadgeTone(badge), + postworkBadgeTone(summary), clickable ? 'cursor-pointer transition-opacity hover:opacity-80' : '', ].join(' ') - if (clickable) { - return ( - + ) : ( + + {summary.state === 'running' ? ( + + ) : summary.state === 'queued' ? ( + + ) : summary.state === 'done' ? ( + + ) : summary.state === 'error' ? ( + + ) : summary.state === 'missing' ? ( + + ) : null} - {badge.state === 'queued' && typeof badge.position === 'number' && badge.position > 0 ? ( - #{badge.position} - ) : null} - - ) + {summary.label} + + ) + + if (opts?.disablePopover) { + return badgeNode } return ( - - {badge.state === 'running' ? ( - - ) : badge.state === 'queued' ? ( - - ) : badge.state === 'done' ? ( - - ) : badge.state === 'error' ? ( - - ) : null} + + open && ( +
+
+
+ {summary.state === 'missing' ? 'Postwork-Phasen' : 'Postwork-Status'} +
+
+ {summary.file} +
+
- {label} +
+ {summary.steps.map((step) => ( +
+
+ {step.label} +
- {badge.state === 'queued' && typeof badge.position === 'number' && badge.position > 0 ? ( - #{badge.position} - ) : null} - +
+ {step.state === 'running' ? ( + <> + + Läuft + + ) : step.state === 'queued' ? ( + <> + + + {typeof step.position === 'number' && step.position > 0 + ? `Wartet #${step.position}` + : 'Wartet'} + + + ) : step.state === 'done' ? ( + <> + + Fertig + + ) : step.state === 'error' ? ( + <> + + Fehler + + ) : step.state === 'missing' ? ( + <> + + Kein Status + + ) : ( + + )} +
+
+ ))} +
+
+ ) + } + > + {badgeNode} +
) } @@ -427,6 +1252,7 @@ export default function FinishedDownloads({ blurPreviews, teaserPlayback, teaserAudio, + pauseTeasers = false, onOpenPlayer, onDeleteJob, onToggleHot, @@ -447,14 +1273,14 @@ export default function FinishedDownloads({ loadMode = 'paged', }: Props) { - type ViewMode = 'table' | 'cards' | 'gallery' const VIEW_KEY = 'finishedDownloads_view' const KEEP_KEY = 'finishedDownloads_includeKeep_v2' const MOBILE_OPTS_KEY = 'finishedDownloads_mobileOptionsOpen_v1' const allMode = loadMode === 'all' - const teaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover' + const requestedTeaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover' + const teaserPlaybackMode: TeaserPlaybackMode = pauseTeasers ? 'still' : requestedTeaserPlaybackMode // Desktop vs Mobile: Desktop (hoverfähiger Pointer) -> echter Hover // Mobile: "hover" wie "all" behandeln @@ -468,7 +1294,14 @@ export default function FinishedDownloads({ const isCoarsePointer = useMediaQuery('(hover: none) and (pointer: coarse)') const isTabletOrSmaller = useMediaQuery('(max-width: 1024px)') - const forcePreviewMuted = isCoarsePointer || isTabletOrSmaller + const mobileNeedsTapForAudio = isCoarsePointer || isTabletOrSmaller + + const [mobileTeaserAudioUnlocked, setMobileTeaserAudioUnlocked] = React.useState(false) + + const forcePreviewMuted = + mobileNeedsTapForAudio && teaserAudio + ? !mobileTeaserAudioUnlocked + : false const notify = useNotify() @@ -495,14 +1328,9 @@ export default function FinishedDownloads({ // ✅ schützt gegen alte Effect-Instanzen / StrictMode-Cleanup / Race Conditions const refillSessionRef = React.useRef(0) - const [postworkByFile, setPostworkByFile] = React.useState>({}) + const [postworkByFile, setPostworkByFile] = React.useState>({}) const [undoing, setUndoing] = React.useState(false) - - type SelectedItem = { - key: string - file: string - } const [selectedItems, setSelectedItems] = React.useState>({}) const [bulkBusy, setBulkBusy] = React.useState(false) @@ -540,17 +1368,6 @@ export default function FinishedDownloads({ [effectiveTeaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio] ) - type UndoAction = - | { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep' } - | { kind: 'keep'; keptFile: string; originalFile: string; rowKey?: string } - | { kind: 'hot'; currentFile: string } - - type PersistedUndoState = { - v: 1 - action: UndoAction - ts: number - } - const LAST_UNDO_KEY = 'finishedDownloads_lastUndo_v1' const selectedKeys = useMemo(() => new Set(Object.keys(selectedItems)), [selectedItems]) @@ -641,6 +1458,14 @@ export default function FinishedDownloads({ setHoverTeaserKey((prev) => (prev === nextKey ? prev : nextKey)) }, [canHover]) + useEffect(() => { + if (!pauseTeasers) return + + setTeaserKey(null) + setHoverTeaserKey(null) + setInlinePlay(null) + }, [pauseTeasers]) + useEffect(() => { let alive = true @@ -660,15 +1485,17 @@ export default function FinishedDownloads({ if (regen?.running) { setPostworkByFile((prev) => ({ ...prev, - [file]: { - file, - assetId: fileStemFromOutput(file), - queue: 'enrich', - state: 'running', - phase: 'assets', - label: String(regen?.text || 'Wird verarbeitet'), - ts: Date.now(), - }, + [file]: [ + { + file, + assetId: fileStemFromOutput(file), + queue: 'enrich', + state: 'running', + phase: 'assets', + label: 'Vorschaubild', + ts: Date.now(), + }, + ], })) return } @@ -676,32 +1503,62 @@ export default function FinishedDownloads({ if (regen?.error) { setPostworkByFile((prev) => ({ ...prev, - [file]: { - file, - assetId: fileStemFromOutput(file), - queue: 'enrich', - state: 'error', - phase: 'assets', - label: 'Fehler', - ts: Date.now(), - }, + [file]: [ + { + file, + assetId: fileStemFromOutput(file), + queue: 'enrich', + state: 'error', + phase: 'assets', + label: 'Fehler', + ts: Date.now(), + }, + ], })) return } + 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() + + 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(), + }, + ], + })) + } + // 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(), - }, + [file]: [ + { + file, + assetId: fileStemFromOutput(file), + queue: 'enrich', + state: 'done', + phase: 'assets', + label: 'Fertig', + ts: Date.now(), + }, + ], })) } } catch { @@ -1289,7 +2146,7 @@ export default function FinishedDownloads({ const k = keyFor(job) const durationSec = durations[k] ?? - ((job as any)?.durationSeconds as number | undefined) ?? + ((job as any)?.meta?.file?.durationSeconds as number | undefined) ?? 0 if (!Number.isFinite(durationSec) || durationSec <= 0) { @@ -1501,37 +2358,6 @@ export default function FinishedDownloads({ [releasePlayingFile] ) - type FileMutationKind = 'delete' | 'keep' | 'rename' - - type RunFileMutationOptions = { - kind: FileMutationKind - job: RecordJob - file: string - rowKey: string - - // UI / State - setBusy?: (v: boolean) => void - isBusyNow?: () => boolean - optimisticRemove?: boolean - alreadyRemoved?: boolean - - // Ausführung - run: () => Promise - - // Hooks - onSuccess?: (result: T) => Promise | void - onError?: (err: unknown) => Promise | void - - // Messages - labels: { - invalidTitle: string - invalidBody: string - inUseTitle: string - failTitle: string - failPrefix?: string - } - } - const runFileMutation = useCallback( async (opts: RunFileMutationOptions): Promise<{ ok: boolean; result?: T }> => { const { @@ -1991,6 +2817,21 @@ export default function FinishedDownloads({ const cleanFile = String(file || '').trim() if (!cleanFile) return false + setPostworkByFile((prev) => ({ + ...prev, + [cleanFile]: [ + { + file: cleanFile, + assetId: fileStemFromOutput(cleanFile), + queue: 'enrich', + state: 'running', + phase: 'assets', + label: 'Wird verarbeitet…', + ts: Date.now(), + }, + ], + })) + try { const res = await fetch('/api/record/regenerate-assets', { method: 'POST', @@ -2002,8 +2843,24 @@ export default function FinishedDownloads({ const text = await res.text().catch(() => '') throw new Error(text || `HTTP ${res.status}`) } + return true } catch (e: any) { + setPostworkByFile((prev) => ({ + ...prev, + [cleanFile]: [ + { + file: cleanFile, + assetId: fileStemFromOutput(cleanFile), + queue: 'enrich', + state: 'error', + phase: 'assets', + label: 'Fehler', + ts: Date.now(), + }, + ], + })) + notify.error( 'Assets neu generieren fehlgeschlagen', e?.message ? String(e.message) : String(e) @@ -2049,7 +2906,13 @@ export default function FinishedDownloads({ const k = keyFor(jj) if (map.has(k)) { - map.set(k, { ...map.get(k)!, ...jj }) + const existing = map.get(k)! + + map.set(k, { + ...existing, + ...jj, + meta: mergeMetaPreferDone((existing as any)?.meta, (jj as any)?.meta), + }) continue } @@ -2059,7 +2922,11 @@ export default function FinishedDownloads({ for (const [existingKey, existingJob] of map.entries()) { if (baseName(existingJob.output || '') === file) { - map.set(existingKey, { ...existingJob, ...jj }) + map.set(existingKey, { + ...existingJob, + ...jj, + meta: mergeMetaPreferDone((existingJob as any)?.meta, (jj as any)?.meta), + }) break } } @@ -2105,32 +2972,59 @@ export default function FinishedDownloads({ - const effectivePostworkByFile = useMemo>(() => { - const next: Record = { ...postworkByFile } + const effectivePostworkSummaryByFile = useMemo>(() => { + const next: Record = {} + // 1) Erst Live-Zustände sammeln + for (const [file, badges] of Object.entries(postworkByFile)) { + const summary = summarizePostworkSteps(file, badges) + if (summary) { + next[file] = summary + } + } + + // 2) Dann für jede sichtbare Datei Meta auswerten und ggf. Live-Zustand überschreiben for (const job of rows) { - const file = baseName(job.output || '') - if (!file) continue + const fileRaw = baseName(job.output || '') + const filePlain = stripHotPrefix(fileRaw) - const existing = next[file] + if (!fileRaw) continue - // Echte Live-/Queue-Badges haben Vorrang - if (existing && existing.state !== 'missing') { - continue - } + const rawMeta = (job as any)?.meta + const meta = parseMetaObject(rawMeta) + const assetId = fileStemFromOutput(job.output) - if (!jobHasFinishedAssets(job)) { - continue - } + const summaryFromHistory = buildPostworkSummaryFromMetaHistory(fileRaw, assetId, meta) + const summaryFromCompleted = + buildCompletedPostworkSummaryFromMeta(fileRaw, assetId, meta) - next[file] = { - file, - assetId: fileStemFromOutput(job.output), - queue: 'enrich', - state: 'done', - phase: 'assets', - label: 'Fertig', - ts: Date.now(), + const mergedMetaSummary = + mergePostworkSummaries( + summaryFromHistory, + summaryFromCompleted, + fileRaw, + assetId + ) + + const metaSummary = + mergedMetaSummary ?? + buildMissingPostworkSummary(fileRaw, assetId, 'Kein Status') + + const liveSummary = next[fileRaw] ?? next[filePlain] + + const summary = + mergePostworkSummaries( + liveSummary ?? null, + metaSummary ?? null, + fileRaw, + assetId + ) ?? metaSummary ?? liveSummary ?? null + + if (summary) { + next[fileRaw] = summary + if (filePlain && filePlain !== fileRaw) { + next[filePlain] = summary + } } } @@ -2158,20 +3052,45 @@ export default function FinishedDownloads({ if (!file) return setPostworkByFile((prev) => { - const cur = prev[file] - const nextTs = Number(detail?.ts ?? 0) - const curTs = Number(cur?.ts ?? 0) - - if (cur && curTs > nextTs) return prev - const next = { ...prev } if (detail.state === 'missing') { - delete next[file] + const cur = Array.isArray(prev[file]) ? prev[file] : [] + const missingKey = `${detail.queue}:${String(detail.phase ?? '').trim().toLowerCase() || 'postwork'}` + const filtered = cur.filter((item) => { + const itemKey = `${item.queue}:${String(item.phase ?? '').trim().toLowerCase() || 'postwork'}` + return itemKey !== missingKey + }) + + if (filtered.length === 0) { + delete next[file] + } else { + next[file] = filtered + } + return next } - next[file] = detail + const incoming: FinishedPostworkBadge = { + ...detail, + label: + String(detail?.label ?? '').trim() === file + ? undefined + : detail?.label, + } + + const cur = Array.isArray(prev[file]) ? prev[file] : [] + const key = `${incoming.queue}:${String(incoming.phase ?? '').trim().toLowerCase() || 'postwork'}` + + let replaced = false + const merged = cur.map((item) => { + const itemKey = `${item.queue}:${String(item.phase ?? '').trim().toLowerCase() || 'postwork'}` + if (itemKey !== key) return item + replaced = true + return (item.ts ?? 0) > (incoming.ts ?? 0) ? item : incoming + }) + + next[file] = replaced ? merged : [...merged, incoming] return next }) } @@ -2217,6 +3136,9 @@ export default function FinishedDownloads({ useEffect(() => { const onReload = () => { + // Nur reagieren, wenn FinishedDownloads wirklich sichtbar/mounted ist + if (document.hidden) return + if (refillInFlightRef.current) { if (!refillQueuedWhileInFlightRef.current) { refillQueuedWhileInFlightRef.current = true @@ -2380,7 +3302,9 @@ export default function FinishedDownloads({ const k = keyFor(job) // ✅ 1) echte Videodauer (Preview-Metadaten oder Backend durationSeconds) - const sec = durations[k] ?? (job as any)?.durationSeconds + const meta = parseMetaObject((job as any)?.meta) + const sec = durations[k] ?? metaDurationSeconds(meta) + if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) { return formatDuration(sec * 1000) } @@ -2704,7 +3628,7 @@ export default function FinishedDownloads({ id="finished-sort" value={sortMode} onChange={(e) => { - const m = e.target.value as SortMode + const m = e.target.value as DoneSortMode onSortModeChange(m) if (page !== 1) onPageChange(1) }} @@ -3104,7 +4028,7 @@ export default function FinishedDownloads({ id="finished-sort-mobile" value={sortMode} onChange={(e) => { - const m = e.target.value as SortMode + const m = e.target.value as DoneSortMode onSortModeChange(m) if (page !== 1) onPageChange(1) }} @@ -3276,13 +4200,18 @@ export default function FinishedDownloads({ onToggleTagFilter={toggleTagFilter} onHoverPreviewKeyChange={setHoverTeaserKey} assetNonce={assetNonce ?? 0} - postworkByFile={effectivePostworkByFile} + postworkByFile={effectivePostworkSummaryByFile} + getPostworkSummaryForOutput={getPostworkSummaryForOutput} forcePreviewMuted={forcePreviewMuted} + mobileTeaserAudioUnlocked={mobileTeaserAudioUnlocked} + onUnlockMobileTeaserAudio={() => setMobileTeaserAudioUnlocked(true)} renderPostworkBadge={(badge) => renderFinishedPostworkBadge(badge, { clickable: true, + disablePopover: isSmall, onClick: () => { if (!badge?.file) return + if (badge.state === 'running' || badge.state === 'queued') return void regenerateAssetsForFile(badge.file) }, }) @@ -3338,13 +4267,16 @@ export default function FinishedDownloads({ onAddToDownloads={onAddToDownloads} deleteVideo={deleteVideo} keepVideo={keepVideo} - postworkByFile={effectivePostworkByFile} + postworkByFile={effectivePostworkSummaryByFile} + getPostworkSummaryForOutput={getPostworkSummaryForOutput} forcePreviewMuted={forcePreviewMuted} renderPostworkBadge={(badge) => renderFinishedPostworkBadge(badge, { clickable: true, + disablePopover: isSmall, onClick: () => { if (!badge?.file) return + if (badge.state === 'running' || badge.state === 'queued') return void regenerateAssetsForFile(badge.file) }, }) @@ -3393,13 +4325,16 @@ export default function FinishedDownloads({ activeTagSet={activeTagSet} onToggleTagFilter={toggleTagFilter} onHoverPreviewKeyChange={setHoverTeaserKey} - postworkByFile={effectivePostworkByFile} + postworkByFile={effectivePostworkSummaryByFile} + getPostworkSummaryForOutput={getPostworkSummaryForOutput} forcePreviewMuted={forcePreviewMuted} renderPostworkBadge={(badge) => renderFinishedPostworkBadge(badge, { clickable: true, + disablePopover: isSmall, onClick: () => { if (!badge?.file) return + if (badge.state === 'running' || badge.state === 'queued') return void regenerateAssetsForFile(badge.file) }, }) diff --git a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx index 0f5b2ab..869387e 100644 --- a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx @@ -3,7 +3,12 @@ import * as React from 'react' import Card from './Card' -import type { RecordJob } from '../../types' +import type { + RecordJob, + FinishedPostworkSummary, + GalleryModelFlags, + InlinePlayState +} from '../../types' import FinishedVideoPreview from './FinishedVideoPreview' import SwipeCard, { type SwipeCardHandle } from './SwipeCard' import RecordJobActions from './RecordJobActions' @@ -17,8 +22,13 @@ import { shouldEnableTeaserAudio, } from './teaserPlayback' import Checkbox from './Checkbox' - -type InlinePlayState = { key: string; nonce: number } | null +import { SpeakerWaveIcon } from '@heroicons/react/24/solid' +import { + buildSpriteFrameStyle, + parseJobMeta, + readPreviewSpriteInfo, + scrubProgressRatioFromIndex, +} from './previewSprite' type Props = { rows: RecordJob[] @@ -70,15 +80,7 @@ type Props = { releasePlayingFile: (file: string, opts?: { close?: boolean }) => Promise - modelsByKey: Record + modelsByKey: Record activeTagSet: Set onToggleTagFilter: (tag: string) => void onToggleHot?: (job: RecordJob) => void | Promise @@ -88,35 +90,17 @@ type Props = { onSplit?: (job: RecordJob) => void | Promise onAddToDownloads?: (job: RecordJob) => void | Promise - postworkByFile: Record + postworkByFile: Record + getPostworkSummaryForOutput: ( + output: string | undefined, + summaries: Record + ) => FinishedPostworkSummary | undefined - renderPostworkBadge?: (badge?: { - file: string - assetId?: string - queue: 'postwork' | 'enrich' - state: 'queued' | 'running' | 'done' | 'error' | 'missing' - phase?: string - label?: string - position?: number - waiting?: number - running?: number - maxParallel?: number - ts: number - }) => React.ReactNode + renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode forcePreviewMuted?: boolean + mobileTeaserAudioUnlocked?: boolean + onUnlockMobileTeaserAudio?: () => void } const parseTags = (raw?: string): string[] => { @@ -138,58 +122,6 @@ const parseTags = (raw?: string): string[] => { return out } -function firstNonEmptyString(...values: unknown[]): string | undefined { - for (const v of values) { - if (typeof v === 'string') { - const s = v.trim() - if (s) return s - } - } - return undefined -} - -function normalizeDurationSeconds(value: unknown): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined - // ms -> s Heuristik wie in GalleryView / FinishedVideoPreview - return value > 24 * 60 * 60 ? value / 1000 : value -} - -function clamp(n: number, min: number, max: number) { - return Math.max(min, Math.min(max, n)) -} - -const DEFAULT_SPRITE_STEP_SECONDS = 5 - -function chooseSpriteGrid(count: number): [number, number] { - if (count <= 1) return [1, 1] - - const targetRatio = 16 / 9 - let bestCols = 1 - let bestRows = count - let bestWaste = Number.POSITIVE_INFINITY - let bestRatioScore = Number.POSITIVE_INFINITY - - for (let c = 1; c <= count; c++) { - const r = Math.max(1, Math.ceil(count / c)) - const waste = c * r - count - const ratio = c / r - const ratioScore = Math.abs(ratio - targetRatio) - - if ( - waste < bestWaste || - (waste === bestWaste && ratioScore < bestRatioScore) || - (waste === bestWaste && ratioScore === bestRatioScore && r < bestRows) - ) { - bestWaste = waste - bestRatioScore = ratioScore - bestCols = c - bestRows = r - } - } - - return [bestCols, bestRows] -} - function CardBlurWrapper({ blurred, animateUnblurOnMount, @@ -420,88 +352,49 @@ export default function FinishedDownloadsCardsView({ onSplit, onAddToDownloads, postworkByFile, + getPostworkSummaryForOutput, renderPostworkBadge, forcePreviewMuted, + mobileTeaserAudioUnlocked, + onUnlockMobileTeaserAudio, }: Props) { const hasAnySelection = selectedKeys.size > 0 const footerTapRef = React.useRef>({}) + const teaserPlayFnsRef = React.useRef void) | null>>({}) + + const [speedHoldKey, setSpeedHoldKey] = React.useState(null) + + const registerTeaserPlayForKey = React.useCallback( + (key: string) => (fn: (() => void) | null) => { + if (fn) teaserPlayFnsRef.current[key] = fn + else delete teaserPlayFnsRef.current[key] + }, + [] + ) + const parseMeta = React.useCallback((j: RecordJob): any | null => { - const raw: any = (j as any)?.meta - if (!raw) return null - if (typeof raw === 'string') { - try { - return JSON.parse(raw) - } catch { - return null - } - } - return raw + return parseJobMeta((j as any)?.meta) }, []) const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => { const meta = parseMeta(j) const w = - (typeof meta?.videoWidth === 'number' && Number.isFinite(meta.videoWidth) ? meta.videoWidth : 0) || - (typeof j.videoWidth === 'number' && Number.isFinite(j.videoWidth) ? j.videoWidth : 0) + typeof meta?.file?.video?.width === 'number' && Number.isFinite(meta.file.video.width) + ? meta.file.video.width + : 0 const h = - (typeof meta?.videoHeight === 'number' && Number.isFinite(meta.videoHeight) ? meta.videoHeight : 0) || - (typeof j.videoHeight === 'number' && Number.isFinite(j.videoHeight) ? j.videoHeight : 0) + typeof meta?.file?.video?.height === 'number' && Number.isFinite(meta.file.video.height) + ? meta.file.video.height + : 0 if (w > 0 && h > 0) return { w, h } return null }, [parseMeta]) - const previewScrubberInfoOf = React.useCallback( - (j: RecordJob): { count: number; stepSeconds: number } | null => { - const meta = parseMeta(j) - - let ps: any = - meta?.previewSprite ?? - meta?.preview?.sprite ?? - meta?.sprite ?? - (j as any)?.previewSprite ?? - (j as any)?.preview?.sprite ?? - null - - if (typeof ps === 'string') { - try { - ps = JSON.parse(ps) - } catch { - ps = null - } - } - - const countRaw = Number(ps?.count ?? ps?.frames ?? ps?.imageCount) - const stepRaw = Number(ps?.stepSeconds ?? ps?.step ?? ps?.intervalSeconds) - - if (Number.isFinite(countRaw) && countRaw >= 2) { - return { - count: Math.max(2, Math.floor(countRaw)), - stepSeconds: Number.isFinite(stepRaw) && stepRaw > 0 ? stepRaw : 5, - } - } - - // Fallback: aus Dauer ableiten - const k = keyFor(j) - const dur = - durations[k] ?? - (typeof (j as any)?.durationSeconds === 'number' ? (j as any).durationSeconds : undefined) - - if (typeof dur === 'number' && Number.isFinite(dur) && dur > 1) { - const stepSeconds = 5 - const count = Math.max(2, Math.min(240, Math.floor(dur / stepSeconds) + 1)) - return { count, stepSeconds } - } - - return null - }, - [parseMeta, keyFor, durations] - ) - const [scrubActiveByKey, setScrubActiveByKey] = React.useState>({}) const [scrubHoveringByKey, setScrubHoveringByKey] = React.useState>({}) @@ -566,13 +459,16 @@ export default function FinishedDownloadsCardsView({ disabled: Boolean(opts?.forceStill), }) + const isMobileTopCard = Boolean(isSmall && opts?.mobileStackTopOnlyVideo) + const previewMuted = forcePreviewMuted ? true : ( isSmall && opts?.mobileStackTopOnlyVideo - ? true + ? teaserAudioEnabledForKey !== k : !allowSound ) + const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0 const forceLoadStill = Boolean(opts?.forceLoadStill) @@ -595,133 +491,33 @@ export default function FinishedDownloadsCardsView({ const model = modelNameFromOutput(j.output) const fileRaw = baseName(j.output || '') - const livePostwork = - postworkByFile[fileRaw] ?? - postworkByFile[stripHotPrefix(fileRaw)] - const previewId = stripHotPrefix(fileRaw.replace(/\.[^.]+$/, '')).trim() + const livePostwork = getPostworkSummaryForOutput(j.output, postworkByFile) const modelKey = lower(model) const flags = modelsByKey[modelKey] - - const meta = parseMeta(j) - const spriteInfo = previewScrubberInfoOf(j) const scrubActiveIndex = scrubActiveByKey[k] const scrubHovering = scrubHoveringByKey[k] === true - // ✅ Sprite-Quelle wie in GalleryView (1 Request, danach nur CSS background-position) - const spritePathRaw = firstNonEmptyString( - meta?.previewSprite?.path, - (meta as any)?.previewSpritePath, - flags?.previewScrubberPath, - previewId ? `/api/preview-sprite/${encodeURIComponent(previewId)}` : undefined - ) - const spritePath = spritePathRaw ? spritePathRaw.replace(/\/+$/, '') : undefined + const sprite = readPreviewSpriteInfo((j as any)?.meta, { + fallbackPath: flags?.previewScrubberPath, + fallbackCount: flags?.previewScrubberCount, + versionFallback: assetNonce ?? 0, + }) - const spriteStepSecondsRaw = - meta?.previewSprite?.stepSeconds ?? (meta as any)?.previewSpriteStepSeconds + const spriteUrl = sprite.url + const hasScrubberUi = sprite.hasScrubberUi + const hasSpriteScrubber = sprite.hasSpriteScrubber + const scrubberCount = hasScrubberUi ? sprite.count : 0 + const scrubberStepSeconds = hasScrubberUi ? sprite.stepSeconds : 0 - const spriteStepSeconds = - typeof spriteStepSecondsRaw === 'number' && - Number.isFinite(spriteStepSecondsRaw) && - spriteStepSecondsRaw > 0 - ? spriteStepSecondsRaw - : (spriteInfo?.stepSeconds && spriteInfo.stepSeconds > 0 - ? spriteInfo.stepSeconds - : DEFAULT_SPRITE_STEP_SECONDS) + const scrubProgressRatio = scrubProgressRatioFromIndex(scrubActiveIndex, scrubberCount) - // Dauer fallback (für count-Inferenz) - const durationForSprite = - normalizeDurationSeconds(meta?.durationSeconds) ?? - normalizeDurationSeconds((j as any)?.durationSeconds) ?? - normalizeDurationSeconds(durations[k]) - - const inferredSpriteCountFromDuration = - typeof durationForSprite === 'number' && durationForSprite > 0 - ? Math.max(1, Math.min(200, Math.floor(durationForSprite / spriteStepSeconds) + 1)) - : undefined - - const spriteCountRaw = - meta?.previewSprite?.count ?? - (meta as any)?.previewSpriteCount ?? - flags?.previewScrubberCount ?? - inferredSpriteCountFromDuration - - const spriteColsRaw = meta?.previewSprite?.cols ?? (meta as any)?.previewSpriteCols - const spriteRowsRaw = meta?.previewSprite?.rows ?? (meta as any)?.previewSpriteRows - - const spriteCount = - typeof spriteCountRaw === 'number' && Number.isFinite(spriteCountRaw) - ? Math.max(0, Math.floor(spriteCountRaw)) - : 0 - - const [inferredCols, inferredRows] = - spriteCount > 1 ? chooseSpriteGrid(spriteCount) : [0, 0] - - // ✅ explizite Werte robust lesen (auch wenn mal String kommt) - const explicitSpriteCols = Number(spriteColsRaw) - const explicitSpriteRows = Number(spriteRowsRaw) - - // ✅ Nur übernehmen, wenn plausibel. - // Verhindert den Fall cols=1/rows=1 bei count>1 (zeigt sonst ganze Sprite-Map) - const explicitGridLooksValid = - Number.isFinite(explicitSpriteCols) && - Number.isFinite(explicitSpriteRows) && - explicitSpriteCols > 0 && - explicitSpriteRows > 0 && - !(spriteCount > 1 && explicitSpriteCols === 1 && explicitSpriteRows === 1) && - (spriteCount <= 1 || explicitSpriteCols * explicitSpriteRows >= spriteCount) - - const spriteCols = explicitGridLooksValid ? Math.floor(explicitSpriteCols) : inferredCols - const spriteRows = explicitGridLooksValid ? Math.floor(explicitSpriteRows) : inferredRows - - const spriteVersion = - (typeof meta?.updatedAtUnix === 'number' && Number.isFinite(meta.updatedAtUnix) - ? meta.updatedAtUnix - : undefined) ?? - (typeof (meta as any)?.fileModUnix === 'number' && Number.isFinite((meta as any).fileModUnix) - ? (meta as any).fileModUnix - : undefined) ?? - (assetNonce ?? 0) - - const spriteUrl = - spritePath && spriteVersion - ? `${spritePath}?v=${encodeURIComponent(String(spriteVersion))}` - : spritePath || undefined - - const hasScrubberUi = - Boolean(spriteUrl) && - spriteCount > 1 - - const hasSpriteScrubber = - hasScrubberUi && - spriteCols > 0 && - spriteRows > 0 - - const scrubberCount = hasScrubberUi ? spriteCount : 0 - const scrubberStepSeconds = hasScrubberUi ? spriteStepSeconds : 0 - - const scrubProgressRatio = - typeof scrubActiveIndex === 'number' && scrubberCount > 1 - ? clamp(scrubActiveIndex / (scrubberCount - 1), 0, 1) - : undefined - - const spriteFrameStyle: React.CSSProperties | undefined = - hasSpriteScrubber && typeof scrubActiveIndex === 'number' - ? (() => { - const idx = clamp(scrubActiveIndex, 0, Math.max(0, spriteCount - 1)) - const col = idx % spriteCols - const row = Math.floor(idx / spriteCols) - - const posX = spriteCols <= 1 ? 0 : (col / (spriteCols - 1)) * 100 - const posY = spriteRows <= 1 ? 0 : (row / (spriteRows - 1)) * 100 - - return { - backgroundImage: `url("${spriteUrl}")`, - backgroundRepeat: 'no-repeat', - backgroundSize: `${spriteCols * 100}% ${spriteRows * 100}%`, - backgroundPosition: `${posX}% ${posY}%`, - } - })() - : undefined + const spriteFrameStyle = buildSpriteFrameStyle({ + spriteUrl, + spriteCols: sprite.cols, + spriteRows: sprite.rows, + spriteCount: sprite.count, + activeIndex: scrubActiveIndex, + }) const isHot = isHotName(fileRaw) const isFav = Boolean(flags?.favorite) @@ -747,9 +543,17 @@ export default function FinishedDownloadsCardsView({ allowTeaserAnimation ? 'anim' : 'still', ].join('::') + const showMobileAudioButton = + Boolean(onUnlockMobileTeaserAudio) && + isMobileTopCard && + !inlineActive && + !opts?.forceStill && + allowTeaserAnimation && + audioButtonDismissedForKey !== k + // ✅ Shell an Gallery angelehnt const shellCls = [ - 'group relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10', + 'group relative rounded-lg overflow-visible outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10', 'bg-white dark:bg-gray-900/40', 'transition-all duration-200', !isSmall && 'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none', @@ -901,7 +705,7 @@ export default function FinishedDownloadsCardsView({ getFileName={(p) => stripHotPrefix(baseName(p))} className="h-full w-full" variant="fill" - durationSeconds={durations[k] ?? (j as any)?.durationSeconds} + durationSeconds={durations[k] ?? (j as any)?.meta?.file?.durationSeconds} onDuration={handleDuration} showPopover={false} blur={inlineActive ? false : Boolean(blurPreviews)} @@ -934,7 +738,33 @@ export default function FinishedDownloadsCardsView({ ? mobileTopActivationNonce : 0 } + registerTeaserPlay={registerTeaserPlayForKey(k)} + holdSpeedActive={speedHoldKey === k} /> + + {showMobileAudioButton ? ( + + ) : null} {/* ✅ Sprite einmal vorladen, damit der erste Scrub-Move sofort sichtbar ist */} {hasSpriteScrubber && spriteUrl ? ( @@ -1010,7 +840,7 @@ export default function FinishedDownloadsCardsView({ {/* Footer / Meta (wie Gallery strukturiert) */}
{ footerTapRef.current[k] = true }} @@ -1028,7 +858,7 @@ export default function FinishedDownloadsCardsView({
{renderPostworkBadge && livePostwork ? ( -
+
{renderPostworkBadge(livePostwork)}
) : null} @@ -1105,6 +935,8 @@ export default function FinishedDownloadsCardsView({ isHot, inlineDomId, cardInner, + inlineActive, + allowTeaserAnimation, } } @@ -1127,6 +959,21 @@ export default function FinishedDownloadsCardsView({ const [mobileTopTapEnabled, setMobileTopTapEnabled] = React.useState(true) const mobileTopTapEnableTimerRef = React.useRef(null) + const [audioButtonDismissedForKey, setAudioButtonDismissedForKey] = React.useState(null) + const [teaserAudioEnabledForKey, setTeaserAudioEnabledForKey] = React.useState(null) + + React.useEffect(() => { + if (!isSmall) return + if (!mobileTopKey) return + if (teaserAudioEnabledForKey !== mobileTopKey) return + if (!mobileTeaserAudioUnlocked) return + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + teaserPlayFnsRef.current[mobileTopKey]?.() + }) + }) + }, [isSmall, mobileTopKey, teaserAudioEnabledForKey, mobileTeaserAudioUnlocked]) React.useEffect(() => { if (mobileTopTeaserEnableTimerRef.current != null) { @@ -1139,6 +986,9 @@ export default function FinishedDownloadsCardsView({ mobileTopTapEnableTimerRef.current = null } + setAudioButtonDismissedForKey(null) + setTeaserAudioEnabledForKey(null) + if (!isSmall || !mobileTopKey) { setMobileTopTeaserEnabled(true) setMobileTopTapEnabled(true) @@ -1301,7 +1151,7 @@ export default function FinishedDownloadsCardsView({ {/* Oberste Karte im Flow -> bestimmt die echte Höhe */} {topRow ? (() => { const j = topRow - const { k, busy, isHot, cardInner, inlineDomId } = renderCardItem(j, { + const { k, busy, isHot, cardInner, inlineDomId, inlineActive, allowTeaserAnimation } = renderCardItem(j, { forceLoadStill: false, forceStill: !mobileTopTeaserEnabled, mobileStackTopOnlyVideo: true, @@ -1327,6 +1177,8 @@ export default function FinishedDownloadsCardsView({ ignoreFromBottomPx={110} doubleTapMs={360} doubleTapMaxMovePx={48} + pressDelayMs={220} + enablePressHold={!inlineActive && mobileTopTeaserEnabled && allowTeaserAnimation && !busy} onDoubleTap={async () => { if (isHot) return await onToggleHot?.(j) @@ -1360,6 +1212,13 @@ export default function FinishedDownloadsCardsView({ setMobileTopTeaserEnabled(false) return await keepVideo(j) }} + onPressStart={(info) => { + if (info.pointerType === 'mouse' && info.button !== 0) return + setSpeedHoldKey(k) + }} + onPressEnd={() => { + setSpeedHoldKey((prev) => (prev === k ? null : prev)) + }} > {cardInner} diff --git a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx index fa8a04e..418bbc0 100644 --- a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx @@ -2,7 +2,11 @@ 'use client' import * as React from 'react' -import type { RecordJob } from '../../types' +import type { + RecordJob, + GalleryModelFlags, + FinishedPostworkSummary, +} from '../../types' import FinishedVideoPreview from './FinishedVideoPreview' import RecordJobActions from './RecordJobActions' import TagOverflowRow from './TagOverflowRow' @@ -16,35 +20,13 @@ import { shouldObserveTeasers, } from './teaserPlayback' import Checkbox from './Checkbox' - -type ModelFlags = { - favorite?: boolean - liked?: boolean | null - watching?: boolean | null - tags?: string - - // ✅ Optional für Model-Hover-Preview - image?: string - imageUrl?: string | null - - // ✅ Optional für stashapp-artigen Preview-Scrubber - previewScrubberPath?: string - previewScrubberCount?: number -} - -type FinishedPostworkBadge = { - file: string - assetId?: string - queue: 'postwork' | 'enrich' - state: 'queued' | 'running' | 'done' | 'error' | 'missing' - phase?: string - label?: string - position?: number - waiting?: number - running?: number - maxParallel?: number - ts: number -} +import { + buildSpriteFrameStyle, + parseJobMeta, + firstNonEmptyString, + readPreviewSpriteInfo, + scrubProgressRatioFromIndex, +} from './previewSprite' type Props = { rows: RecordJob[] @@ -54,9 +36,13 @@ type Props = { isLoading?: boolean blurPreviews?: boolean durations: Record - postworkByFile: Record + postworkByFile: Record + getPostworkSummaryForOutput: ( + output: string | undefined, + summaries: Record + ) => FinishedPostworkSummary | undefined teaserState: FinishedDownloadsTeaserState - renderPostworkBadge?: (badge?: FinishedPostworkBadge) => React.ReactNode + renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode handleDuration: (job: RecordJob, seconds: number) => void @@ -83,7 +69,7 @@ type Props = { lower: (s: string) => string - modelsByKey: Record + modelsByKey: Record activeTagSet: Set onHoverPreviewKeyChange?: (key: string | null) => void onToggleTagFilter: (tag: string) => void @@ -97,77 +83,19 @@ type Props = { forcePreviewMuted?: boolean } -function firstNonEmptyString(...values: unknown[]): string | undefined { - for (const v of values) { - if (typeof v === 'string') { - const s = v.trim() - if (s) return s - } - } - return undefined -} - -function parseJobMeta(metaRaw: unknown): any | null { - if (!metaRaw) return null - if (typeof metaRaw === 'string') { - try { - return JSON.parse(metaRaw) - } catch { - return null - } - } - if (typeof metaRaw === 'object') return metaRaw - return null -} - function normalizeDurationSeconds(value: unknown): number | undefined { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined // ms -> s Heuristik wie in FinishedVideoPreview return value > 24 * 60 * 60 ? value / 1000 : value } -function clamp(n: number, min: number, max: number) { - return Math.max(min, Math.min(max, n)) -} - -const DEFAULT_SPRITE_STEP_SECONDS = 5 - -function chooseSpriteGrid(count: number): [number, number] { - if (count <= 1) return [1, 1] - - const targetRatio = 16 / 9 - let bestCols = 1 - let bestRows = count - let bestWaste = Number.POSITIVE_INFINITY - let bestRatioScore = Number.POSITIVE_INFINITY - - for (let c = 1; c <= count; c++) { - const r = Math.max(1, Math.ceil(count / c)) - const waste = c * r - count - const ratio = c / r - const ratioScore = Math.abs(ratio - targetRatio) - - if ( - waste < bestWaste || - (waste === bestWaste && ratioScore < bestRatioScore) || - (waste === bestWaste && ratioScore === bestRatioScore && r < bestRows) - ) { - bestWaste = waste - bestRatioScore = ratioScore - bestCols = c - bestRows = r - } - } - - return [bestCols, bestRows] -} - export default function FinishedDownloadsGalleryView({ rows, isLoading, blurPreviews, durations, postworkByFile, + getPostworkSummaryForOutput, teaserState, renderPostworkBadge, selectedKeys, @@ -242,22 +170,27 @@ export default function FinishedDownloadsGalleryView({ // ✅ Auflösung als {w,h} aus meta.json bevorzugen const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => { - const w = - (typeof (j as any)?.meta?.videoWidth === 'number' && Number.isFinite((j as any).meta.videoWidth) - ? (j as any).meta.videoWidth - : 0) || - (typeof (j as any)?.videoWidth === 'number' && Number.isFinite((j as any).videoWidth) - ? (j as any).videoWidth - : 0) - const h = - (typeof (j as any)?.meta?.videoHeight === 'number' && Number.isFinite((j as any).meta.videoHeight) - ? (j as any).meta.videoHeight - : 0) || - (typeof (j as any)?.videoHeight === 'number' && Number.isFinite((j as any).videoHeight) - ? (j as any).videoHeight - : 0) + const meta = parseJobMeta((j as any)?.meta) + + const w = + meta?.media?.video?.width ?? + meta?.file?.video?.width + + const h = + meta?.media?.video?.height ?? + meta?.file?.video?.height + + if ( + typeof w === 'number' && + Number.isFinite(w) && + w > 0 && + typeof h === 'number' && + Number.isFinite(h) && + h > 0 + ) { + return { w, h } + } - if (w > 0 && h > 0) return { w, h } return null }, []) @@ -317,15 +250,8 @@ export default function FinishedDownloadsGalleryView({ const isHot = isHotName(fileRaw) const file = stripHotPrefix(fileRaw) - const postworkBadge = - postworkByFile[fileRaw] ?? - postworkByFile[stripHotPrefix(fileRaw)] - const showPostworkBadge = - postworkBadge && - (postworkBadge.state === 'queued' || - postworkBadge.state === 'running' || - postworkBadge.state === 'done' || - postworkBadge.state === 'error') + const postworkBadge = getPostworkSummaryForOutput(j.output, postworkByFile) + const showPostworkBadge = !!postworkBadge const dur = runtimeOf(j) const size = formatBytes(sizeBytesOf(j)) @@ -346,10 +272,6 @@ export default function FinishedDownloadsGalleryView({ (j as any)?.meta?.modelImageUrl ) - // Preview-ID wie in FinishedVideoPreview ableiten (HOT entfernen + ext entfernen) - const fileForPreviewId = stripHotPrefix(baseName(j.output || '')) - const previewId = fileForPreviewId.replace(/\.[^.]+$/, '').trim() - // meta robust lesen (Objekt oder JSON-String) const meta = parseJobMeta((j as any)?.meta) @@ -357,119 +279,30 @@ export default function FinishedDownloadsGalleryView({ // ✅ STASHAPP-LIKE: Sprite-Preview (1 Bild + CSS background-position) // Erwartet z.B. meta.previewSprite = { path, count, cols, rows, stepSeconds } // ------------------------------------------------------------ - const spritePathRaw = firstNonEmptyString( - meta?.previewSprite?.path, - (meta as any)?.previewSpritePath, - flags?.previewScrubberPath, - previewId ? `/api/preview-sprite/${encodeURIComponent(previewId)}` : undefined - ) - const spritePath = spritePathRaw ? spritePathRaw.replace(/\/+$/, '') : undefined + const sprite = readPreviewSpriteInfo((j as any)?.meta, { + fallbackPath: flags?.previewScrubberPath, + fallbackCount: flags?.previewScrubberCount, + }) - const spriteStepSecondsRaw = - meta?.previewSprite?.stepSeconds ?? (meta as any)?.previewSpriteStepSeconds - - const spriteStepSeconds = - typeof spriteStepSecondsRaw === 'number' && - Number.isFinite(spriteStepSecondsRaw) && - spriteStepSecondsRaw > 0 - ? spriteStepSecondsRaw - : DEFAULT_SPRITE_STEP_SECONDS - - // ✅ Fallback-Dauer (meta -> job -> durations cache) - const durationForSprite = - normalizeDurationSeconds(meta?.durationSeconds) ?? - normalizeDurationSeconds((j as any)?.durationSeconds) ?? - normalizeDurationSeconds(durations[k]) - - // ✅ Count aus Meta/Flags ODER aus Dauer ableiten - const inferredSpriteCountFromDuration = - typeof durationForSprite === 'number' && durationForSprite > 0 - ? Math.max(1, Math.min(200, Math.floor(durationForSprite / spriteStepSeconds) + 1)) - : undefined - - const spriteCountRaw = - meta?.previewSprite?.count ?? - (meta as any)?.previewSpriteCount ?? - flags?.previewScrubberCount ?? - inferredSpriteCountFromDuration - - const spriteColsRaw = meta?.previewSprite?.cols ?? (meta as any)?.previewSpriteCols - const spriteRowsRaw = meta?.previewSprite?.rows ?? (meta as any)?.previewSpriteRows - - const spriteCount = - typeof spriteCountRaw === 'number' && Number.isFinite(spriteCountRaw) - ? Math.max(0, Math.floor(spriteCountRaw)) - : 0 - - // ✅ Wenn cols/rows fehlen, aus Count ableiten (wie Backend) - const [inferredCols, inferredRows] = - spriteCount > 1 ? chooseSpriteGrid(spriteCount) : [0, 0] - - const spriteCols = - typeof spriteColsRaw === 'number' && Number.isFinite(spriteColsRaw) - ? Math.max(0, Math.floor(spriteColsRaw)) - : inferredCols - - const spriteRows = - typeof spriteRowsRaw === 'number' && Number.isFinite(spriteRowsRaw) - ? Math.max(0, Math.floor(spriteRowsRaw)) - : inferredRows - - // Optionaler Cache-Buster (wenn du sowas in meta hast) - const spriteVersion = - (typeof meta?.updatedAtUnix === 'number' && Number.isFinite(meta.updatedAtUnix) - ? meta.updatedAtUnix - : undefined) ?? - (typeof (meta as any)?.fileModUnix === 'number' && Number.isFinite((meta as any).fileModUnix) - ? (meta as any).fileModUnix - : undefined) ?? - 0 - - const spriteUrl = - spritePath && spriteVersion - ? `${spritePath}?v=${encodeURIComponent(String(spriteVersion))}` - : spritePath || undefined - - const hasScrubberUi = - Boolean(spriteUrl) && - spriteCount > 1 - - const hasSpriteScrubber = - hasScrubberUi && - spriteCols > 0 && - spriteRows > 0 - - // Finales Scrubber-Setup - const scrubberCount = hasScrubberUi ? spriteCount : 0 - const scrubberStepSeconds = hasScrubberUi ? spriteStepSeconds : 0 + const spriteUrl = sprite.url + const hasScrubberUi = sprite.hasScrubberUi + const hasSpriteScrubber = sprite.hasSpriteScrubber + const scrubberCount = sprite.count + const scrubberStepSeconds = sprite.stepSeconds const hasScrubber = hasScrubberUi const activeScrubIndex = scrubIndexByKey[k] - const scrubProgressRatio = - typeof activeScrubIndex === 'number' && scrubberCount > 1 - ? clamp(activeScrubIndex / (scrubberCount - 1), 0, 1) - : undefined + const scrubProgressRatio = scrubProgressRatioFromIndex(activeScrubIndex, scrubberCount) // Sprite-Overlay-Frame (kein Request pro Move) - const spriteFrameStyle: React.CSSProperties | undefined = - hasSpriteScrubber && typeof activeScrubIndex === 'number' - ? (() => { - const idx = clamp(activeScrubIndex, 0, Math.max(0, spriteCount - 1)) - const col = idx % spriteCols - const row = Math.floor(idx / spriteCols) - - const posX = spriteCols <= 1 ? 0 : (col / (spriteCols - 1)) * 100 - const posY = spriteRows <= 1 ? 0 : (row / (spriteRows - 1)) * 100 - - return { - backgroundImage: `url("${spriteUrl}")`, - backgroundRepeat: 'no-repeat', - backgroundSize: `${spriteCols * 100}% ${spriteRows * 100}%`, - backgroundPosition: `${posX}% ${posY}%`, - } - })() - : undefined + const spriteFrameStyle = buildSpriteFrameStyle({ + spriteUrl, + spriteCols: sprite.cols, + spriteRows: sprite.rows, + spriteCount: sprite.count, + activeIndex: activeScrubIndex, + }) const showModelPreviewInThumb = hoveredModelPreviewKey === k && Boolean(modelImageSrc) const showScrubberSpriteInThumb = !showModelPreviewInThumb && Boolean(spriteFrameStyle) @@ -477,6 +310,11 @@ export default function FinishedDownloadsGalleryView({ const hideTeaserUnderOverlay = showModelPreviewInThumb || showScrubberSpriteInThumb + const previewDurationSeconds = + durations[k] ?? + normalizeDurationSeconds(meta?.media?.durationSeconds) ?? + normalizeDurationSeconds(meta?.file?.durationSeconds) + return (
{ - e.preventDefault() - e.stopPropagation() - }} - onMouseDown={(e) => e.stopPropagation()} - > - onToggleSelected(j)} - /> -
-
- {/* Thumb */} -
{ - setHoveredThumbKey(k) - }} - onMouseLeave={() => { - setHoveredThumbKey((prev) => (prev === k ? null : prev)) - clearScrubIndex(k) - setHoveredModelPreviewKey((prev) => (prev === k ? null : prev)) - }} - > - {/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */} -
-
- stripHotPrefix(baseName(p))} - durationSeconds={durations[k] ?? (j as any)?.durationSeconds} - onDuration={handleDuration} - variant="fill" - showPopover={false} - blur={blurPreviews} - animated={shouldAnimateTeaser({ - state: teaserState, - itemKey: k, - disabled: hideTeaserUnderOverlay, - })} - animatedMode="teaser" - animatedTrigger="always" - muted={previewMuted} - popoverMuted={previewMuted} - scrubProgressRatio={scrubProgressRatio} - preferScrubProgress={typeof activeScrubIndex === 'number'} - forceActive={isPreviewActive} - teaserPreloadEnabled - /> -
+
{ + e.preventDefault() + e.stopPropagation() + }} + onMouseDown={(e) => e.stopPropagation()} + > + onToggleSelected(j)} + /> +
- {/* ✅ Sprite vorladen (einmal), damit erster Scrub-Move sofort sichtbar ist */} - {hasSpriteScrubber && spriteUrl ? ( - - ) : null} - - {/* ✅ Scrubber-Frame Overlay (Sprite-first = stashapp-like, kein Request pro Move) */} - {showScrubberSpriteInThumb && spriteFrameStyle ? ( -