This commit is contained in:
Linrador 2026-03-26 07:38:24 +01:00
parent 19b473f8bd
commit ff06d0d18b
24 changed files with 4636 additions and 2471 deletions

View File

@ -498,8 +498,8 @@ func analyzeVideoFromFrames(ctx context.Context, outPath, goal string) ([]analyz
Time: t, Time: t,
Label: bestLabel, Label: bestLabel,
Score: bestScore, Score: bestScore,
Start: math.Max(0, t-4), Start: t,
End: t + 4, End: t,
}) })
} }
@ -545,16 +545,12 @@ func analyzeSpriteCandidatesWithAI(
continue continue
} }
span := inferredSpanSeconds(ps.StepSeconds, 8)
start := math.Max(0, c.Time-(span/2))
end := c.Time + (span / 2)
hits = append(hits, analyzeHit{ hits = append(hits, analyzeHit{
Time: c.Time, Time: c.Time,
Label: bestLabel, Label: bestLabel,
Score: bestScore, Score: bestScore,
Start: start, Start: c.Time,
End: end, End: c.Time,
}) })
} }
@ -579,14 +575,14 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit {
start := h.Start start := h.Start
end := h.End end := h.End
if start <= 0 && end <= 0 { if start < 0 && end < 0 {
start = h.Time start = h.Time
end = h.Time end = h.Time
} else { } else {
if start <= 0 { if start < 0 {
start = h.Time start = h.Time
} }
if end <= 0 { if end < 0 {
end = h.Time end = h.Time
} }
} }
@ -660,18 +656,17 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme
start := hit.Start start := hit.Start
end := hit.End end := hit.End
if start <= 0 && end <= 0 { if start < 0 && end < 0 {
start = hit.Time start = hit.Time
end = hit.Time end = hit.Time
} else { } else {
if start <= 0 { if start < 0 {
start = hit.Time start = hit.Time
} }
if end <= 0 { if end < 0 {
end = hit.Time end = hit.Time
} }
} }
if start > end { if start > end {
start, end = end, start start, end = end, start
} }

View File

@ -3,7 +3,6 @@ package main
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"math" "math"
@ -324,36 +323,70 @@ func ensurePrimaryAssetsForVideoWithProgressCtx(
return out, nil 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) videoPath = strings.TrimSpace(videoPath)
if videoPath == "" { if videoPath == "" {
return nil return false, nil
} }
fi, err := os.Stat(videoPath) fi, err := os.Stat(videoPath)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return nil return false, nil
} }
id := assetIDFromVideoPath(videoPath) id := assetIDFromVideoPath(videoPath)
if id == "" { if id == "" {
return nil return false, nil
} }
_, _, previewPath, spritePath, metaPath, perr := assetPathsForID(id) _, _, _, _, metaPath, perr := assetPathsForID(id)
if perr != nil { 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) 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
}
if !previewExists {
const ( const (
previewClipLenSec = 0.75 previewClipLenSec = 0.75
previewMaxClips = 12 previewMaxClips = 12
@ -362,8 +395,9 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string)
genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel() defer cancel()
if err := genSem.Acquire(genCtx); err == nil { if err := genSem.Acquire(genCtx); err != nil {
func() { return false, err
}
defer genSem.Release() defer genSem.Release()
if err := generateTeaserClipsMP4WithProgress( if err := generateTeaserClipsMP4WithProgress(
@ -375,36 +409,13 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string)
nil, nil,
); err != nil { ); err != nil {
if isFFmpegInputInvalidError(err) { if isFFmpegInputInvalidError(err) {
fmt.Printf("⚠️ preview clips skipped (invalid/incomplete input): %s\n", videoPath) return false, nil
return
}
fmt.Printf("⚠️ deferred preview failed for %s: %v\n", videoPath, err)
return
}
}()
} }
return false, err
} }
// vorhandene previewClips / spriteMeta aus bestehender meta.json übernehmen
var computedPreviewClips []previewClip var computedPreviewClips []previewClip
var spriteMeta *previewSpriteMeta if meta.durSec > 0 {
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
)
opts := TeaserPreviewOptions{ opts := TeaserPreviewOptions{
Segments: previewMaxClips, Segments: previewMaxClips,
SegmentDuration: previewClipLenSec, SegmentDuration: previewClipLenSec,
@ -422,18 +433,68 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string)
computedPreviewClips = clips computedPreviewClips = clips
} }
// Sprite erzeugen, wenn noch nicht vorhanden var spriteMeta *previewSpriteMeta
spriteExists := false if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.Preview.Sprite != nil {
if sfi, err := os.Stat(spritePath); err == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 { spriteMeta = oldMeta.Preview.Sprite
spriteExists = true }
if meta.durSec > 0 {
_ = writeVideoMetaWithPreviewClipsAndSprite(
metaPath,
fi,
meta.durSec,
meta.vw,
meta.vh,
meta.fps,
meta.sourceURL,
computedPreviewClips,
spriteMeta,
)
}
return true, nil
}
func ensureSpritesForVideoCtx(
ctx context.Context,
videoPath string,
sourceURL string,
) (bool, error) {
videoPath = strings.TrimSpace(videoPath)
if videoPath == "" {
return false, nil
}
fi, err := os.Stat(videoPath)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return false, nil
}
id := assetIDFromVideoPath(videoPath)
if id == "" {
return false, nil
}
_, _, _, spritePath, metaPath, perr := assetPathsForID(id)
if perr != nil {
return false, perr
}
if sfi, err := os.Stat(spritePath); err == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 {
return false, nil
}
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
if !(meta.durSec > 0) {
return false, nil
} }
if !spriteExists && meta.durSec > 0 {
genCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) genCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel() defer cancel()
if err := genSem.Acquire(genCtx); err == nil { if err := genSem.Acquire(genCtx); err != nil {
func() { return false, err
}
defer genSem.Release() defer genSem.Release()
cols, rows, count, cellW, cellH := fixedPreviewSpriteLayout() cols, rows, count, cellW, cellH := fixedPreviewSpriteLayout()
@ -449,11 +510,18 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string)
cellW, cellW,
cellH, cellH,
); err != nil { ); err != nil {
fmt.Printf("⚠️ preview sprite failed for %s: %v\n", videoPath) if sfi, statErr := os.Stat(spritePath); statErr == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 {
return return false, nil
}
return false, err
} }
spriteMeta = &previewSpriteMeta{ 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), Path: fmt.Sprintf("/api/preview-sprite/%s", id),
Count: count, Count: count,
Cols: cols, Cols: cols,
@ -462,30 +530,7 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string)
CellWidth: cellW, CellWidth: cellW,
CellHeight: cellH, 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( _ = writeVideoMetaWithPreviewClipsAndSprite(
metaPath, metaPath,
fi, fi,
@ -497,35 +542,108 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string)
computedPreviewClips, computedPreviewClips,
spriteMeta, 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"
} }
// AI erst NACH Sprite
if !hasAIResultsForOutput(videoPath) {
if spriteMeta != nil {
actx, cancel := context.WithTimeout(ctx, 45*time.Second) actx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel() defer cancel()
durationSec, _ := durationSecondsForAnalyze(actx, videoPath) durationSec, _ := durationSecondsForAnalyze(actx, videoPath)
hits, aerr := analyzeVideoFromSprite(actx, videoPath, "nsfw") hits, aerr := analyzeVideoFromSprite(actx, videoPath, goal)
if aerr != nil { if aerr != nil {
fmt.Println("⚠️ deferred analyze:", aerr) return false, nil
return nil
} }
segments := buildSegmentsFromAnalyzeHits(hits, durationSec) segments := buildSegmentsFromAnalyzeHits(hits, durationSec)
ai := &aiAnalysisMeta{ ai := &aiAnalysisMeta{
Goal: "nsfw", Goal: goal,
Mode: "sprite", Mode: "sprite",
Hits: hits, Hits: hits,
Segments: segments, Segments: segments,
AnalyzedAtUnix: time.Now().Unix(), AnalyzedAtUnix: time.Now().Unix(),
} }
if werr := writeVideoAIForFile(actx, videoPath, sourceURL, ai); werr != nil { if werr := writeVideoAIForFile(actx, videoPath, meta.sourceURL, ai); werr != nil {
fmt.Println("⚠️ writeVideoAIForFile:", werr) 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 return nil
@ -700,7 +818,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
// aber NICHT sofort returnen, damit meta.json // aber NICHT sofort returnen, damit meta.json
// (previewClips / previewSprite) trotzdem sauber geschrieben wird. // (previewClips / previewSprite) trotzdem sauber geschrieben wird.
metaHasSprite := false 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 metaHasSprite = true
} }
@ -905,7 +1023,6 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
return return
} }
fmt.Printf("⚠️ preview sprite failed for %s: %v\n", videoPath)
return return
} }
@ -918,16 +1035,16 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
// Falls wir in diesem Lauf keine neuen Clips berechnet haben: // Falls wir in diesem Lauf keine neuen Clips berechnet haben:
// alte aus gültigem meta.json übernehmen (best effort) // alte aus gültigem meta.json übernehmen (best effort)
if len(computedPreviewClips) == 0 { if len(computedPreviewClips) == 0 {
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && len(oldMeta.PreviewClips) > 0 { if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && len(oldMeta.Preview.Clips) > 0 {
computedPreviewClips = oldMeta.PreviewClips computedPreviewClips = oldMeta.Preview.Clips
} }
} }
// Falls wir in diesem Lauf kein neues spriteMeta gesetzt haben: // Falls wir in diesem Lauf kein neues spriteMeta gesetzt haben:
// altes aus gültigem meta.json übernehmen // altes aus gültigem meta.json übernehmen
if spriteMeta == nil { if spriteMeta == nil {
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 {
spriteMeta = oldMeta.PreviewSprite spriteMeta = oldMeta.Preview.Sprite
} }
} }
@ -964,19 +1081,7 @@ func hasAIResultsForOutput(outPath string) bool {
return false return false
} }
b, err := os.ReadFile(metaPath) aiMap, ok := readVideoMetaAI(metaPath)
if err != nil || len(b) == 0 {
return false
}
var m map[string]any
dec := json.NewDecoder(strings.NewReader(string(b)))
dec.UseNumber()
if err := dec.Decode(&m); err != nil {
return false
}
aiMap, ok := m["ai"].(map[string]any)
if !ok || aiMap == nil { if !ok || aiMap == nil {
return false return false
} }

View File

@ -1,4 +1,5 @@
// backend/meta.go // backend\meta.go
package main package main
import ( import (
@ -25,28 +26,78 @@ type previewSpriteMeta struct {
Count int `json:"count"` // Anzahl Frames im Sprite Count int `json:"count"` // Anzahl Frames im Sprite
Cols int `json:"cols"` // Spalten im Tile Cols int `json:"cols"` // Spalten im Tile
Rows int `json:"rows"` // Zeilen 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"` CellWidth int `json:"cellWidth,omitempty"`
CellHeight int `json:"cellHeight,omitempty"` CellHeight int `json:"cellHeight,omitempty"`
} }
type videoMeta struct { type videoFileMeta struct {
Version int `json:"version"` Size int64 `json:"size"`
DurationSeconds float64 `json:"durationSeconds"` ModUnix int64 `json:"modUnix"`
FileSize int64 `json:"fileSize"` SourceURL string `json:"sourceUrl,omitempty"`
FileModUnix int64 `json:"fileModUnix"` }
VideoWidth int `json:"videoWidth,omitempty"` type videoStreamMeta struct {
VideoHeight int `json:"videoHeight,omitempty"` Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
FPS float64 `json:"fps,omitempty"` FPS float64 `json:"fps,omitempty"`
Resolution string `json:"resolution,omitempty"` Resolution string `json:"resolution,omitempty"`
}
SourceURL string `json:"sourceUrl,omitempty"` type videoMediaMeta struct {
PreviewClips []previewClip `json:"previewClips,omitempty"` DurationSeconds float64 `json:"durationSeconds"`
PreviewSprite *previewSpriteMeta `json:"previewSprite,omitempty"` 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"` 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"`
UpdatedAtUnix int64 `json:"updatedAtUnix"` 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 { type aiSegmentMeta struct {
@ -66,54 +117,20 @@ type aiAnalysisMeta struct {
AnalyzedAtUnix int64 `json:"analyzedAtUnix,omitempty"` 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) { func readVideoMeta(metaPath string, fi os.FileInfo) (dur float64, w int, h int, fps float64, ok bool) {
b, err := os.ReadFile(metaPath) m, ok := readVideoMetaIfValid(metaPath, fi)
if err != nil || len(b) == 0 { if !ok || m == nil {
return 0, 0, 0, 0, false return 0, 0, 0, 0, false
} }
return m.Media.DurationSeconds, m.Media.Video.Width, m.Media.Video.Height, m.Media.Video.FPS, true
// 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
} }
func readVideoMetaDuration(metaPath string, fi os.FileInfo) (float64, bool) { func readVideoMetaDuration(metaPath string, fi os.FileInfo) (float64, bool) {
m, ok := readVideoMetaIfValid(metaPath, fi) m, ok := readVideoMetaIfValid(metaPath, fi)
if !ok || m == nil || m.DurationSeconds <= 0 { if !ok || m == nil || m.Media.DurationSeconds <= 0 {
return 0, false return 0, false
} }
return m.DurationSeconds, true return m.Media.DurationSeconds, true
} }
func metaJSONPathForAssetID(assetID string) (string, error) { 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) { 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)) stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath))
assetID := stripHotPrefix(strings.TrimSpace(stem)) assetID := stripHotPrefix(strings.TrimSpace(stem))
if assetID == "" { if assetID == "" {
return nil, false return nil, false
} }
// sanitize wie bei deinen generated Ordnern
var err error var err error
assetID, err = sanitizeID(assetID) assetID, err = sanitizeID(assetID)
if err != nil || assetID == "" { if err != nil || assetID == "" {
@ -147,12 +162,10 @@ func ensureVideoMetaForFile(ctx context.Context, fullPath string, fi os.FileInfo
return nil, false return nil, false
} }
// 1) valid meta vorhanden?
if m, ok := readVideoMetaIfValid(metaPath, fi); ok { if m, ok := readVideoMetaIfValid(metaPath, fi); ok {
return m, true return m, true
} }
// 2) sonst neu erzeugen (mit Concurrency-Limit)
if ctx == nil { if ctx == nil {
ctx = context.Background() ctx = context.Background()
} }
@ -166,38 +179,26 @@ func ensureVideoMetaForFile(ctx context.Context, fullPath string, fi os.FileInfo
defer durSem.Release() defer durSem.Release()
} }
// Dauer
dur, derr := durationSecondsCached(cctx, fullPath) dur, derr := durationSecondsCached(cctx, fullPath)
if derr != nil || dur <= 0 { if derr != nil || dur <= 0 {
return nil, false return nil, false
} }
// Video props
w, h, fps, perr := probeVideoProps(cctx, fullPath) w, h, fps, perr := probeVideoProps(cctx, fullPath)
if perr != nil { if perr != nil {
// width/height/fps dürfen 0 bleiben, duration ist aber trotzdem nützlich
w, h, fps = 0, 0, 0 w, h, fps = 0, 0, 0
} }
// meta dir anlegen
_ = os.MkdirAll(filepath.Dir(metaPath), 0o755) _ = os.MkdirAll(filepath.Dir(metaPath), 0o755)
m := &videoMeta{ if err := writeVideoMeta(metaPath, fi, dur, w, h, fps, sourceURL); err != nil {
Version: 2, return nil, false
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(),
} }
b, _ := json.MarshalIndent(m, "", " ") m, ok := readVideoMetaLoose(metaPath)
b = append(b, '\n') if !ok || m == nil {
_ = atomicWriteFile(metaPath, b) // best effort return nil, false
}
return m, true return m, true
} }
@ -211,45 +212,41 @@ func attachMetaToJobBestEffort(ctx context.Context, job *RecordJob, fullPath str
return return
} }
// Stat
fi, err := os.Stat(fullPath) fi, err := os.Stat(fullPath)
if err != nil || fi == nil || fi.IsDir() { if err != nil || fi == nil || fi.IsDir() {
return return
} }
// Größe immer mitgeben (macht Sort/Anzeige einfacher)
if job.SizeBytes <= 0 { if job.SizeBytes <= 0 {
job.SizeBytes = fi.Size() job.SizeBytes = fi.Size()
} }
// Meta.json lesen/erzeugen (best effort)
m, ok := ensureVideoMetaForFileBestEffort(ctx, fullPath, job.SourceURL) m, ok := ensureVideoMetaForFileBestEffort(ctx, fullPath, job.SourceURL)
if !ok || m == nil { if !ok || m == nil {
return return
} }
// Optional: komplettes Meta mitsenden
job.Meta = m job.Meta = m
// Und zusätzlich die "Top-Level" Felder befüllen (für Frontend bequem) if strings.Contains(strings.TrimSpace(fullPath), "alice_kosmos_03_24_2026__03-48-34") {
if job.DurationSeconds <= 0 && m.DurationSeconds > 0 { b, _ := json.MarshalIndent(m, "", " ")
job.DurationSeconds = m.DurationSeconds 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 { if job.VideoWidth <= 0 && m.Media.Video.Width > 0 {
job.VideoHeight = m.VideoHeight job.VideoWidth = m.Media.Video.Width
} }
if job.FPS <= 0 && m.FPS > 0 { if job.VideoHeight <= 0 && m.Media.Video.Height > 0 {
job.FPS = m.FPS 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) { func ensureVideoMetaForFileBestEffort(ctx context.Context, fullPath string, sourceURL string) (*videoMeta, bool) {
fullPath = strings.TrimSpace(fullPath) fullPath = strings.TrimSpace(fullPath)
if fullPath == "" { if fullPath == "" {
@ -261,23 +258,21 @@ func ensureVideoMetaForFileBestEffort(ctx context.Context, fullPath string, sour
return nil, false return nil, false
} }
// 1) Normaler Weg: meta erzeugen/lesen (ffprobe/ffmpeg)
if m, ok := ensureVideoMetaForFile(ctx, fullPath, fi, sourceURL); ok && m != nil { if m, ok := ensureVideoMetaForFile(ctx, fullPath, fi, sourceURL); ok && m != nil {
return m, true return m, true
} }
// 2) Fallback: wenn wir Duration schon im RAM-Cache haben -> meta.json (Duration-only) persistieren
dur := durationSecondsCacheOnly(fullPath, fi) dur := durationSecondsCacheOnly(fullPath, fi)
if dur <= 0 { if dur <= 0 {
return nil, false return nil, false
} }
stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath)) stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath))
assetID := stripHotPrefix(strings.TrimSpace(stem)) assetID := stripHotPrefix(strings.TrimSpace(stem))
if assetID == "" { if assetID == "" {
return nil, false return nil, false
} }
assetID, err = sanitizeID(assetID) assetID, err = sanitizeID(assetID)
if err != nil || assetID == "" { if err != nil || assetID == "" {
return nil, false return nil, false
@ -291,7 +286,6 @@ func ensureVideoMetaForFileBestEffort(ctx context.Context, fullPath string, sour
_ = os.MkdirAll(filepath.Dir(metaPath), 0o755) _ = os.MkdirAll(filepath.Dir(metaPath), 0o755)
_ = writeVideoMetaDuration(metaPath, fi, dur, sourceURL) _ = writeVideoMetaDuration(metaPath, fi, dur, sourceURL)
// nochmal lesen/validieren
if m, ok := readVideoMetaIfValid(metaPath, fi); ok && m != nil { if m, ok := readVideoMetaIfValid(metaPath, fi); ok && m != nil {
return m, true return m, true
} }
@ -309,63 +303,162 @@ func readVideoMetaIfValid(metaPath string, fi os.FileInfo) (*videoMeta, bool) {
if err := json.Unmarshal(b, &m); err != nil { if err := json.Unmarshal(b, &m); err != nil {
return nil, false return nil, false
} }
if m.Version != 1 && m.Version != 2 { if m.Version != 3 {
return nil, false 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 return nil, false
} }
if m.DurationSeconds <= 0 { if m.Media.DurationSeconds <= 0 {
return nil, false return nil, false
} }
return &m, true 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) { func readVideoMetaSourceURL(metaPath string, fi os.FileInfo) (string, bool) {
m, ok := readVideoMetaIfValid(metaPath, fi) m, ok := readVideoMetaIfValid(metaPath, fi)
if !ok || m == nil { if !ok || m == nil {
return "", false return "", false
} }
u := strings.TrimSpace(m.SourceURL) u := strings.TrimSpace(m.File.SourceURL)
if u == "" { if u == "" {
return "", false return "", false
} }
return u, true 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 { func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64, w int, h int, fps float64, sourceURL string) error {
if strings.TrimSpace(metaPath) == "" || dur <= 0 { if strings.TrimSpace(metaPath) == "" || dur <= 0 {
return nil return nil
} }
var existing *videoMeta var existing *videoMeta
if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil { if old, ok := readVideoMetaLoose(metaPath); ok && old != nil {
existing = old existing = old
} }
m := videoMeta{ m := videoMeta{
Version: 2, Version: 3,
UpdatedAtUnix: time.Now().Unix(),
File: videoFileMeta{
Size: fi.Size(),
ModUnix: fi.ModTime().Unix(),
SourceURL: strings.TrimSpace(sourceURL),
},
Media: videoMediaMeta{
DurationSeconds: dur, DurationSeconds: dur,
FileSize: fi.Size(), Video: videoStreamMeta{
FileModUnix: fi.ModTime().Unix(), Width: w,
VideoWidth: w, Height: h,
VideoHeight: h,
FPS: fps, FPS: fps,
Resolution: formatResolution(w, h), Resolution: formatResolution(w, h),
SourceURL: strings.TrimSpace(sourceURL), },
UpdatedAtUnix: time.Now().Unix(), },
PreviewClips: nil,
PreviewSprite: nil,
AI: nil,
} }
if existing != nil { if existing != nil {
m.PreviewClips = existing.PreviewClips m.Preview = existing.Preview
m.PreviewSprite = existing.PreviewSprite m.Analysis = existing.Analysis
m.AI = existing.AI 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) buf, err := json.Marshal(m)
if err != nil { if err != nil {
return err return err
@ -378,33 +471,63 @@ func writeVideoMetaWithPreviewClips(metaPath string, fi os.FileInfo, dur float64
if strings.TrimSpace(metaPath) == "" || dur <= 0 { if strings.TrimSpace(metaPath) == "" || dur <= 0 {
return nil return nil
} }
assetID := strings.TrimSpace(filepath.Base(filepath.Dir(metaPath)))
var existing *videoMeta var existing *videoMeta
if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil { if old, ok := readVideoMetaLoose(metaPath); ok && old != nil {
existing = old existing = old
} }
m := videoMeta{ m := videoMeta{
Version: 2, Version: 3,
UpdatedAtUnix: time.Now().Unix(),
File: videoFileMeta{
Size: fi.Size(),
ModUnix: fi.ModTime().Unix(),
SourceURL: strings.TrimSpace(sourceURL),
},
Media: videoMediaMeta{
DurationSeconds: dur, DurationSeconds: dur,
FileSize: fi.Size(), Video: videoStreamMeta{
FileModUnix: fi.ModTime().Unix(), Width: w,
VideoWidth: w, Height: h,
VideoHeight: h,
FPS: fps, FPS: fps,
Resolution: formatResolution(w, h), Resolution: formatResolution(w, h),
SourceURL: strings.TrimSpace(sourceURL), },
PreviewClips: clips, },
UpdatedAtUnix: time.Now().Unix(), Preview: previewMeta{
Clips: clips,
Thumb: buildPreviewThumbMeta(assetID),
},
} }
if existing != nil { 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) buf, err := json.Marshal(m)
if err != nil { if err != nil {
return err return err
@ -427,35 +550,67 @@ func writeVideoMetaWithPreviewClipsAndSprite(
if strings.TrimSpace(metaPath) == "" || dur <= 0 { if strings.TrimSpace(metaPath) == "" || dur <= 0 {
return nil 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{ m := videoMeta{
Version: 2, Version: 3,
UpdatedAtUnix: time.Now().Unix(),
File: videoFileMeta{
Size: fi.Size(),
ModUnix: fi.ModTime().Unix(),
SourceURL: strings.TrimSpace(sourceURL),
},
Media: videoMediaMeta{
DurationSeconds: dur, DurationSeconds: dur,
FileSize: fi.Size(), Video: videoStreamMeta{
FileModUnix: fi.ModTime().Unix(), Width: w,
VideoWidth: w, Height: h,
VideoHeight: h,
FPS: fps, FPS: fps,
Resolution: formatResolution(w, h), Resolution: formatResolution(w, h),
SourceURL: strings.TrimSpace(sourceURL), },
PreviewClips: clips, },
PreviewSprite: sprite, Preview: previewMeta{
UpdatedAtUnix: time.Now().Unix(), Clips: clips,
Thumb: buildPreviewThumbMeta(assetID),
Sprite: sprite,
},
} }
if sprite == nil { if existing != nil {
if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil && old.PreviewSprite != nil { m.Analysis = existing.Analysis
m.PreviewSprite = old.PreviewSprite m.Postwork = existing.Postwork
}
}
if len(clips) == 0 {
if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil && len(old.PreviewClips) > 0 {
m.PreviewClips = old.PreviewClips
}
}
if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil && old.AI != nil { m.Preview.Sprite = mergePreviewSpriteMeta(existing.Preview.Sprite, m.Preview.Sprite)
m.AI = old.AI
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) buf, err := json.Marshal(m)
@ -466,7 +621,6 @@ func writeVideoMetaWithPreviewClipsAndSprite(
return atomicWriteFile(metaPath, buf) return atomicWriteFile(metaPath, buf)
} }
// Duration-only Write (ohne props)
func writeVideoMetaDuration(metaPath string, fi os.FileInfo, dur float64, sourceURL string) error { func writeVideoMetaDuration(metaPath string, fi os.FileInfo, dur float64, sourceURL string) error {
return writeVideoMeta(metaPath, fi, dur, 0, 0, 0, sourceURL) return writeVideoMeta(metaPath, fi, dur, 0, 0, 0, sourceURL)
} }
@ -477,7 +631,6 @@ func generatedMetaFile(assetID string) (string, error) {
return "", fmt.Errorf("empty assetID") return "", fmt.Errorf("empty assetID")
} }
// exakt wie beim Schreiben normalisieren
id, err := sanitizeID(assetID) id, err := sanitizeID(assetID)
if err != nil || id == "" { if err != nil || id == "" {
return "", fmt.Errorf("invalid assetID: %w", err) return "", fmt.Errorf("invalid assetID: %w", err)
@ -486,7 +639,6 @@ func generatedMetaFile(assetID string) (string, error) {
return metaJSONPathForAssetID(id) return metaJSONPathForAssetID(id)
} }
// ✅ Neu: /generated/meta/<id>/...
func generatedDirForID(id string) (string, error) { func generatedDirForID(id string) (string, error) {
id, err := sanitizeID(id) id, err := sanitizeID(id)
if err != nil { if err != nil {
@ -544,6 +696,27 @@ func generatedPreviewSpriteFile(id string) (string, error) {
return filepath.Join(dir, "preview-sprite.jpg"), nil 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 { func ensureGeneratedDirs() error {
root, err := generatedMetaRoot() root, err := generatedMetaRoot()
if err != nil { if err != nil {
@ -581,44 +754,57 @@ func writeVideoMetaAI(
} }
var existing *videoMeta var existing *videoMeta
if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil { if old, ok := readVideoMetaLoose(metaPath); ok && old != nil {
existing = old existing = old
} }
m := videoMeta{ m := videoMeta{
Version: 2, Version: 3,
UpdatedAtUnix: time.Now().Unix(),
File: videoFileMeta{
Size: fi.Size(),
ModUnix: fi.ModTime().Unix(),
SourceURL: strings.TrimSpace(sourceURL),
},
Media: videoMediaMeta{
DurationSeconds: dur, DurationSeconds: dur,
FileSize: fi.Size(), Video: videoStreamMeta{
FileModUnix: fi.ModTime().Unix(), Width: w,
VideoWidth: w, Height: h,
VideoHeight: h,
FPS: fps, FPS: fps,
Resolution: formatResolution(w, h), Resolution: formatResolution(w, h),
SourceURL: strings.TrimSpace(sourceURL), },
UpdatedAtUnix: time.Now().Unix(), },
Analysis: analysisMeta{
PreviewClips: nil,
PreviewSprite: nil,
AI: ai, AI: ai,
},
} }
if existing != nil { if existing != nil {
m.PreviewClips = existing.PreviewClips m.Preview = existing.Preview
m.PreviewSprite = existing.PreviewSprite m.Postwork = existing.Postwork
if m.VideoWidth <= 0 {
m.VideoWidth = existing.VideoWidth 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 { if m.Media.Video.Height <= 0 {
m.FPS = existing.FPS m.Media.Video.Height = existing.Media.Video.Height
} }
if m.Resolution == "" { if m.Media.Video.FPS <= 0 {
m.Resolution = existing.Resolution m.Media.Video.FPS = existing.Media.Video.FPS
} }
if m.SourceURL == "" { if m.Media.Video.Resolution == "" {
m.SourceURL = existing.SourceURL 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( return writeVideoMetaAI(
metaPath, metaPath,
fi, fi,
m.DurationSeconds, m.Media.DurationSeconds,
m.VideoWidth, m.Media.Video.Width,
m.VideoHeight, m.Media.Video.Height,
m.FPS, m.Media.Video.FPS,
sourceURL, sourceURL,
ai, 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) { func readVideoMetaAI(metaPath string) (map[string]any, bool) {
b, err := os.ReadFile(metaPath) b, err := os.ReadFile(metaPath)
if err != nil || len(b) == 0 { if err != nil || len(b) == 0 {
@ -692,10 +966,70 @@ func readVideoMetaAI(metaPath string) (map[string]any, bool) {
return nil, false 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 { if !ok || ai == nil {
return nil, false return nil, false
} }
return ai, true 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(),
})
}

View File

@ -57,16 +57,18 @@ type previewSpriteMetaResp struct {
StepSeconds float64 `json:"stepSeconds,omitempty"` 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 { type doneMetaFileResp struct {
File string `json:"file"` File string `json:"file"`
MetaExists bool `json:"metaExists"` MetaExists bool `json:"metaExists"`
DurationSeconds float64 `json:"durationSeconds,omitempty"` Meta *videoMeta `json:"meta,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"` Error string `json:"error,omitempty"`
} }
@ -168,6 +170,29 @@ type regenerateAssetsRequest struct {
File string `json:"file"` 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) { func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 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 // 1) Vorher evtl. laufende enrich-Jobs abbrechen
cancelDeferredEnrichForFile(file) cancelDeferredEnrichForFile(file)
// ✅ alten Analyze-Status aktiv löschen
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "")
// 2) SOFORT running an UI senden // 2) SOFORT running an UI senden
startEv := finishedPostworkEvent{ publishFinishedPostworkPhase(file, id, "postwork", "meta", "running", "Meta")
Type: "finished_postwork", publishFinishedPostworkPhase(file, id, "postwork", "teaser", "running", "Teaser")
File: file, publishFinishedPostworkPhase(file, id, "postwork", "thumb", "running", "Vorschaubild")
AssetID: id, publishFinishedPostworkPhase(file, id, "postwork", "sprites", "running", "Sprites")
Queue: "enrich",
State: "running",
Phase: "assets",
Label: "Wird verarbeitet",
TS: time.Now().UnixMilli(),
}
if b, err := json.Marshal(startEv); err == nil {
publishSSE("finishedPostwork", b)
}
notifyDoneChanged() 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 // 4) Regeneration
_, errAssets := ensureAssetsForVideoWithProgressCtx(context.Background(), videoPath, "", nil) _, errAssets := ensureAssetsForVideoWithProgressCtx(context.Background(), videoPath, "", nil)
close(assetWatchDone)
if errAssets == nil {
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "KI-Analyse")
}
errAI := ensureDeferredAssetsAndAI(context.Background(), videoPath, "") errAI := ensureDeferredAssetsAndAI(context.Background(), videoPath, "")
if errAssets == nil && errAI == nil {
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "KI-Analyse")
}
// 5) Ergebnis zurück an UI // 5) Ergebnis zurück an UI
if errAssets != nil || errAI != nil { if errAssets != nil || errAI != nil {
errMsg := "" errMsg := ""
@ -259,6 +331,8 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
errMsg = errAI.Error() errMsg = errAI.Error()
} }
setRegenerateAssetsTaskError(file, errMsg)
errEv := finishedPostworkEvent{ errEv := finishedPostworkEvent{
Type: "finished_postwork", Type: "finished_postwork",
File: file, File: file,
@ -273,25 +347,23 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
publishSSE("finishedPostwork", b) publishSSE("finishedPostwork", b)
} }
notifyDoneChanged()
time.AfterFunc(5*time.Second, func() {
clearRegenerateAssetsTaskState()
})
fmt.Println("[regenerate-assets] failed:", file, errMsg) fmt.Println("[regenerate-assets] failed:", file, errMsg)
return return
} }
doneEv := finishedPostworkEvent{ setRegenerateAssetsTaskDone(file, "Fertig")
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)
}
notifyDoneChanged() notifyDoneChanged()
time.AfterFunc(3*time.Second, func() {
clearRegenerateAssetsTaskState()
})
}(videoPath, file) }(videoPath, file)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@ -623,7 +695,12 @@ func readPreviewSpriteMetaFromMetaFile(metaPath string) (previewSpriteMetaFileIn
return out, false 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 { if !ok || ps == nil {
return out, false return out, false
} }
@ -752,14 +829,11 @@ func previewSpriteTruthForID(id string) previewSpriteMetaResp {
return out return out
} }
metaPath, err := generatedMetaFile(id) _, _, _, spriteFile, metaPath, err := assetPathsForID(id)
if err != nil || strings.TrimSpace(metaPath) == "" { if err != nil {
return out return out
} }
genDir := filepath.Dir(metaPath)
spriteFile := filepath.Join(genDir, "preview-sprite.jpg")
fi, err := os.Stat(spriteFile) fi, err := os.Stat(spriteFile)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return out return out
@ -786,11 +860,154 @@ func previewSpriteTruthForID(id string) previewSpriteMetaResp {
return out 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) { func applyPreviewSpriteTruthToDoneMetaResp(id string, resp *doneMetaFileResp) {
if resp == nil { if resp == nil {
return 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) // robust meta setter into RecordJob.Meta (any/string/[]byte/typed map)
@ -940,9 +1157,6 @@ func applyPreviewSpriteTruthToRecordJobMeta(j *RecordJob) {
meta = map[string]any{} meta = map[string]any{}
} }
delete(meta, "previewScrubberPath")
delete(meta, "previewScrubberCount")
psMap := map[string]any{"exists": ps.Exists} psMap := map[string]any{"exists": ps.Exists}
if ps.Exists { if ps.Exists {
psMap["path"] = ps.Path psMap["path"] = ps.Path
@ -959,8 +1173,81 @@ func applyPreviewSpriteTruthToRecordJobMeta(j *RecordJob) {
psMap["stepSeconds"] = ps.StepSeconds 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) setStructFieldJSONMap(fv, meta)
} }
@ -1699,35 +1986,31 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
resp := doneMetaFileResp{File: filepath.Base(outPath)} resp := doneMetaFileResp{File: filepath.Base(outPath)}
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(outPath), filepath.Ext(outPath))) id := stripHotPrefix(strings.TrimSuffix(filepath.Base(outPath), filepath.Ext(outPath)))
applyPreviewSpriteTruthToDoneMetaResp(id, &resp)
if strings.TrimSpace(id) != "" { if strings.TrimSpace(id) != "" {
if mp, merr := generatedMetaFile(id); merr == nil && strings.TrimSpace(mp) != "" { 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 { if mfi, serr := os.Stat(mp); serr == nil && mfi != nil && !mfi.IsDir() && mfi.Size() > 0 {
if meta, ok := readVideoMetaIfValid(mp, fi); ok && meta != nil {
resp.MetaExists = true resp.MetaExists = true
resp.Meta = meta
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 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) pctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel() defer cancel()
if d, derr := durationSecondsCached(pctx, outPath); derr == nil && d > 0 { 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) applyPreviewSpriteTruthToRecordJobMeta(&c)
applyFinishedPhaseTruthToRecordJobMeta(&c)
out = append(out, &c) out = append(out, &c)
} }

View File

@ -412,10 +412,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
err = errors.New("unsupported provider") err = errors.New("unsupported provider")
} }
if err != nil && shouldLogRecordError(err, provider, req) {
fmt.Println("❌ [record]", provider, job.SourceURL, "->", err)
}
end := time.Now() end := time.Now()
target := JobFinished target := JobFinished

View File

@ -3,7 +3,6 @@ package main
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
@ -29,6 +28,9 @@ type AssetsTaskState struct {
FinishedAt *time.Time `json:"finishedAt,omitempty"` FinishedAt *time.Time `json:"finishedAt,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
CurrentFile string `json:"currentFile,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 var assetsTaskMu sync.Mutex
@ -91,11 +93,14 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
FinishedAt: nil, FinishedAt: nil,
Error: "", Error: "",
CurrentFile: "", CurrentFile: "",
CurrentQueue: "",
CurrentPhase: "",
CurrentLabel: "",
} }
st := assetsTaskState st := assetsTaskState
assetsTaskMu.Unlock() assetsTaskMu.Unlock()
// SSE: Start pushen // SSE: Start pushen
notifyAssetsChanged() notifyAssetsChanged()
go runGenerateMissingAssets(ctx) 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) fileName = strings.TrimSpace(fileName)
if fileName == "" { if fileName == "" {
return return
@ -143,52 +148,31 @@ func publishAssetsTaskFinishedPostworkState(fileName string, state string, label
base := strings.TrimSuffix(fileName, filepath.Ext(fileName)) base := strings.TrimSuffix(fileName, filepath.Ext(fileName))
assetID := stripHotPrefix(base) assetID := stripHotPrefix(base)
ev := finishedPostworkEvent{ publishFinishedPostworkPhase(
Type: "finished_postwork", fileName,
File: fileName, assetID,
AssetID: assetID, queue,
Queue: "enrich", phase,
State: state, // "queued" | "running" | "done" | "error" | "missing" state,
Phase: "assets", label,
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)
} }
func clearAssetsTaskFinishedPostworkState(fileName string) { func clearAssetsTaskFinishedPostworkStates(fileName string) {
fileName = strings.TrimSpace(fileName) defs := []struct {
if fileName == "" { queue string
return 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)) for _, def := range defs {
assetID := stripHotPrefix(base) publishAssetsTaskPhase(fileName, def.queue, def.phase, "missing", "")
ev := finishedPostworkEvent{
Type: "finished_postwork",
File: fileName,
AssetID: assetID,
Queue: "enrich",
State: "missing",
Phase: "assets",
Label: "",
TS: time.Now().UnixMilli(),
} }
b, err := json.Marshal(ev)
if err != nil {
return
}
publishSSE("finishedPostwork", b)
} }
func runGenerateMissingAssets(ctx context.Context) { func runGenerateMissingAssets(ctx context.Context) {
@ -206,14 +190,15 @@ func runGenerateMissingAssets(ctx context.Context) {
st.Running = false st.Running = false
st.FinishedAt = &now st.FinishedAt = &now
st.CurrentFile = "" st.CurrentFile = ""
st.CurrentQueue = ""
st.CurrentPhase = ""
st.CurrentLabel = ""
if err == nil { if err == nil {
// Erfolg: Error leeren
st.Error = "" st.Error = ""
return return
} }
// stabiler Text für UI
if errors.Is(err, context.Canceled) { if errors.Is(err, context.Canceled) {
st.Error = "abgebrochen" st.Error = "abgebrochen"
} else { } else {
@ -234,77 +219,48 @@ func runGenerateMissingAssets(ctx context.Context) {
path string path string
} }
// .trash niemals verarbeiten indexedItems, sortedIdx := buildDoneIndex(doneAbs)
isTrashPath := func(full string) bool {
p := strings.ToLower(strings.ReplaceAll(full, "\\", "/")) // gleiche Grundreihenfolge wie FinishedDownloads-Default:
return strings.Contains(p, "/.trash/") || strings.HasSuffix(p, "/.trash") // includeKeep = true, sort = completed_desc
} order := sortedIdx["1|completed_desc"]
seen := map[string]struct{}{} seen := map[string]struct{}{}
items := make([]item, 0, 512) items := make([]item, 0, len(order))
addIfVideo := func(full string) { for _, idx := range order {
if isTrashPath(full) { if idx < 0 || idx >= len(indexedItems) {
return continue
} }
name := filepath.Base(full) j := indexedItems[idx].job
low := strings.ToLower(name) if j == nil {
if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") { continue
return
} }
ext := strings.ToLower(filepath.Ext(name))
if ext != ".mp4" && ext != ".ts" { full := strings.TrimSpace(j.Output)
return if full == "" {
continue
} }
if _, ok := seen[full]; ok { if _, ok := seen[full]; ok {
return continue
} }
seen[full] = struct{}{} 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) { // Initialisierung: Total etc. + SSE Push
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/<model>/ + done/keep + done/keep/<model>/
scanOneLevel(doneAbs)
scanOneLevel(filepath.Join(doneAbs, "keep"))
// ✅ Initialisierung: Total etc. + SSE Push
updateAssetsState(func(st *AssetsTaskState) { updateAssetsState(func(st *AssetsTaskState) {
st.Total = len(items) st.Total = len(items)
st.Done = 0 st.Done = 0
st.GeneratedThumbs = 0 st.GeneratedThumbs = 0
st.GeneratedPreviews = 0 st.GeneratedPreviews = 0
st.Skipped = 0 st.Skipped = 0
// Start hat Error schon geleert — hier nur sicherheitshalber:
st.Error = "" st.Error = ""
}) })
@ -314,22 +270,16 @@ func runGenerateMissingAssets(ctx context.Context) {
return return
} }
// aktuellen Dateinamen für UI setzen // aktuellen Dateinamen für UI setzen
updateAssetsState(func(st *AssetsTaskState) { updateAssetsState(func(st *AssetsTaskState) {
st.CurrentFile = it.name st.CurrentFile = it.name
}) })
publishAssetsTaskFinishedPostworkState(
it.name,
"running",
"Erstelle Teaser / Vorschau / Thumbnails…",
)
// ID aus Dateiname // ID aus Dateiname
base := strings.TrimSuffix(it.name, filepath.Ext(it.name)) base := strings.TrimSuffix(it.name, filepath.Ext(it.name))
id := stripHotPrefix(base) id := stripHotPrefix(base)
if strings.TrimSpace(id) == "" { if strings.TrimSpace(id) == "" {
clearAssetsTaskFinishedPostworkState(it.name) clearAssetsTaskFinishedPostworkStates(it.name)
updateAssetsState(func(st *AssetsTaskState) { updateAssetsState(func(st *AssetsTaskState) {
st.Done = i + 1 st.Done = i + 1
@ -340,7 +290,7 @@ func runGenerateMissingAssets(ctx context.Context) {
// Datei-Info (validieren) // Datei-Info (validieren)
vfi, verr := os.Stat(it.path) vfi, verr := os.Stat(it.path)
if verr != nil || vfi.IsDir() || vfi.Size() <= 0 { if verr != nil || vfi.IsDir() || vfi.Size() <= 0 {
clearAssetsTaskFinishedPostworkState(it.name) clearAssetsTaskFinishedPostworkStates(it.name)
updateAssetsState(func(st *AssetsTaskState) { updateAssetsState(func(st *AssetsTaskState) {
st.Done = i + 1 st.Done = i + 1
@ -348,17 +298,46 @@ func runGenerateMissingAssets(ctx context.Context) {
continue 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 // Pfade einmalig über zentralen Helper
_, _, _, _, metaPath, perr := assetPathsForID(id) _, _, _, _, metaPath, perr := assetPathsForID(id)
if perr != nil { if perr != nil {
publishAssetsTaskFinishedPostworkState( if needMeta {
it.name, publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen")
"error", }
"Assets 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) { 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.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)"
st.Done = i + 1 st.Done = i + 1
}) })
@ -372,43 +351,133 @@ func runGenerateMissingAssets(ctx context.Context) {
sourceURL = u sourceURL = u
} }
// Generate/Ensure (einheitliche Core-Funktion) thumbGenerated := false
res, e := ensureAssetsForVideoWithProgressCtx(ctx, it.path, sourceURL, nil) previewGenerated := false
if e != nil {
publishAssetsTaskFinishedPostworkState(
it.name,
"error",
"Assets fehlgeschlagen",
)
finishWithErr(e)
return
}
if _, aerr := prepareVideoForSplit(ctx, it.path, sourceURL, "nsfw"); aerr != nil { // ----------------
// META
// ----------------
if needMeta {
updateAssetsState(func(st *AssetsTaskState) { 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) // 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 { } else {
publishAssetsTaskFinishedPostworkState( publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen")
it.name, }
"done", }
"Teaser / Vorschau / Thumbnails fertig",
) // ----------------
// 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) { updateAssetsState(func(st *AssetsTaskState) {
if res.Skipped { if thumbGenerated {
st.Skipped++
}
if res.ThumbGenerated {
st.GeneratedThumbs++ st.GeneratedThumbs++
} }
if res.PreviewGenerated { if previewGenerated {
st.GeneratedPreviews++ st.GeneratedPreviews++
} }
st.Done = i + 1 st.Done = i + 1

View File

@ -14,6 +14,12 @@ import (
"time" "time"
) )
type cleanupCandidate struct {
path string
name string
size int64
}
type cleanupResp struct { type cleanupResp struct {
// Small downloads cleanup // Small downloads cleanup
ScannedFiles int `json:"scannedFiles"` ScannedFiles int `json:"scannedFiles"`
@ -103,8 +109,9 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
return ext == ".mp4" || ext == ".ts" return ext == ".mp4" || ext == ".ts"
} }
// scan: doneAbs + 1-level subdirs, "keep" wird übersprungen candidates := make([]cleanupCandidate, 0, 1024)
scanDir := func(dir string, allowSubdirs bool) {
collectCandidates := func(dir string, allowSubdirs bool) {
ents, err := os.ReadDir(dir) ents, err := os.ReadDir(dir)
if err != nil { if err != nil {
return return
@ -125,10 +132,12 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
if err != nil { if err != nil {
continue continue
} }
for _, se := range sub { for _, se := range sub {
if se.IsDir() { if se.IsDir() {
continue continue
} }
name := se.Name() name := se.Name()
if !isCandidate(name) { if !isCandidate(name) {
resp.SkippedFiles++ resp.SkippedFiles++
@ -142,31 +151,15 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
continue continue
} }
resp.ScannedFiles++ candidates = append(candidates, cleanupCandidate{
path: p,
if fi.Size() < threshold { name: name,
base := strings.TrimSuffix(filepath.Base(p), filepath.Ext(p)) size: fi.Size(),
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++
}
}
} }
continue continue
} }
// root-level file
name := e.Name() name := e.Name()
if !isCandidate(name) { if !isCandidate(name) {
resp.SkippedFiles++ resp.SkippedFiles++
@ -179,29 +172,43 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
continue continue
} }
resp.ScannedFiles++ candidates = append(candidates, cleanupCandidate{
path: full,
name: name,
size: fi.Size(),
})
}
}
if fi.Size() < threshold { collectCandidates(doneAbs, true)
base := strings.TrimSuffix(filepath.Base(full), filepath.Ext(full))
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) id := stripHotPrefix(base)
if derr := removeWithRetry(full); derr == nil || os.IsNotExist(derr) { if derr := removeWithRetry(c.path); derr == nil || os.IsNotExist(derr) {
resp.DeletedFiles++ resp.DeletedFiles++
resp.DeletedBytes += fi.Size() resp.DeletedBytes += c.size
if strings.TrimSpace(id) != "" { if strings.TrimSpace(id) != "" {
removeGeneratedForID(id) removeGeneratedForID(id)
} }
purgeDurationCacheForPath(full) purgeDurationCacheForPath(c.path)
} else { } else {
resp.ErrorCount++ resp.ErrorCount++
} }
} }
}
}
scanDir(doneAbs, true)
} }
var generatedGCRunning int32 var generatedGCRunning int32

View File

@ -7,12 +7,17 @@ import (
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
"time"
) )
type CleanupTaskState struct { type CleanupTaskState struct {
Running bool `json:"running"` Running bool `json:"running"`
Done int `json:"done"`
Total int `json:"total"`
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
CurrentFile string `json:"currentFile,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
FinishedAt *string `json:"finishedAt,omitempty"`
} }
type RegenerateAssetsTaskState struct { type RegenerateAssetsTaskState struct {
@ -37,6 +42,11 @@ var (
regenerateAssetsTaskState RegenerateAssetsTaskState regenerateAssetsTaskState RegenerateAssetsTaskState
) )
func cleanupNowISO() *string {
s := time.Now().Format(time.RFC3339)
return &s
}
func snapshotRegenerateAssetsTaskState() RegenerateAssetsTaskState { func snapshotRegenerateAssetsTaskState() RegenerateAssetsTaskState {
regenerateAssetsTaskMu.Lock() regenerateAssetsTaskMu.Lock()
st := regenerateAssetsTaskState st := regenerateAssetsTaskState
@ -77,6 +87,12 @@ func setRegenerateAssetsTaskError(file, errText string) {
regenerateAssetsTaskMu.Unlock() regenerateAssetsTaskMu.Unlock()
} }
func clearRegenerateAssetsTaskState() {
regenerateAssetsTaskMu.Lock()
regenerateAssetsTaskState = RegenerateAssetsTaskState{}
regenerateAssetsTaskMu.Unlock()
}
func snapshotCleanupTaskState() CleanupTaskState { func snapshotCleanupTaskState() CleanupTaskState {
cleanupTaskMu.Lock() cleanupTaskMu.Lock()
st := cleanupTaskState st := cleanupTaskState
@ -88,29 +104,49 @@ func setCleanupTaskRunning(text string) {
cleanupTaskMu.Lock() cleanupTaskMu.Lock()
cleanupTaskState = CleanupTaskState{ cleanupTaskState = CleanupTaskState{
Running: true, Running: true,
Done: 0,
Total: 0,
Text: strings.TrimSpace(text), Text: strings.TrimSpace(text),
CurrentFile: "",
Error: "", Error: "",
FinishedAt: nil,
} }
cleanupTaskMu.Unlock() 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) { func setCleanupTaskDone(text string) {
cleanupTaskMu.Lock() cleanupTaskMu.Lock()
cleanupTaskState = CleanupTaskState{ cleanupTaskState.Running = false
Running: false, cleanupTaskState.Text = strings.TrimSpace(text)
Text: strings.TrimSpace(text), cleanupTaskState.CurrentFile = ""
Error: "", cleanupTaskState.Error = ""
cleanupTaskState.FinishedAt = cleanupNowISO()
if cleanupTaskState.Total > 0 {
cleanupTaskState.Done = cleanupTaskState.Total
} }
cleanupTaskMu.Unlock() cleanupTaskMu.Unlock()
} }
func setCleanupTaskError(errText string) { func setCleanupTaskError(errText string) {
cleanupTaskMu.Lock() cleanupTaskMu.Lock()
cleanupTaskState = CleanupTaskState{ cleanupTaskState.Running = false
Running: false, cleanupTaskState.Error = strings.TrimSpace(errText)
Text: "", cleanupTaskState.CurrentFile = ""
Error: strings.TrimSpace(errText), cleanupTaskState.FinishedAt = cleanupNowISO()
}
cleanupTaskMu.Unlock() cleanupTaskMu.Unlock()
} }

View File

@ -69,7 +69,7 @@ type RecorderSettingsState = {
} }
type JobEvent = { type JobEvent = {
type?: 'job_upsert' | 'job_remove' | 'finished_postwork' type?: 'job_upsert' | 'job_remove'
model?: string model?: string
jobId?: string jobId?: string
status?: string status?: string
@ -604,17 +604,33 @@ export default function App() {
const loadSettingsTaskState = async () => { const loadSettingsTaskState = async () => {
try { try {
const res = await fetch('/api/tasks/generate-assets', { cache: 'no-store' as any }) const [genRes, statusRes] = await Promise.all([
if (!res.ok) { fetch('/api/tasks/generate-assets', { cache: 'no-store' as any }),
if (!cancelled) setSettingsTaskRunning(false) fetch('/api/tasks/status', { cache: 'no-store' as any }),
return ])
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 if (cancelled) return
const running = Boolean(data?.running) setSettingsTaskRunning(
setSettingsTaskRunning(running) generateAssetsRunning ||
regenerateAssetsRunning ||
cleanupRunning
)
} catch { } catch {
if (!cancelled) setSettingsTaskRunning(false) if (!cancelled) setSettingsTaskRunning(false)
} }
@ -1250,56 +1266,6 @@ export default function App() {
const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent
const modelKey = String(msg?.model ?? '').trim().toLowerCase() 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 (msg?.type === 'job_upsert') {
if (modelKey) { if (modelKey) {
const roomStatus = String(msg?.roomStatus ?? '').trim().toLowerCase() const roomStatus = String(msg?.roomStatus ?? '').trim().toLowerCase()
@ -2453,34 +2419,6 @@ export default function App() {
}, document.hidden ? 60000 : 5000) }, 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 applyAutostartState = (data: unknown) => {
const d = (data ?? {}) as any const d = (data ?? {}) as any
setAutostartState({ 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 loadJobs()
void loadDoneCount() void loadDoneCount()
// Optional initialer Fallback für autostart, bis erstes SSE kommt
void apiJSON<AutostartState>('/api/autostart/state', { cache: 'no-store' as any }) void apiJSON<AutostartState>('/api/autostart/state', { cache: 'no-store' as any })
.then((s) => { .then((s) => {
setAutostartState({ setAutostartState({
@ -2517,102 +2508,10 @@ export default function App() {
startFallbackPoll() 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('doneChanged', onDoneChanged as any)
es.addEventListener('autostart', onAutostart as any) es.addEventListener('autostart', onAutostart as any)
es.addEventListener('finishedPostwork', onFinishedPostwork 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 = () => { const onVis = () => {
if (document.hidden) return if (document.hidden) return
void loadJobs() void loadJobs()
@ -2626,11 +2525,6 @@ export default function App() {
return () => { return () => {
document.removeEventListener('visibilitychange', onVis) document.removeEventListener('visibilitychange', onVis)
if (doneCoalesceTimer != null) {
window.clearTimeout(doneCoalesceTimer)
}
stopFallbackPoll() stopFallbackPoll()
if (es) { if (es) {
@ -2650,18 +2544,8 @@ export default function App() {
eventSourceRef.current = null eventSourceRef.current = null
modelEventNamesRef.current = new Set() modelEventNamesRef.current = new Set()
es = null
} }
}, [ }, [authed, loadJobs, loadDoneCount, requestFinishedReload])
authed,
loadJobs,
loadDoneCount,
requestFinishedReload,
ensureModelEventListener,
doneJobs,
pendingWatchedRooms,
watchedModelKeysLower,
])
useEffect(() => { useEffect(() => {
const desired = new Set<string>() const desired = new Set<string>()
@ -3648,6 +3532,7 @@ export default function App() {
blurPreviews={Boolean(recSettings.blurPreviews)} blurPreviews={Boolean(recSettings.blurPreviews)}
teaserPlayback={recSettings.teaserPlayback ?? 'hover'} teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
teaserAudio={Boolean(recSettings.teaserAudio)} teaserAudio={Boolean(recSettings.teaserAudio)}
pauseTeasers={Boolean(detailsModelKey) || splitModalOpen}
assetNonce={assetNonce} assetNonce={assetNonce}
sortMode={doneSort} sortMode={doneSort}
onSortModeChange={(m) => { onSortModeChange={(m) => {

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,12 @@
import * as React from 'react' import * as React from 'react'
import Card from './Card' import Card from './Card'
import type { RecordJob } from '../../types' import type {
RecordJob,
FinishedPostworkSummary,
GalleryModelFlags,
InlinePlayState
} from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview' import FinishedVideoPreview from './FinishedVideoPreview'
import SwipeCard, { type SwipeCardHandle } from './SwipeCard' import SwipeCard, { type SwipeCardHandle } from './SwipeCard'
import RecordJobActions from './RecordJobActions' import RecordJobActions from './RecordJobActions'
@ -17,8 +22,13 @@ import {
shouldEnableTeaserAudio, shouldEnableTeaserAudio,
} from './teaserPlayback' } from './teaserPlayback'
import Checkbox from './Checkbox' import Checkbox from './Checkbox'
import { SpeakerWaveIcon } from '@heroicons/react/24/solid'
type InlinePlayState = { key: string; nonce: number } | null import {
buildSpriteFrameStyle,
parseJobMeta,
readPreviewSpriteInfo,
scrubProgressRatioFromIndex,
} from './previewSprite'
type Props = { type Props = {
rows: RecordJob[] rows: RecordJob[]
@ -70,15 +80,7 @@ type Props = {
releasePlayingFile: (file: string, opts?: { close?: boolean }) => Promise<void> releasePlayingFile: (file: string, opts?: { close?: boolean }) => Promise<void>
modelsByKey: Record<string, { modelsByKey: Record<string, GalleryModelFlags>
favorite?: boolean
liked?: boolean | null
watching?: boolean | null
tags?: string
// ✅ wie GalleryView: optionale Scrubber-Meta-Fallbacks
previewScrubberPath?: string
previewScrubberCount?: number
}>
activeTagSet: Set<string> activeTagSet: Set<string>
onToggleTagFilter: (tag: string) => void onToggleTagFilter: (tag: string) => void
onToggleHot?: (job: RecordJob) => void | Promise<void> onToggleHot?: (job: RecordJob) => void | Promise<void>
@ -88,35 +90,17 @@ type Props = {
onSplit?: (job: RecordJob) => void | Promise<void> onSplit?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean> onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
postworkByFile: Record<string, { postworkByFile: Record<string, FinishedPostworkSummary>
file: string getPostworkSummaryForOutput: (
assetId?: string output: string | undefined,
queue: 'postwork' | 'enrich' summaries: Record<string, FinishedPostworkSummary>
state: 'queued' | 'running' | 'done' | 'error' | 'missing' ) => FinishedPostworkSummary | undefined
phase?: string
label?: string
position?: number
waiting?: number
running?: number
maxParallel?: number
ts: number
}>
renderPostworkBadge?: (badge?: { renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode
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
forcePreviewMuted?: boolean forcePreviewMuted?: boolean
mobileTeaserAudioUnlocked?: boolean
onUnlockMobileTeaserAudio?: () => void
} }
const parseTags = (raw?: string): string[] => { const parseTags = (raw?: string): string[] => {
@ -138,58 +122,6 @@ const parseTags = (raw?: string): string[] => {
return out 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({ function CardBlurWrapper({
blurred, blurred,
animateUnblurOnMount, animateUnblurOnMount,
@ -420,88 +352,49 @@ export default function FinishedDownloadsCardsView({
onSplit, onSplit,
onAddToDownloads, onAddToDownloads,
postworkByFile, postworkByFile,
getPostworkSummaryForOutput,
renderPostworkBadge, renderPostworkBadge,
forcePreviewMuted, forcePreviewMuted,
mobileTeaserAudioUnlocked,
onUnlockMobileTeaserAudio,
}: Props) { }: Props) {
const hasAnySelection = selectedKeys.size > 0 const hasAnySelection = selectedKeys.size > 0
const footerTapRef = React.useRef<Record<string, boolean>>({}) const footerTapRef = React.useRef<Record<string, boolean>>({})
const teaserPlayFnsRef = React.useRef<Record<string, (() => void) | null>>({})
const [speedHoldKey, setSpeedHoldKey] = React.useState<string | null>(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 parseMeta = React.useCallback((j: RecordJob): any | null => {
const raw: any = (j as any)?.meta return parseJobMeta((j as any)?.meta)
if (!raw) return null
if (typeof raw === 'string') {
try {
return JSON.parse(raw)
} catch {
return null
}
}
return raw
}, []) }, [])
const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => { const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => {
const meta = parseMeta(j) const meta = parseMeta(j)
const w = const w =
(typeof meta?.videoWidth === 'number' && Number.isFinite(meta.videoWidth) ? meta.videoWidth : 0) || typeof meta?.file?.video?.width === 'number' && Number.isFinite(meta.file.video.width)
(typeof j.videoWidth === 'number' && Number.isFinite(j.videoWidth) ? j.videoWidth : 0) ? meta.file.video.width
: 0
const h = const h =
(typeof meta?.videoHeight === 'number' && Number.isFinite(meta.videoHeight) ? meta.videoHeight : 0) || typeof meta?.file?.video?.height === 'number' && Number.isFinite(meta.file.video.height)
(typeof j.videoHeight === 'number' && Number.isFinite(j.videoHeight) ? j.videoHeight : 0) ? meta.file.video.height
: 0
if (w > 0 && h > 0) return { w, h } if (w > 0 && h > 0) return { w, h }
return null return null
}, [parseMeta]) }, [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<Record<string, number | undefined>>({}) const [scrubActiveByKey, setScrubActiveByKey] = React.useState<Record<string, number | undefined>>({})
const [scrubHoveringByKey, setScrubHoveringByKey] = React.useState<Record<string, boolean | undefined>>({}) const [scrubHoveringByKey, setScrubHoveringByKey] = React.useState<Record<string, boolean | undefined>>({})
@ -566,13 +459,16 @@ export default function FinishedDownloadsCardsView({
disabled: Boolean(opts?.forceStill), disabled: Boolean(opts?.forceStill),
}) })
const isMobileTopCard = Boolean(isSmall && opts?.mobileStackTopOnlyVideo)
const previewMuted = forcePreviewMuted const previewMuted = forcePreviewMuted
? true ? true
: ( : (
isSmall && opts?.mobileStackTopOnlyVideo isSmall && opts?.mobileStackTopOnlyVideo
? true ? teaserAudioEnabledForKey !== k
: !allowSound : !allowSound
) )
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0 const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
const forceLoadStill = Boolean(opts?.forceLoadStill) const forceLoadStill = Boolean(opts?.forceLoadStill)
@ -595,133 +491,33 @@ export default function FinishedDownloadsCardsView({
const model = modelNameFromOutput(j.output) const model = modelNameFromOutput(j.output)
const fileRaw = baseName(j.output || '') const fileRaw = baseName(j.output || '')
const livePostwork = const livePostwork = getPostworkSummaryForOutput(j.output, postworkByFile)
postworkByFile[fileRaw] ??
postworkByFile[stripHotPrefix(fileRaw)]
const previewId = stripHotPrefix(fileRaw.replace(/\.[^.]+$/, '')).trim()
const modelKey = lower(model) const modelKey = lower(model)
const flags = modelsByKey[modelKey] const flags = modelsByKey[modelKey]
const meta = parseMeta(j)
const spriteInfo = previewScrubberInfoOf(j)
const scrubActiveIndex = scrubActiveByKey[k] const scrubActiveIndex = scrubActiveByKey[k]
const scrubHovering = scrubHoveringByKey[k] === true const scrubHovering = scrubHoveringByKey[k] === true
// ✅ Sprite-Quelle wie in GalleryView (1 Request, danach nur CSS background-position) const sprite = readPreviewSpriteInfo((j as any)?.meta, {
const spritePathRaw = firstNonEmptyString( fallbackPath: flags?.previewScrubberPath,
meta?.previewSprite?.path, fallbackCount: flags?.previewScrubberCount,
(meta as any)?.previewSpritePath, versionFallback: assetNonce ?? 0,
flags?.previewScrubberPath, })
previewId ? `/api/preview-sprite/${encodeURIComponent(previewId)}` : undefined
)
const spritePath = spritePathRaw ? spritePathRaw.replace(/\/+$/, '') : undefined
const spriteStepSecondsRaw = const spriteUrl = sprite.url
meta?.previewSprite?.stepSeconds ?? (meta as any)?.previewSpriteStepSeconds const hasScrubberUi = sprite.hasScrubberUi
const hasSpriteScrubber = sprite.hasSpriteScrubber
const scrubberCount = hasScrubberUi ? sprite.count : 0
const scrubberStepSeconds = hasScrubberUi ? sprite.stepSeconds : 0
const spriteStepSeconds = const scrubProgressRatio = scrubProgressRatioFromIndex(scrubActiveIndex, scrubberCount)
typeof spriteStepSecondsRaw === 'number' &&
Number.isFinite(spriteStepSecondsRaw) &&
spriteStepSecondsRaw > 0
? spriteStepSecondsRaw
: (spriteInfo?.stepSeconds && spriteInfo.stepSeconds > 0
? spriteInfo.stepSeconds
: DEFAULT_SPRITE_STEP_SECONDS)
// Dauer fallback (für count-Inferenz) const spriteFrameStyle = buildSpriteFrameStyle({
const durationForSprite = spriteUrl,
normalizeDurationSeconds(meta?.durationSeconds) ?? spriteCols: sprite.cols,
normalizeDurationSeconds((j as any)?.durationSeconds) ?? spriteRows: sprite.rows,
normalizeDurationSeconds(durations[k]) spriteCount: sprite.count,
activeIndex: scrubActiveIndex,
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 isHot = isHotName(fileRaw) const isHot = isHotName(fileRaw)
const isFav = Boolean(flags?.favorite) const isFav = Boolean(flags?.favorite)
@ -747,9 +543,17 @@ export default function FinishedDownloadsCardsView({
allowTeaserAnimation ? 'anim' : 'still', allowTeaserAnimation ? 'anim' : 'still',
].join('::') ].join('::')
const showMobileAudioButton =
Boolean(onUnlockMobileTeaserAudio) &&
isMobileTopCard &&
!inlineActive &&
!opts?.forceStill &&
allowTeaserAnimation &&
audioButtonDismissedForKey !== k
// ✅ Shell an Gallery angelehnt // ✅ Shell an Gallery angelehnt
const shellCls = [ 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', 'bg-white dark:bg-gray-900/40',
'transition-all duration-200', 'transition-all duration-200',
!isSmall && 'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none', !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))} getFileName={(p) => stripHotPrefix(baseName(p))}
className="h-full w-full" className="h-full w-full"
variant="fill" variant="fill"
durationSeconds={durations[k] ?? (j as any)?.durationSeconds} durationSeconds={durations[k] ?? (j as any)?.meta?.file?.durationSeconds}
onDuration={handleDuration} onDuration={handleDuration}
showPopover={false} showPopover={false}
blur={inlineActive ? false : Boolean(blurPreviews)} blur={inlineActive ? false : Boolean(blurPreviews)}
@ -934,8 +738,34 @@ export default function FinishedDownloadsCardsView({
? mobileTopActivationNonce ? mobileTopActivationNonce
: 0 : 0
} }
registerTeaserPlay={registerTeaserPlayForKey(k)}
holdSpeedActive={speedHoldKey === k}
/> />
{showMobileAudioButton ? (
<button
type="button"
className="absolute top-2 right-2 z-[35] inline-flex items-center gap-1.5 rounded-full bg-black/70 px-2.5 py-1.5 text-[11px] font-medium text-white backdrop-blur hover:bg-black/80"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setAudioButtonDismissedForKey(k)
setTeaserAudioEnabledForKey(k)
onUnlockMobileTeaserAudio?.()
teaserPlayFnsRef.current[k]?.()
}}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
aria-label="Teaser mit Ton abspielen"
title="Ton einschalten"
>
<SpeakerWaveIcon className="size-3.5 shrink-0" />
<span>Ton aktivieren</span>
</button>
) : null}
{/* ✅ Sprite einmal vorladen, damit der erste Scrub-Move sofort sichtbar ist */} {/* ✅ Sprite einmal vorladen, damit der erste Scrub-Move sofort sichtbar ist */}
{hasSpriteScrubber && spriteUrl ? ( {hasSpriteScrubber && spriteUrl ? (
<img <img
@ -1010,7 +840,7 @@ export default function FinishedDownloadsCardsView({
{/* Footer / Meta (wie Gallery strukturiert) */} {/* Footer / Meta (wie Gallery strukturiert) */}
<div <div
className="relative min-h-[112px] overflow-hidden px-4 py-3 border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900" className="relative min-h-[112px] overflow-visible px-4 py-3 border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900"
onPointerDownCapture={() => { onPointerDownCapture={() => {
footerTapRef.current[k] = true footerTapRef.current[k] = true
}} }}
@ -1028,7 +858,7 @@ export default function FinishedDownloadsCardsView({
</div> </div>
{renderPostworkBadge && livePostwork ? ( {renderPostworkBadge && livePostwork ? (
<div className="shrink-0"> <div className="relative z-20 shrink-0">
{renderPostworkBadge(livePostwork)} {renderPostworkBadge(livePostwork)}
</div> </div>
) : null} ) : null}
@ -1105,6 +935,8 @@ export default function FinishedDownloadsCardsView({
isHot, isHot,
inlineDomId, inlineDomId,
cardInner, cardInner,
inlineActive,
allowTeaserAnimation,
} }
} }
@ -1127,6 +959,21 @@ export default function FinishedDownloadsCardsView({
const [mobileTopTapEnabled, setMobileTopTapEnabled] = React.useState(true) const [mobileTopTapEnabled, setMobileTopTapEnabled] = React.useState(true)
const mobileTopTapEnableTimerRef = React.useRef<number | null>(null) const mobileTopTapEnableTimerRef = React.useRef<number | null>(null)
const [audioButtonDismissedForKey, setAudioButtonDismissedForKey] = React.useState<string | null>(null)
const [teaserAudioEnabledForKey, setTeaserAudioEnabledForKey] = React.useState<string | null>(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(() => { React.useEffect(() => {
if (mobileTopTeaserEnableTimerRef.current != null) { if (mobileTopTeaserEnableTimerRef.current != null) {
@ -1139,6 +986,9 @@ export default function FinishedDownloadsCardsView({
mobileTopTapEnableTimerRef.current = null mobileTopTapEnableTimerRef.current = null
} }
setAudioButtonDismissedForKey(null)
setTeaserAudioEnabledForKey(null)
if (!isSmall || !mobileTopKey) { if (!isSmall || !mobileTopKey) {
setMobileTopTeaserEnabled(true) setMobileTopTeaserEnabled(true)
setMobileTopTapEnabled(true) setMobileTopTapEnabled(true)
@ -1301,7 +1151,7 @@ export default function FinishedDownloadsCardsView({
{/* Oberste Karte im Flow -> bestimmt die echte Höhe */} {/* Oberste Karte im Flow -> bestimmt die echte Höhe */}
{topRow ? (() => { {topRow ? (() => {
const j = topRow const j = topRow
const { k, busy, isHot, cardInner, inlineDomId } = renderCardItem(j, { const { k, busy, isHot, cardInner, inlineDomId, inlineActive, allowTeaserAnimation } = renderCardItem(j, {
forceLoadStill: false, forceLoadStill: false,
forceStill: !mobileTopTeaserEnabled, forceStill: !mobileTopTeaserEnabled,
mobileStackTopOnlyVideo: true, mobileStackTopOnlyVideo: true,
@ -1327,6 +1177,8 @@ export default function FinishedDownloadsCardsView({
ignoreFromBottomPx={110} ignoreFromBottomPx={110}
doubleTapMs={360} doubleTapMs={360}
doubleTapMaxMovePx={48} doubleTapMaxMovePx={48}
pressDelayMs={220}
enablePressHold={!inlineActive && mobileTopTeaserEnabled && allowTeaserAnimation && !busy}
onDoubleTap={async () => { onDoubleTap={async () => {
if (isHot) return if (isHot) return
await onToggleHot?.(j) await onToggleHot?.(j)
@ -1360,6 +1212,13 @@ export default function FinishedDownloadsCardsView({
setMobileTopTeaserEnabled(false) setMobileTopTeaserEnabled(false)
return await keepVideo(j) return await keepVideo(j)
}} }}
onPressStart={(info) => {
if (info.pointerType === 'mouse' && info.button !== 0) return
setSpeedHoldKey(k)
}}
onPressEnd={() => {
setSpeedHoldKey((prev) => (prev === k ? null : prev))
}}
> >
{cardInner} {cardInner}
</SwipeCard> </SwipeCard>

View File

@ -2,7 +2,11 @@
'use client' 'use client'
import * as React from 'react' import * as React from 'react'
import type { RecordJob } from '../../types' import type {
RecordJob,
GalleryModelFlags,
FinishedPostworkSummary,
} from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview' import FinishedVideoPreview from './FinishedVideoPreview'
import RecordJobActions from './RecordJobActions' import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow' import TagOverflowRow from './TagOverflowRow'
@ -16,35 +20,13 @@ import {
shouldObserveTeasers, shouldObserveTeasers,
} from './teaserPlayback' } from './teaserPlayback'
import Checkbox from './Checkbox' import Checkbox from './Checkbox'
import {
type ModelFlags = { buildSpriteFrameStyle,
favorite?: boolean parseJobMeta,
liked?: boolean | null firstNonEmptyString,
watching?: boolean | null readPreviewSpriteInfo,
tags?: string scrubProgressRatioFromIndex,
} from './previewSprite'
// ✅ 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
}
type Props = { type Props = {
rows: RecordJob[] rows: RecordJob[]
@ -54,9 +36,13 @@ type Props = {
isLoading?: boolean isLoading?: boolean
blurPreviews?: boolean blurPreviews?: boolean
durations: Record<string, number> durations: Record<string, number>
postworkByFile: Record<string, FinishedPostworkBadge> postworkByFile: Record<string, FinishedPostworkSummary>
getPostworkSummaryForOutput: (
output: string | undefined,
summaries: Record<string, FinishedPostworkSummary>
) => FinishedPostworkSummary | undefined
teaserState: FinishedDownloadsTeaserState teaserState: FinishedDownloadsTeaserState
renderPostworkBadge?: (badge?: FinishedPostworkBadge) => React.ReactNode renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode
handleDuration: (job: RecordJob, seconds: number) => void handleDuration: (job: RecordJob, seconds: number) => void
@ -83,7 +69,7 @@ type Props = {
lower: (s: string) => string lower: (s: string) => string
modelsByKey: Record<string, ModelFlags> modelsByKey: Record<string, GalleryModelFlags>
activeTagSet: Set<string> activeTagSet: Set<string>
onHoverPreviewKeyChange?: (key: string | null) => void onHoverPreviewKeyChange?: (key: string | null) => void
onToggleTagFilter: (tag: string) => void onToggleTagFilter: (tag: string) => void
@ -97,77 +83,19 @@ type Props = {
forcePreviewMuted?: boolean 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 { function normalizeDurationSeconds(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
// ms -> s Heuristik wie in FinishedVideoPreview // ms -> s Heuristik wie in FinishedVideoPreview
return value > 24 * 60 * 60 ? value / 1000 : value 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({ export default function FinishedDownloadsGalleryView({
rows, rows,
isLoading, isLoading,
blurPreviews, blurPreviews,
durations, durations,
postworkByFile, postworkByFile,
getPostworkSummaryForOutput,
teaserState, teaserState,
renderPostworkBadge, renderPostworkBadge,
selectedKeys, selectedKeys,
@ -242,22 +170,27 @@ export default function FinishedDownloadsGalleryView({
// ✅ Auflösung als {w,h} aus meta.json bevorzugen // ✅ Auflösung als {w,h} aus meta.json bevorzugen
const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => { const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => {
const w = const meta = parseJobMeta((j as any)?.meta)
(typeof (j as any)?.meta?.videoWidth === 'number' && Number.isFinite((j as any).meta.videoWidth)
? (j as any).meta.videoWidth const w =
: 0) || meta?.media?.video?.width ??
(typeof (j as any)?.videoWidth === 'number' && Number.isFinite((j as any).videoWidth) meta?.file?.video?.width
? (j as any).videoWidth
: 0) const h =
const h = meta?.media?.video?.height ??
(typeof (j as any)?.meta?.videoHeight === 'number' && Number.isFinite((j as any).meta.videoHeight) meta?.file?.video?.height
? (j as any).meta.videoHeight
: 0) || if (
(typeof (j as any)?.videoHeight === 'number' && Number.isFinite((j as any).videoHeight) typeof w === 'number' &&
? (j as any).videoHeight Number.isFinite(w) &&
: 0) w > 0 &&
typeof h === 'number' &&
Number.isFinite(h) &&
h > 0
) {
return { w, h }
}
if (w > 0 && h > 0) return { w, h }
return null return null
}, []) }, [])
@ -317,15 +250,8 @@ export default function FinishedDownloadsGalleryView({
const isHot = isHotName(fileRaw) const isHot = isHotName(fileRaw)
const file = stripHotPrefix(fileRaw) const file = stripHotPrefix(fileRaw)
const postworkBadge = const postworkBadge = getPostworkSummaryForOutput(j.output, postworkByFile)
postworkByFile[fileRaw] ?? const showPostworkBadge = !!postworkBadge
postworkByFile[stripHotPrefix(fileRaw)]
const showPostworkBadge =
postworkBadge &&
(postworkBadge.state === 'queued' ||
postworkBadge.state === 'running' ||
postworkBadge.state === 'done' ||
postworkBadge.state === 'error')
const dur = runtimeOf(j) const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j)) const size = formatBytes(sizeBytesOf(j))
@ -346,10 +272,6 @@ export default function FinishedDownloadsGalleryView({
(j as any)?.meta?.modelImageUrl (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) // meta robust lesen (Objekt oder JSON-String)
const meta = parseJobMeta((j as any)?.meta) const meta = parseJobMeta((j as any)?.meta)
@ -357,119 +279,30 @@ export default function FinishedDownloadsGalleryView({
// ✅ STASHAPP-LIKE: Sprite-Preview (1 Bild + CSS background-position) // ✅ STASHAPP-LIKE: Sprite-Preview (1 Bild + CSS background-position)
// Erwartet z.B. meta.previewSprite = { path, count, cols, rows, stepSeconds } // Erwartet z.B. meta.previewSprite = { path, count, cols, rows, stepSeconds }
// ------------------------------------------------------------ // ------------------------------------------------------------
const spritePathRaw = firstNonEmptyString( const sprite = readPreviewSpriteInfo((j as any)?.meta, {
meta?.previewSprite?.path, fallbackPath: flags?.previewScrubberPath,
(meta as any)?.previewSpritePath, fallbackCount: flags?.previewScrubberCount,
flags?.previewScrubberPath, })
previewId ? `/api/preview-sprite/${encodeURIComponent(previewId)}` : undefined
)
const spritePath = spritePathRaw ? spritePathRaw.replace(/\/+$/, '') : undefined
const spriteStepSecondsRaw = const spriteUrl = sprite.url
meta?.previewSprite?.stepSeconds ?? (meta as any)?.previewSpriteStepSeconds const hasScrubberUi = sprite.hasScrubberUi
const hasSpriteScrubber = sprite.hasSpriteScrubber
const spriteStepSeconds = const scrubberCount = sprite.count
typeof spriteStepSecondsRaw === 'number' && const scrubberStepSeconds = sprite.stepSeconds
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 hasScrubber = hasScrubberUi const hasScrubber = hasScrubberUi
const activeScrubIndex = scrubIndexByKey[k] const activeScrubIndex = scrubIndexByKey[k]
const scrubProgressRatio = const scrubProgressRatio = scrubProgressRatioFromIndex(activeScrubIndex, scrubberCount)
typeof activeScrubIndex === 'number' && scrubberCount > 1
? clamp(activeScrubIndex / (scrubberCount - 1), 0, 1)
: undefined
// Sprite-Overlay-Frame (kein Request pro Move) // Sprite-Overlay-Frame (kein Request pro Move)
const spriteFrameStyle: React.CSSProperties | undefined = const spriteFrameStyle = buildSpriteFrameStyle({
hasSpriteScrubber && typeof activeScrubIndex === 'number' spriteUrl,
? (() => { spriteCols: sprite.cols,
const idx = clamp(activeScrubIndex, 0, Math.max(0, spriteCount - 1)) spriteRows: sprite.rows,
const col = idx % spriteCols spriteCount: sprite.count,
const row = Math.floor(idx / spriteCols) activeIndex: activeScrubIndex,
})
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 showModelPreviewInThumb = hoveredModelPreviewKey === k && Boolean(modelImageSrc) const showModelPreviewInThumb = hoveredModelPreviewKey === k && Boolean(modelImageSrc)
const showScrubberSpriteInThumb = !showModelPreviewInThumb && Boolean(spriteFrameStyle) const showScrubberSpriteInThumb = !showModelPreviewInThumb && Boolean(spriteFrameStyle)
@ -477,6 +310,11 @@ export default function FinishedDownloadsGalleryView({
const hideTeaserUnderOverlay = const hideTeaserUnderOverlay =
showModelPreviewInThumb || showScrubberSpriteInThumb showModelPreviewInThumb || showScrubberSpriteInThumb
const previewDurationSeconds =
durations[k] ??
normalizeDurationSeconds(meta?.media?.durationSeconds) ??
normalizeDurationSeconds(meta?.file?.durationSeconds)
return ( return (
<div key={k} className="relative"> <div key={k} className="relative">
<div <div
@ -509,24 +347,7 @@ export default function FinishedDownloadsGalleryView({
> >
<div <div
className={[ className={[
'absolute left-3 top-3 z-20 transition-opacity duration-150', 'relative rounded-lg overflow-visible outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
].join(' ')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onMouseDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={checked}
ariaLabel={`${checked ? 'Abwählen' : 'Auswählen'}: ${baseName(j.output || '') || 'Download'}`}
onChange={() => onToggleSelected(j)}
/>
</div>
<div
className={[
'relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
'bg-white dark:bg-gray-900/40', 'bg-white dark:bg-gray-900/40',
'transition-[transform,opacity,box-shadow,background-color] duration-220 ease-out', 'transition-[transform,opacity,box-shadow,background-color] duration-220 ease-out',
'will-change-[transform,opacity]', 'will-change-[transform,opacity]',
@ -543,6 +364,24 @@ export default function FinishedDownloadsGalleryView({
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
> >
<div
className={[
'absolute left-3 top-3 z-20 transition-opacity duration-150',
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
].join(' ')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onMouseDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={checked}
ariaLabel={`${checked ? 'Abwählen' : 'Auswählen'}: ${baseName(j.output || '') || 'Download'}`}
onChange={() => onToggleSelected(j)}
/>
</div>
{/* Thumb */} {/* Thumb */}
<div <div
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5" className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
@ -563,7 +402,7 @@ export default function FinishedDownloadsGalleryView({
key={`gallery-preview-${k}-${isPreviewActive ? 'active' : 'idle'}`} key={`gallery-preview-${k}-${isPreviewActive ? 'active' : 'idle'}`}
job={j} job={j}
getFileName={(p) => stripHotPrefix(baseName(p))} getFileName={(p) => stripHotPrefix(baseName(p))}
durationSeconds={durations[k] ?? (j as any)?.durationSeconds} durationSeconds={previewDurationSeconds}
onDuration={handleDuration} onDuration={handleDuration}
variant="fill" variant="fill"
showPopover={false} showPopover={false}
@ -674,30 +513,12 @@ export default function FinishedDownloadsGalleryView({
{/* Model oben + Badge direkt dahinter */} {/* Model oben + Badge direkt dahinter */}
<div className="min-w-0"> <div className="min-w-0">
<div className="mt-0.5 flex items-start justify-between gap-2 min-w-0"> <div className="mt-0.5 flex items-start justify-between gap-2 min-w-0">
<span <div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
className={[ {model}
'block min-w-0 flex-1 truncate text-sm font-semibold text-gray-900 dark:text-white', </div>
modelImageSrc ? 'cursor-zoom-in hover:text-gray-700 dark:hover:text-gray-200' : '',
]
.filter(Boolean)
.join(' ')}
title={modelImageSrc ? `${model} (Hover: Model-Preview)` : model}
onMouseEnter={(e) => {
e.stopPropagation()
if (modelImageSrc) setHoveredModelPreviewKey(k)
}}
onMouseLeave={(e) => {
e.stopPropagation()
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
}}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{model || '—'}
</span>
{showPostworkBadge && renderPostworkBadge ? ( {showPostworkBadge && renderPostworkBadge ? (
<div className="shrink-0"> <div className="relative z-20 shrink-0">
{renderPostworkBadge(postworkBadge)} {renderPostworkBadge(postworkBadge)}
</div> </div>
) : null} ) : null}

View File

@ -4,7 +4,12 @@
import * as React from 'react' import * as React from 'react'
import Table, { type Column, type SortState } from './Table' import Table, { type Column, type SortState } from './Table'
import type { RecordJob } from '../../types' import type {
RecordJob,
FinishedPostworkSummary,
DoneSortMode,
StoredModelFlags,
} from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview' import FinishedVideoPreview from './FinishedVideoPreview'
import RecordJobActions from './RecordJobActions' import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow' import TagOverflowRow from './TagOverflowRow'
@ -16,30 +21,7 @@ import {
shouldEnableTeaserAudio, shouldEnableTeaserAudio,
} from './teaserPlayback' } from './teaserPlayback'
import Checkbox from './Checkbox' import Checkbox from './Checkbox'
import { parseJobMeta } from './previewSprite'
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 = { type Props = {
rows: RecordJob[] rows: RecordJob[]
@ -58,9 +40,13 @@ type Props = {
formatBytes: (bytes?: number | null) => string formatBytes: (bytes?: number | null) => string
resolutions: Record<string, { w: number; h: number }> resolutions: Record<string, { w: number; h: number }>
durations: Record<string, number> durations: Record<string, number>
postworkByFile: Record<string, FinishedPostworkBadge> postworkByFile: Record<string, FinishedPostworkSummary>
getPostworkSummaryForOutput: (
output: string | undefined,
summaries: Record<string, FinishedPostworkSummary>
) => FinishedPostworkSummary | undefined
jobForDetails: (job: RecordJob) => RecordJob jobForDetails: (job: RecordJob) => RecordJob
renderPostworkBadge?: (badge?: FinishedPostworkBadge) => React.ReactNode renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode
// teaser/preview // teaser/preview
canHover: boolean canHover: boolean
@ -77,14 +63,14 @@ type Props = {
keepingKeys: Set<string> keepingKeys: Set<string>
removingKeys: Set<string> removingKeys: Set<string>
modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean | null; tags?: string }> modelsByKey: Record<string, StoredModelFlags>
activeTagSet: Set<string> activeTagSet: Set<string>
onToggleTagFilter: (tag: string) => void onToggleTagFilter: (tag: string) => void
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
// actions // actions
onOpenPlayer: (job: RecordJob) => void onOpenPlayer: (job: RecordJob) => void
onSortModeChange: (m: SortMode) => void onSortModeChange: (m: DoneSortMode) => void
page: number page: number
onPageChange: (page: number) => void onPageChange: (page: number) => void
@ -116,6 +102,7 @@ export default function FinishedDownloadsTableView({
resolutions, resolutions,
durations, durations,
postworkByFile, postworkByFile,
getPostworkSummaryForOutput,
jobForDetails, jobForDetails,
renderPostworkBadge, renderPostworkBadge,
@ -156,7 +143,7 @@ export default function FinishedDownloadsTableView({
const runtimeSecondsForSort = React.useCallback((job: RecordJob) => { const runtimeSecondsForSort = React.useCallback((job: RecordJob) => {
// 1) Prefer real video duration (ffprobe / backend) // 1) Prefer real video duration (ffprobe / backend)
const sec = (job as any)?.durationSeconds const sec = (job as any)?.meta?.file?.durationSeconds
if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) return sec if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) return sec
// 2) Fallback: endedAt-startedAt (only if plausible) // 2) Fallback: endedAt-startedAt (only if plausible)
@ -191,16 +178,7 @@ export default function FinishedDownloadsTableView({
}, []) }, [])
const parseMeta = React.useCallback((j: RecordJob): any | null => { const parseMeta = React.useCallback((j: RecordJob): any | null => {
const raw: any = (j as any)?.meta return parseJobMeta((j as any)?.meta)
if (!raw) return null
if (typeof raw === 'string') {
try {
return JSON.parse(raw)
} catch {
return null
}
}
return raw
}, []) }, [])
const previewSpriteInfoOf = React.useCallback( const previewSpriteInfoOf = React.useCallback(
@ -329,7 +307,7 @@ export default function FinishedDownloadsTableView({
key={`table-preview-${k}-${isPreviewActive ? 'active' : 'idle'}`} key={`table-preview-${k}-${isPreviewActive ? 'active' : 'idle'}`}
job={jobForDetails(j)} job={jobForDetails(j)}
getFileName={baseName} getFileName={baseName}
durationSeconds={durations[k]} durationSeconds={durations[k] ?? (j as any)?.meta?.file?.durationSeconds}
muted={previewMuted} muted={previewMuted}
popoverMuted={previewMuted} popoverMuted={previewMuted}
onDuration={handleDuration} onDuration={handleDuration}
@ -382,15 +360,9 @@ export default function FinishedDownloadsTableView({
const modelKey = lower(modelNameFromOutput(j.output)) const modelKey = lower(modelNameFromOutput(j.output))
const tags = parseTags(modelsByKey[modelKey]?.tags) const tags = parseTags(modelsByKey[modelKey]?.tags)
const postworkBadge = const postworkBadge = getPostworkSummaryForOutput(j.output, postworkByFile)
postworkByFile[fileRaw] ??
postworkByFile[stripHotPrefix(fileRaw)]
const showPostworkBadge = const showPostworkBadge =
postworkBadge && !!postworkBadge && postworkBadge.state !== 'missing'
(postworkBadge.state === 'queued' ||
postworkBadge.state === 'running' ||
postworkBadge.state === 'done' ||
postworkBadge.state === 'error')
return ( return (
<div className="min-w-0"> <div className="min-w-0">
@ -587,7 +559,7 @@ export default function FinishedDownloadsTableView({
someSelectedOnPage, someSelectedOnPage,
]) ])
const sortStateToMode = React.useCallback((s: SortState): SortMode => { const sortStateToMode = React.useCallback((s: SortState): DoneSortMode => {
if (!s) return 'completed_desc' if (!s) return 'completed_desc'
const anyS = s as any const anyS = s as any
const key = String(anyS.key ?? anyS.columnKey ?? anyS.id ?? '') const key = String(anyS.key ?? anyS.columnKey ?? anyS.id ?? '')

View File

@ -83,6 +83,7 @@ export type FinishedVideoPreviewProps = {
forceActive?: boolean forceActive?: boolean
activationNonce?: number activationNonce?: number
registerTeaserPlay?: (fn: (() => void) | null) => void registerTeaserPlay?: (fn: (() => void) | null) => void
holdSpeedActive?: boolean
} }
function baseName(path: string) { function baseName(path: string) {
@ -131,6 +132,7 @@ export default function FinishedVideoPreview({
forceActive= false, forceActive= false,
activationNonce = 0, activationNonce = 0,
registerTeaserPlay, registerTeaserPlay,
holdSpeedActive = false,
}: FinishedVideoPreviewProps) { }: FinishedVideoPreviewProps) {
const file = baseName(job.output || '') || getFileName(job.output || '') const file = baseName(job.output || '') || getFileName(job.output || '')
const blurCls = blur ? 'blur-md' : '' const blurCls = blur ? 'blur-md' : ''
@ -390,32 +392,65 @@ export default function FinishedVideoPreview({
const inlineRef = useRef<HTMLVideoElement | null>(null) const inlineRef = useRef<HTMLVideoElement | null>(null)
const teaserMp4Ref = useRef<HTMLVideoElement | null>(null) const teaserMp4Ref = useRef<HTMLVideoElement | null>(null)
const clipsRef = useRef<HTMLVideoElement | null>(null) const clipsRef = useRef<HTMLVideoElement | null>(null)
const teaserSoundForcedRef = useRef(false)
const playTeaserFromGesture = useCallback(() => { const setTeaserPlaybackRate = useCallback((rate: number) => {
const el = teaserMp4Ref.current const el = teaserMp4Ref.current
if (!el) return if (!el) return
applyInlineVideoPolicy(el, { muted }) try {
el.playbackRate = rate
el.defaultPlaybackRate = rate
} catch {
// ignore
}
}, [])
const playTeaserFromGesture = useCallback((withSound = true) => {
const el = teaserMp4Ref.current
if (!el) return false
const nextMuted = !withSound
applyInlineVideoPolicy(el, { muted: nextMuted })
try { try {
el.muted = muted el.pause()
el.defaultMuted = muted } catch {}
try {
el.muted = nextMuted
el.defaultMuted = nextMuted
el.playsInline = true el.playsInline = true
el.loop = true el.loop = true
el.autoplay = true el.autoplay = true
el.currentTime = 0
} catch {} } catch {}
const p = el.play?.() const p = el.play?.()
if (p && typeof (p as any).catch === 'function') { if (p && typeof (p as any).catch === 'function') {
;(p as Promise<void>).catch(() => {}) ;(p as Promise<void>).catch(() => {})
} }
}, [muted])
return true
}, [])
useEffect(() => {
return () => {
setTeaserPlaybackRate(1)
}
}, [setTeaserPlaybackRate])
useEffect(() => { useEffect(() => {
if (!registerTeaserPlay) return if (!registerTeaserPlay) return
registerTeaserPlay(playTeaserFromGesture)
registerTeaserPlay(() => {
teaserSoundForcedRef.current = true
return playTeaserFromGesture(true)
})
return () => registerTeaserPlay(null) return () => registerTeaserPlay(null)
}, [registerTeaserPlay, playTeaserFromGesture]) }, [registerTeaserPlay, playTeaserFromGesture])
const teaserPlayRetryTimerRef = useRef<number | null>(null) const teaserPlayRetryTimerRef = useRef<number | null>(null)
const teaserLastProgressTimeRef = useRef<number>(0) const teaserLastProgressTimeRef = useRef<number>(0)
@ -557,6 +592,7 @@ export default function FinishedVideoPreview({
// ✅ neue Datei/Asset -> Callback-Dedupe zurücksetzen // ✅ neue Datei/Asset -> Callback-Dedupe zurücksetzen
emittedDurationRef.current = '' emittedDurationRef.current = ''
emittedResolutionRef.current = '' emittedResolutionRef.current = ''
teaserSoundForcedRef.current = false
setMetaLoaded(false) setMetaLoaded(false)
}, [previewId, assetNonce, noGenerateTeaser]) }, [previewId, assetNonce, noGenerateTeaser])
@ -698,6 +734,13 @@ export default function FinishedVideoPreview({
(animatedMode === 'clips' && hasDuration) (animatedMode === 'clips' && hasDuration)
) )
const canHoldSpeedTeaser =
teaserActive &&
animatedMode === 'teaser' &&
!showingInlineVideo
const teaserSpeedHold = holdSpeedActive && canHoldSpeedTeaser
const progressTotalSeconds = const progressTotalSeconds =
hasDuration && typeof effectiveDurationSec === 'number' ? effectiveDurationSec : undefined hasDuration && typeof effectiveDurationSec === 'number' ? effectiveDurationSec : undefined
@ -967,6 +1010,10 @@ export default function FinishedVideoPreview({
const active = teaserActive && animatedMode === 'teaser' const active = teaserActive && animatedMode === 'teaser'
if (!active) {
setTeaserPlaybackRate(1)
}
const clearRetryTimer = () => { const clearRetryTimer = () => {
if (teaserPlayRetryTimerRef.current != null) { if (teaserPlayRetryTimerRef.current != null) {
window.clearTimeout(teaserPlayRetryTimerRef.current) window.clearTimeout(teaserPlayRetryTimerRef.current)
@ -992,14 +1039,18 @@ export default function FinishedVideoPreview({
return return
} }
applyInlineVideoPolicy(el, { muted }) const effectiveMuted = teaserSoundForcedRef.current ? false : muted
applyInlineVideoPolicy(el, { muted: effectiveMuted })
try { try {
el.muted = muted el.muted = effectiveMuted
el.defaultMuted = muted el.defaultMuted = effectiveMuted
el.playsInline = true el.playsInline = true
el.loop = true el.loop = true
el.autoplay = true el.autoplay = true
el.playbackRate = teaserSpeedHold ? 2 : 1
el.defaultPlaybackRate = teaserSpeedHold ? 2 : 1
} catch {} } catch {}
if (opts?.resetToStart) { if (opts?.resetToStart) {
@ -1008,7 +1059,6 @@ export default function FinishedVideoPreview({
} catch {} } catch {}
} }
const p = el.play?.() const p = el.play?.()
if (p && typeof (p as any).catch === 'function') { if (p && typeof (p as any).catch === 'function') {
;(p as Promise<void>).catch(() => {}) ;(p as Promise<void>).catch(() => {})
@ -1135,11 +1185,13 @@ export default function FinishedVideoPreview({
return return
} }
applyInlineVideoPolicy(el, { muted }) const effectiveMuted = teaserSoundForcedRef.current ? false : muted
applyInlineVideoPolicy(el, { muted: effectiveMuted })
try { try {
el.muted = muted el.muted = effectiveMuted
el.defaultMuted = muted el.defaultMuted = effectiveMuted
el.playsInline = true el.playsInline = true
el.loop = true el.loop = true
el.autoplay = true el.autoplay = true
@ -1301,9 +1353,22 @@ export default function FinishedVideoPreview({
const previewNode = ( const previewNode = (
<div <div
ref={rootRef} ref={rootRef}
className={['group bg-gray-100 dark:bg-white/5 overflow-hidden relative', sizeClass, className ?? ''].join(' ')} className={[
'group bg-gray-100 dark:bg-white/5 overflow-hidden relative',
'select-none touch-manipulation',
sizeClass,
className ?? '',
].join(' ')}
style={{
WebkitTouchCallout: 'none',
WebkitUserSelect: 'none',
userSelect: 'none',
}}
onContextMenu={(e) => e.preventDefault()}
onPointerLeave={() => {
if (wantsHover) setHovered(false)
}}
onMouseEnter={wantsHover ? () => setHovered(true) : undefined} onMouseEnter={wantsHover ? () => setHovered(true) : undefined}
onMouseLeave={wantsHover ? () => setHovered(false) : undefined}
onFocus={wantsHover ? () => setHovered(true) : undefined} onFocus={wantsHover ? () => setHovered(true) : undefined}
onBlur={wantsHover ? () => setHovered(false) : undefined} onBlur={wantsHover ? () => setHovered(false) : undefined}
data-duration={hasDuration ? String(effectiveDurationSec) : undefined} data-duration={hasDuration ? String(effectiveDurationSec) : undefined}
@ -1318,7 +1383,16 @@ export default function FinishedVideoPreview({
loading={alwaysLoadStill ? 'eager' : 'lazy'} loading={alwaysLoadStill ? 'eager' : 'lazy'}
decoding="async" decoding="async"
alt={file} alt={file}
className={['absolute inset-0 w-full h-full object-cover', blurCls].filter(Boolean).join(' ')} draggable={false}
className={[
'absolute inset-0 w-full h-full object-cover pointer-events-none',
blurCls,
].filter(Boolean).join(' ')}
style={{
WebkitTouchCallout: 'none',
WebkitUserSelect: 'none',
userSelect: 'none',
}}
onError={() => setThumbOk(false)} onError={() => setThumbOk(false)}
/> />
) : ( ) : (
@ -1387,7 +1461,7 @@ export default function FinishedVideoPreview({
] ]
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
muted={muted} muted={teaserSoundForcedRef.current ? false : muted}
playsInline playsInline
autoPlay autoPlay
loop loop
@ -1418,6 +1492,20 @@ export default function FinishedVideoPreview({
/> />
) : null} ) : null}
{teaserSpeedHold && canHoldSpeedTeaser ? (
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-40 flex justify-center">
<div
className="
rounded-full bg-black/70 px-3 py-1
text-[11px] font-semibold text-white
shadow-lg ring-1 ring-white/10 backdrop-blur-sm
"
>
2x speed &gt;&gt;
</div>
</div>
) : null}
{/* ▶️ Progressbar: kräftiger + mehr Kontrast */} {/* ▶️ Progressbar: kräftiger + mehr Kontrast */}
{showAnyProgress ? ( {showAnyProgress ? (
<div <div

View File

@ -56,6 +56,7 @@ type ModalProps = {
bodyClassName?: string bodyClassName?: string
leftClassName?: string leftClassName?: string
rightClassName?: string rightClassName?: string
className?: string
/** /**
@ -87,15 +88,14 @@ export default function Modal({
footer, footer,
icon, icon,
width = 'max-w-lg', width = 'max-w-lg',
layout = 'single', layout = 'single',
left, left,
leftWidthClass = 'lg:w-80', leftWidthClass = 'lg:w-80',
scroll, scroll,
bodyClassName, bodyClassName,
leftClassName, leftClassName,
rightClassName, rightClassName,
className,
rightHeader, rightHeader,
rightBodyClassName, rightBodyClassName,
mobileCollapsedImageSrc, mobileCollapsedImageSrc,
@ -110,30 +110,6 @@ export default function Modal({
const mobileScrollRef = useRef<HTMLDivElement | null>(null) const mobileScrollRef = useRef<HTMLDivElement | null>(null)
const [mobileCollapsed, setMobileCollapsed] = useState(false) const [mobileCollapsed, setMobileCollapsed] = useState(false)
useEffect(() => {
if (!open) return
const html = document.documentElement
const body = document.body
const prevHtmlOverflow = html.style.overflow
const prevBodyOverflow = body.style.overflow
const prevBodyPaddingRight = body.style.paddingRight
// verhindert Layout-Shift wenn Scrollbar verschwindet
const scrollBarWidth = window.innerWidth - html.clientWidth
html.style.overflow = 'hidden'
body.style.overflow = 'hidden'
if (scrollBarWidth > 0) body.style.paddingRight = `${scrollBarWidth}px`
return () => {
html.style.overflow = prevHtmlOverflow
body.style.overflow = prevBodyOverflow
body.style.paddingRight = prevBodyPaddingRight
}
}, [open])
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
// reset when opening // reset when opening
@ -165,7 +141,7 @@ export default function Modal({
return ( return (
<Transition show={open} as={Fragment}> <Transition show={open} as={Fragment}>
<Dialog open={open} as="div" className="relative z-50" onClose={onClose}> <Dialog open={open} as="div" className={cn('relative z-[120]', className)} onClose={onClose}>
{/* Backdrop */} {/* Backdrop */}
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
@ -180,7 +156,7 @@ export default function Modal({
</Transition.Child> </Transition.Child>
{/* Modal Panel */} {/* Modal Panel */}
<div className="fixed inset-0 z-50 overflow-hidden px-4 py-6 sm:px-6"> <div className="fixed inset-0 z-50 overflow-y-auto overscroll-contain px-4 py-6 sm:px-6">
<div className="min-h-full flex items-start justify-center sm:items-center"> <div className="min-h-full flex items-start justify-center sm:items-center">
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}

View File

@ -37,6 +37,12 @@ import PreviewScrubber from './PreviewScrubber'
import { formatResolution } from './formatters' import { formatResolution } from './formatters'
import Pagination from './Pagination' import Pagination from './Pagination'
import LiveVideo from './LiveVideo' import LiveVideo from './LiveVideo'
import {
buildSpriteFrameStyle,
parseJobMeta,
readPreviewSpriteInfo,
scrubProgressRatioFromIndex,
} from './previewSprite'
function cn(...parts: Array<string | false | null | undefined>) { function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ') return parts.filter(Boolean).join(' ')
@ -253,71 +259,6 @@ function pill(cls: string) {
const previewBlurCls = (blur?: boolean) => const previewBlurCls = (blur?: boolean) =>
blur ? 'blur-md scale-[1.03] brightness-90' : '' blur ? 'blur-md scale-[1.03] brightness-90' : ''
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]
}
// ------ API types (Chaturbate online) ------ // ------ API types (Chaturbate online) ------
type ChaturbateRoom = { type ChaturbateRoom = {
@ -944,14 +885,8 @@ export default function ModelDetails({
const handleScrubberClickIndex = React.useCallback( const handleScrubberClickIndex = React.useCallback(
(job: RecordJob, segmentIndex: number, _segmentCount: number) => { (job: RecordJob, segmentIndex: number, _segmentCount: number) => {
const metaRaw = (job as any)?.meta const sprite = readPreviewSpriteInfo((job as any)?.meta)
const meta = typeof metaRaw === 'string' ? (() => { try { return JSON.parse(metaRaw) } catch { return null } })() : metaRaw const step = sprite.stepSeconds > 0 ? sprite.stepSeconds : 5
const step =
typeof meta?.previewSprite?.stepSeconds === 'number' && Number.isFinite(meta.previewSprite.stepSeconds) && meta.previewSprite.stepSeconds > 0
? meta.previewSprite.stepSeconds
: 5
const startAtSec = Math.max(0, Math.floor(segmentIndex) * step) const startAtSec = Math.max(0, Math.floor(segmentIndex) * step)
onOpenPlayer?.(job, startAtSec) onOpenPlayer?.(job, startAtSec)
}, },
@ -1107,6 +1042,7 @@ export default function ModelDetails({
width="max-w-6xl" width="max-w-6xl"
layout="split" layout="split"
scroll="right" scroll="right"
className="z-[120]"
leftWidthClass="lg:w-[320px]" leftWidthClass="lg:w-[320px]"
mobileCollapsedImageSrc={heroImg || undefined} mobileCollapsedImageSrc={heroImg || undefined}
mobileCollapsedImageAlt={titleName} mobileCollapsedImageAlt={titleName}
@ -2060,91 +1996,29 @@ export default function ModelDetails({
const cardTags = allTags const cardTags = allTags
// Model Preview Bild: nimm Hero // Model Preview Bild: nimm Hero
const modelImageSrc = firstNonEmptyString(heroImgFull, heroImg) const modelImageSrc = heroImgFull || heroImg || undefined
// Preview-ID (für sprite fallback) // -------- Sprite/Scrubber Setup (nur wenn echte Sprite-Metadaten existieren) --------
const fileForPreviewId = stripHotPrefix(baseName(j.output || '')) const sprite = readPreviewSpriteInfo((j as any)?.meta)
const previewId = fileForPreviewId.replace(/\.[^.]+$/, '').trim()
// -------- Sprite/Scrubber Setup (wie Gallery) -------- const spriteUrl = sprite.url
const spritePathRaw = firstNonEmptyString( const hasScrubberUi = sprite.hasScrubberUi
meta?.previewSprite?.path, const hasSpriteScrubber = sprite.hasSpriteScrubber
(meta as any)?.previewSpritePath,
previewId ? `/api/preview-sprite/${encodeURIComponent(previewId)}` : undefined
)
const spritePath = spritePathRaw ? spritePathRaw.replace(/\/+$/, '') : undefined
const spriteStepSecondsRaw = meta?.previewSprite?.stepSeconds ?? (meta as any)?.previewSpriteStepSeconds const scrubberCount = hasScrubberUi ? sprite.count : 0
const spriteStepSeconds = const scrubberStepSeconds = hasScrubberUi ? sprite.stepSeconds : 0
typeof spriteStepSecondsRaw === 'number' && Number.isFinite(spriteStepSecondsRaw) && spriteStepSecondsRaw > 0
? spriteStepSecondsRaw
: DEFAULT_SPRITE_STEP_SECONDS
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 ??
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]
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
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
const scrubberCount = hasScrubberUi ? spriteCount : 0
const scrubberStepSeconds = hasScrubberUi ? spriteStepSeconds : 0
const hasScrubber = hasScrubberUi const hasScrubber = hasScrubberUi
const activeScrubIndex = scrubIndexByKey[k] const activeScrubIndex = scrubIndexByKey[k]
const scrubProgressRatio = const scrubProgressRatio = scrubProgressRatioFromIndex(activeScrubIndex, scrubberCount)
typeof activeScrubIndex === 'number' && scrubberCount > 1 ? clamp(activeScrubIndex / (scrubberCount - 1), 0, 1) : undefined
const spriteFrameStyle: React.CSSProperties | undefined = const spriteFrameStyle = buildSpriteFrameStyle({
hasSpriteScrubber && typeof activeScrubIndex === 'number' spriteUrl,
? (() => { spriteCols: sprite.cols,
const idx = clamp(activeScrubIndex, 0, Math.max(0, spriteCount - 1)) spriteRows: sprite.rows,
const col = idx % spriteCols spriteCount: sprite.count,
const row = Math.floor(idx / spriteCols) activeIndex: activeScrubIndex,
})
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 showModelPreviewInThumb = hoveredModelPreviewKey === k && Boolean(modelImageSrc) const showModelPreviewInThumb = hoveredModelPreviewKey === k && Boolean(modelImageSrc)
const showScrubberSpriteInThumb = !showModelPreviewInThumb && Boolean(spriteFrameStyle) const showScrubberSpriteInThumb = !showModelPreviewInThumb && Boolean(spriteFrameStyle)
@ -2212,6 +2086,14 @@ export default function ModelDetails({
popoverMuted={previewMuted} popoverMuted={previewMuted}
scrubProgressRatio={scrubProgressRatio} scrubProgressRatio={scrubProgressRatio}
preferScrubProgress={typeof activeScrubIndex === 'number'} preferScrubProgress={typeof activeScrubIndex === 'number'}
forceActive={
!hideTeaserUnderOverlay &&
(
teaserPlayback === 'all' ||
(teaserPlayback === 'hover' && hoveredThumbKey === k)
)
}
teaserPreloadEnabled
/> />
</div> </div>
@ -2401,108 +2283,25 @@ export default function ModelDetails({
const size = formatBytes(sizeBytesOf(j)) const size = formatBytes(sizeBytesOf(j))
const cardTags = allTags const cardTags = allTags
const modelImageSrc = firstNonEmptyString(heroImgFull, heroImg) const modelImageSrc = heroImgFull || heroImg || undefined
// Preview-ID (für sprite fallback) // -------- Sprite/Scrubber Setup (nur wenn echte Sprite-Metadaten existieren) --------
const fileForPreviewId = stripHotPrefix(baseName(j.output || '')) const sprite = readPreviewSpriteInfo((j as any)?.meta)
const previewId = fileForPreviewId.replace(/\.[^.]+$/, '').trim()
// -------- Sprite/Scrubber Setup (wie Gallery) -------- const spriteUrl = sprite.url
const spritePathRaw = firstNonEmptyString( const hasScrubberUi = sprite.hasScrubberUi
meta?.previewSprite?.path, const scrubberCount = hasScrubberUi ? sprite.count : 0
(meta as any)?.previewSpritePath,
previewId ? `/api/preview-sprite/${encodeURIComponent(previewId)}` : undefined
)
const spritePath = spritePathRaw ? spritePathRaw.replace(/\/+$/, '') : undefined
const spriteStepSecondsRaw =
meta?.previewSprite?.stepSeconds ?? (meta as any)?.previewSpriteStepSeconds
const spriteStepSeconds =
typeof spriteStepSecondsRaw === 'number' &&
Number.isFinite(spriteStepSecondsRaw) &&
spriteStepSecondsRaw > 0
? spriteStepSecondsRaw
: DEFAULT_SPRITE_STEP_SECONDS
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 ??
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]
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
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
const scrubberCount = hasScrubberUi ? spriteCount : 0
const activeScrubIndex = scrubIndexByKey[k] const activeScrubIndex = scrubIndexByKey[k]
const scrubProgressRatio = const scrubProgressRatio = scrubProgressRatioFromIndex(activeScrubIndex, scrubberCount)
typeof activeScrubIndex === 'number' && scrubberCount > 1
? clamp(activeScrubIndex / (scrubberCount - 1), 0, 1)
: undefined
const spriteFrameStyle: React.CSSProperties | undefined = const spriteFrameStyle = buildSpriteFrameStyle({
hasSpriteScrubber && typeof activeScrubIndex === 'number' spriteUrl,
? (() => { spriteCols: sprite.cols,
const idx = clamp(activeScrubIndex, 0, Math.max(0, spriteCount - 1)) spriteRows: sprite.rows,
const col = idx % spriteCols spriteCount: sprite.count,
const row = Math.floor(idx / spriteCols) activeIndex: activeScrubIndex,
})
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 showModelPreviewInThumb = hoveredModelPreviewKey === k && Boolean(modelImageSrc) const showModelPreviewInThumb = hoveredModelPreviewKey === k && Boolean(modelImageSrc)
const showScrubberSpriteInThumb = !showModelPreviewInThumb && Boolean(spriteFrameStyle) const showScrubberSpriteInThumb = !showModelPreviewInThumb && Boolean(spriteFrameStyle)
@ -2574,6 +2373,14 @@ export default function ModelDetails({
popoverMuted={previewMuted} popoverMuted={previewMuted}
scrubProgressRatio={scrubProgressRatio} scrubProgressRatio={scrubProgressRatio}
preferScrubProgress={typeof activeScrubIndex === 'number'} preferScrubProgress={typeof activeScrubIndex === 'number'}
forceActive={
!hideTeaserUnderOverlay &&
(
teaserPlayback === 'all' ||
(teaserPlayback === 'hover' && hoveredThumbKey === k)
)
}
teaserPreloadEnabled
/> />
</div> </div>
) : null} ) : null}

View File

@ -58,9 +58,14 @@ export default function ModelPreview({
// ✅ page visibility als REF (kein Rerender-Fanout bei visibilitychange) // ✅ page visibility als REF (kein Rerender-Fanout bei visibilitychange)
const pageVisibleRef = useRef(true) const pageVisibleRef = useRef(true)
const [pageVisible, setPageVisible] = useState(true)
const [popupMuted, setPopupMuted] = useState(true)
const [popupVolume, setPopupVolume] = useState(1)
// inView als State (brauchen wir für eager/lazy + fetchPriority + UI) // inView als State (brauchen wir für eager/lazy + fetchPriority + UI)
const [inView, setInView] = useState(false) const [inView, setInView] = useState(false)
const inViewRef = useRef(false) const inViewRef = useRef(false)
const shouldRenderPreview = inView && pageVisible
const [localTick, setLocalTick] = useState(0) const [localTick, setLocalTick] = useState(0)
const [directImgError, setDirectImgError] = useState(false) const [directImgError, setDirectImgError] = useState(false)
@ -71,10 +76,6 @@ export default function ModelPreview({
const hadSuccess = useRef(false) const hadSuccess = useRef(false)
const enteredViewOnce = useRef(false) const enteredViewOnce = useRef(false)
const [pageVisible, setPageVisible] = useState(true)
const [popupMuted, setPopupMuted] = useState(true)
const [popupVolume, setPopupVolume] = useState(1)
const toMs = (v: any): number => { const toMs = (v: any): number => {
if (typeof v === 'number' && Number.isFinite(v)) return v if (typeof v === 'number' && Number.isFinite(v)) return v
if (v instanceof Date) return v.getTime() if (v instanceof Date) return v.getTime()
@ -365,11 +366,12 @@ export default function ModelPreview({
e.stopPropagation() e.stopPropagation()
}} }}
> >
{!apiImgError ? ( {shouldRenderPreview ? (
!apiImgError ? (
<img <img
src={currentImgSrc} src={currentImgSrc}
loading={inView ? 'eager' : 'lazy'} loading="eager"
fetchPriority={inView ? 'high' : 'auto'} fetchPriority="high"
decoding="async" decoding="async"
alt="" alt=""
className={['block w-full h-full object-cover object-center', blurCls].filter(Boolean).join(' ')} className={['block w-full h-full object-cover object-center', blurCls].filter(Boolean).join(' ')}
@ -378,18 +380,15 @@ export default function ModelPreview({
fastTries.current = 0 fastTries.current = 0
if (retryT.current) window.clearTimeout(retryT.current) if (retryT.current) window.clearTimeout(retryT.current)
// nur den aktuell genutzten Pfad als "ok" markieren
if (useDirectThumb) setDirectImgError(false) if (useDirectThumb) setDirectImgError(false)
else setApiImgError(false) else setApiImgError(false)
}} }}
onError={() => { onError={() => {
// 1) Wenn direkte preview.jpg fehlschlägt -> auf API-Fallback umschalten
if (useDirectThumb) { if (useDirectThumb) {
setDirectImgError(true) setDirectImgError(true)
return return
} }
// 2) API-Fallback fehlschlägt -> bisherige Retry-Logik
setApiImgError(true) setApiImgError(true)
if (!fastRetryMs) return if (!fastRetryMs) return
@ -407,7 +406,7 @@ export default function ModelPreview({
if (retryT.current) window.clearTimeout(retryT.current) if (retryT.current) window.clearTimeout(retryT.current)
retryT.current = window.setTimeout(() => { retryT.current = window.setTimeout(() => {
fastTries.current += 1 fastTries.current += 1
setApiImgError(false) // API erneut probieren setApiImgError(false)
setLocalTick((x) => x + 1) setLocalTick((x) => x + 1)
}, fastRetryMs) }, fastRetryMs)
}} }}
@ -416,6 +415,9 @@ export default function ModelPreview({
<div className="absolute inset-0 grid place-items-center px-1 text-center text-[10px] text-gray-500 dark:text-gray-400"> <div className="absolute inset-0 grid place-items-center px-1 text-center text-[10px] text-gray-500 dark:text-gray-400">
keine Vorschau keine Vorschau
</div> </div>
)
) : (
<div className="absolute inset-0 bg-gray-100 dark:bg-white/5" />
)} )}
</div> </div>
</HoverPopover> </HoverPopover>

View File

@ -1788,7 +1788,7 @@ export default function Player({
<div className="absolute right-2 bottom-2 z-[60] flex items-center gap-2"> <div className="absolute right-2 bottom-2 z-[60] flex items-center gap-2">
<button <button
type="button" type="button"
className="pointer-events-auto inline-flex items-center justify-center rounded-full bg-black/65 px-2.5 py-1.5 text-white shadow-sm ring-1 ring-white/10 hover:bg-black/75" className="pointer-events-auto inline-flex items-center justify-center rounded-full bg-white/90 px-2.5 py-1.5 text-gray-900 shadow-sm ring-1 ring-black/10 hover:bg-white dark:bg-black/65 dark:text-white dark:ring-white/10 dark:hover:bg-black/75"
title={liveMuted ? 'Ton an' : 'Stumm'} title={liveMuted ? 'Ton an' : 'Stumm'}
aria-label={liveMuted ? 'Ton an' : 'Stumm'} aria-label={liveMuted ? 'Ton an' : 'Stumm'}
onClick={() => { onClick={() => {

View File

@ -75,6 +75,7 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState<number>(0) const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState<number>(0)
const saveSuccessTimerRef = useRef<number | null>(null) const saveSuccessTimerRef = useRef<number | null>(null)
const cleanupDismissUntilRef = useRef(0)
const [cleaning, setCleaning] = useState(false) const [cleaning, setCleaning] = useState(false)
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null) const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
const [msg, setMsg] = useState<string | null>(null) const [msg, setMsg] = useState<string | null>(null)
@ -138,7 +139,7 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
status: 'idle', status: 'idle',
title: 'Aufräumen', title: 'Aufräumen',
text: '', text: '',
cancellable: false, cancellable: true,
fading: false, fading: false,
}) })
@ -147,7 +148,7 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
status: 'idle', status: 'idle',
title: 'Assets neu generieren', title: 'Assets neu generieren',
text: '', text: '',
cancellable: false, cancellable: true,
fading: false, fading: false,
}) })
@ -234,6 +235,7 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
title: 'Assets neu generieren', title: 'Assets neu generieren',
text: String(regen.text || regen.file || ''), text: String(regen.text || regen.file || ''),
err: undefined, err: undefined,
cancellable: true,
fading: false, fading: false,
})) }))
} else if (regen?.error) { } else if (regen?.error) {
@ -243,6 +245,7 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
title: 'Assets neu generieren', title: 'Assets neu generieren',
text: String(regen.file || ''), text: String(regen.file || ''),
err: String(regen.error), err: String(regen.error),
cancellable: false,
fading: false, fading: false,
})) }))
} else if (regen?.text || regen?.file) { } else if (regen?.text || regen?.file) {
@ -252,6 +255,7 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
title: 'Assets neu generieren', title: 'Assets neu generieren',
text: String(regen.text || regen.file || ''), text: String(regen.text || regen.file || ''),
err: undefined, err: undefined,
cancellable: false,
fading: false, fading: false,
})) }))
} else { } else {
@ -261,6 +265,64 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
title: 'Assets neu generieren', title: 'Assets neu generieren',
text: '', text: '',
err: undefined, err: undefined,
cancellable: true,
fading: false,
}))
}
const cleanup = data.cleanup
const cleanupSuppressed = Date.now() < cleanupDismissUntilRef.current
if (cleanup?.running) {
setCleaning(true)
setCleanupTask((t) => ({
...t,
status: 'running',
title: 'Aufräumen',
text: String(cleanup.currentFile || cleanup.text || 'Räume auf…'),
done: Number(cleanup.done ?? 0),
total: Number(cleanup.total ?? 0),
err: undefined,
cancellable: false,
fading: false,
}))
} else if (!cleanupSuppressed && cleanup?.error) {
setCleaning(false)
setCleanupTask((t) => ({
...t,
status: 'error',
title: 'Aufräumen',
text: String(cleanup.text || 'Fehler beim Aufräumen.'),
done: Number(cleanup.done ?? 0),
total: Number(cleanup.total ?? 0),
err: String(cleanup.error),
cancellable: false,
fading: false,
}))
} else if (!cleanupSuppressed && (cleanup?.finishedAt || cleanup?.text)) {
setCleaning(false)
setCleanupTask((t) => ({
...t,
status: 'done',
title: 'Aufräumen',
text: String(cleanup.text || 'Aufräumen abgeschlossen.'),
done: Number(cleanup.done ?? 0),
total: Number(cleanup.total ?? 0),
err: undefined,
cancellable: false,
fading: false,
}))
} else {
setCleaning(false)
setCleanupTask((t) => ({
...t,
status: 'idle',
title: 'Aufräumen',
text: '',
done: 0,
total: 0,
err: undefined,
cancellable: true,
fading: false, fading: false,
})) }))
} }
@ -523,7 +585,7 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
text: 'Räume auf…', text: 'Räume auf…',
err: undefined, err: undefined,
done: 0, done: 0,
total: 1, total: 0,
fading: false, fading: false,
})) }))
@ -550,12 +612,13 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
setCleanupTask((t) => ({ setCleanupTask((t) => ({
...t, ...t,
status: 'done', status: 'done',
done: 1, done: scannedFiles,
total: 1, total: scannedFiles,
title: 'Aufräumen', title: 'Aufräumen',
text: `geprüft: ${scannedFiles} · Orphans: ${orphansTotalRemoved}`, text: `geprüft: ${scannedFiles} · Orphans: ${orphansTotalRemoved}`,
})) }))
cleanupDismissUntilRef.current = Date.now() + 4500
fadeOutTask(setCleanupTask) // ✅ nach und nach ausfaden fadeOutTask(setCleanupTask) // ✅ nach und nach ausfaden
} catch (e: any) { } catch (e: any) {
const msg = e?.message ?? String(e) const msg = e?.message ?? String(e)
@ -594,6 +657,30 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
} }
} }
async function cancelRegenerateAssetsTask() {
// UI sofort aktualisieren
setRegenerateAssetsTask((t) => ({
...t,
status: 'cancelled',
title: 'Assets neu generieren',
text: 'Abgebrochen.',
err: undefined,
fading: false,
}))
try {
// Vorschlag: gleicher Endpoint wie Start, aber DELETE zum Abbrechen
await fetch('/api/record/regenerate-assets', {
method: 'DELETE',
cache: 'no-store' as any,
})
} catch {
// ignore
}
fadeOutTask(setRegenerateAssetsTask)
}
return ( return (
<Card <Card
header={ header={
@ -626,9 +713,17 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
> >
<div className="space-y-4"> <div className="space-y-4">
<TaskList <TaskList
tasks={[assetsTask, regenerateAssetsTask, cleanupTask]} // ✅ cleanupTask ist “am Ende” tasks={[assetsTask, regenerateAssetsTask, cleanupTask]}
onCancel={(id: string) => { onCancel={(id: string) => {
if (id === 'generate-assets') cancelAssetsTask() if (id === 'generate-assets') {
void cancelAssetsTask()
return
}
if (id === 'regenerate-assets') {
void cancelRegenerateAssetsTask()
return
}
}} }}
/> />

View File

@ -6,13 +6,6 @@ import * as React from 'react'
import { FireIcon as FireSolidIcon } from '@heroicons/react/24/solid' import { FireIcon as FireSolidIcon } from '@heroicons/react/24/solid'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
const DEBUG_SWIPE = true
function swipeDbg(...args: any[]) {
if (!DEBUG_SWIPE) return
console.log('[SwipeCard]', ...args)
}
function cn(...parts: Array<string | false | null | undefined>) { function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ') return parts.filter(Boolean).join(' ')
} }
@ -63,6 +56,10 @@ export type SwipeCardProps = {
hotTargetSelector?: string hotTargetSelector?: string
doubleTapMs?: number doubleTapMs?: number
doubleTapMaxMovePx?: number doubleTapMaxMovePx?: number
pressDelayMs?: number
enablePressHold?: boolean
onPressStart?: (info: { pointerType: string; button: number }) => void
onPressEnd?: () => void
} }
export type SwipeCardHandle = { export type SwipeCardHandle = {
@ -116,6 +113,10 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
hotTargetSelector = '[data-hot-target]', hotTargetSelector = '[data-hot-target]',
doubleTapMs = 360, doubleTapMs = 360,
doubleTapMaxMovePx = 48, doubleTapMaxMovePx = 48,
pressDelayMs = 220,
enablePressHold = true,
onPressStart,
onPressEnd,
}, },
ref ref
) { ) {
@ -123,6 +124,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const outerRef = React.useRef<HTMLDivElement | null>(null) const outerRef = React.useRef<HTMLDivElement | null>(null)
const doubleTapBusyRef = React.useRef(false) const doubleTapBusyRef = React.useRef(false)
const pressTimerRef = React.useRef<number | null>(null)
const longPressActiveRef = React.useRef(false)
const rafRef = React.useRef<number | null>(null) const rafRef = React.useRef<number | null>(null)
const dxRef = React.useRef(0) const dxRef = React.useRef(0)
@ -167,6 +170,22 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
} }
}, []) }, [])
const clearPressTimer = React.useCallback(() => {
if (pressTimerRef.current != null) {
window.clearTimeout(pressTimerRef.current)
pressTimerRef.current = null
}
}, [])
const endLongPress = React.useCallback(() => {
clearPressTimer()
if (!longPressActiveRef.current) return
longPressActiveRef.current = false
onPressEnd?.()
}, [clearPressTimer, onPressEnd])
const flushDx = React.useCallback((nextDx: number) => { const flushDx = React.useCallback((nextDx: number) => {
dxRef.current = nextDx dxRef.current = nextDx
if (rafRef.current != null) return if (rafRef.current != null) return
@ -177,6 +196,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}, []) }, [])
const reset = React.useCallback(() => { const reset = React.useCallback(() => {
clearPressTimer()
longPressActiveRef.current = false
if (rafRef.current != null) { if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current) cancelAnimationFrame(rafRef.current)
rafRef.current = null rafRef.current = null
@ -196,7 +218,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
window.setTimeout(() => { window.setTimeout(() => {
setAnimMs(0) setAnimMs(0)
}, snapMs) }, snapMs)
}, [snapMs]) }, [snapMs, clearPressTimer])
const commit = React.useCallback( const commit = React.useCallback(
async (dir: 'left' | 'right', runAction: boolean) => { async (dir: 'left' | 'right', runAction: boolean) => {
@ -205,8 +227,6 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
rafRef.current = null rafRef.current = null
} }
swipeDbg('commit start', { dir, runAction })
if (cardRef.current) { if (cardRef.current) {
cardRef.current.style.touchAction = 'pan-y' cardRef.current.style.touchAction = 'pan-y'
} }
@ -226,8 +246,6 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
// Wichtig: erst rausanimieren lassen, dann Aktion starten // Wichtig: erst rausanimieren lassen, dann Aktion starten
await animPromise await animPromise
swipeDbg('commit after anim', { dir })
let ok: boolean | void = true let ok: boolean | void = true
if (runAction) { if (runAction) {
ok = await Promise.resolve( ok = await Promise.resolve(
@ -235,8 +253,6 @@ swipeDbg('commit after anim', { dir })
).catch(() => false) ).catch(() => false)
} }
swipeDbg('commit action result', { dir, ok })
if (ok === false) { if (ok === false) {
setAnimMs(snapMs) setAnimMs(snapMs)
setArmedDir(null) setArmedDir(null)
@ -385,6 +401,13 @@ swipeDbg('commit action result', { dir, ok })
[hotTargetSelector] [hotTargetSelector]
) )
React.useEffect(() => {
return () => {
clearTapTimer()
clearPressTimer()
}
}, [clearTapTimer, clearPressTimer])
React.useImperativeHandle( React.useImperativeHandle(
ref, ref,
() => ({ () => ({
@ -427,12 +450,6 @@ swipeDbg('commit action result', { dir, ok })
onPointerDown={(e) => { onPointerDown={(e) => {
if (!enabled || disabled) return if (!enabled || disabled) return
swipeDbg('pointerdown', {
pointerId: e.pointerId,
x: e.clientX,
y: e.clientY,
})
const target = e.target as HTMLElement | null const target = e.target as HTMLElement | null
let tapIgnored = Boolean(tapIgnoreSelector && target?.closest?.(tapIgnoreSelector)) let tapIgnored = Boolean(tapIgnoreSelector && target?.closest?.(tapIgnoreSelector))
@ -481,6 +498,30 @@ swipeDbg('commit action result', { dir, ok })
noSwipe = true noSwipe = true
} }
endLongPress()
// Long-Press nur im Teaser-Modus, nicht im Inline-Playback / Controls / gesperrten Zonen
if (enablePressHold && !noSwipe && !tapIgnored && onPressStart) {
const pressInfo = {
pointerType: e.pointerType,
button: e.button,
}
const pointerId = e.pointerId
pressTimerRef.current = window.setTimeout(() => {
pressTimerRef.current = null
if (pointer.current.id !== pointerId) return
if (pointer.current.dragging) return
if (pointer.current.noSwipe) return
if (pointer.current.tapIgnored) return
longPressActiveRef.current = true
onPressStart(pressInfo)
}, pressDelayMs)
}
const w = cardRef.current?.offsetWidth || 360 const w = cardRef.current?.offsetWidth || 360
widthRef.current = w widthRef.current = w
thresholdRef.current = Math.min(thresholdPx, w * thresholdRatio) thresholdRef.current = Math.min(thresholdPx, w * thresholdRatio)
@ -505,6 +546,7 @@ swipeDbg('commit action result', { dir, ok })
if (!enabled || disabled) return if (!enabled || disabled) return
if (pointer.current.id !== e.pointerId) return if (pointer.current.id !== e.pointerId) return
if (pointer.current.noSwipe) return if (pointer.current.noSwipe) return
if (longPressActiveRef.current) return
const ddx = e.clientX - pointer.current.startX const ddx = e.clientX - pointer.current.startX
const ddy = e.clientY - pointer.current.startY const ddy = e.clientY - pointer.current.startY
@ -515,6 +557,7 @@ swipeDbg('commit action result', { dir, ok })
if (absX < HORIZONTAL_START_PX && absY < HORIZONTAL_START_PX) return if (absX < HORIZONTAL_START_PX && absY < HORIZONTAL_START_PX) return
if (absY > absX * LOCK_RATIO) { if (absY > absX * LOCK_RATIO) {
clearPressTimer()
pointer.current.axisLocked = 'y' pointer.current.axisLocked = 'y'
pointer.current.id = null pointer.current.id = null
return return
@ -530,6 +573,7 @@ swipeDbg('commit action result', { dir, ok })
if (pointer.current.axisLocked !== 'x') return if (pointer.current.axisLocked !== 'x') return
if (!pointer.current.dragging) { if (!pointer.current.dragging) {
clearPressTimer()
pointer.current.dragging = true pointer.current.dragging = true
;(e.currentTarget as HTMLElement).style.touchAction = 'none' ;(e.currentTarget as HTMLElement).style.touchAction = 'none'
setAnimMs(0) setAnimMs(0)
@ -577,6 +621,9 @@ swipeDbg('commit action result', { dir, ok })
const wasCaptured = pointer.current.captured const wasCaptured = pointer.current.captured
const wasTapIgnored = pointer.current.tapIgnored const wasTapIgnored = pointer.current.tapIgnored
const wasLongPress = longPressActiveRef.current
clearPressTimer()
pointer.current.id = null pointer.current.id = null
pointer.current.dragging = false pointer.current.dragging = false
pointer.current.captured = false pointer.current.captured = false
@ -592,6 +639,12 @@ swipeDbg('commit action result', { dir, ok })
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y' ;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
if (wasLongPress) {
longPressActiveRef.current = false
onPressEnd?.()
return
}
if (!wasDragging) { if (!wasDragging) {
const now = Date.now() const now = Date.now()
const last = lastTapRef.current const last = lastTapRef.current
@ -665,9 +718,6 @@ swipeDbg('commit action result', { dir, ok })
dxRef.current = 0 dxRef.current = 0
swipeDbg('commit-left')
swipeDbg('commit-right')
if (finalDx > threshold || (fastRight && abs >= threshold * 0.5)) { if (finalDx > threshold || (fastRight && abs >= threshold * 0.5)) {
void commit('right', true) void commit('right', true)
return return
@ -682,6 +732,8 @@ swipeDbg('commit action result', { dir, ok })
}} }}
onPointerCancel={(e) => { onPointerCancel={(e) => {
if (!enabled || disabled) return if (!enabled || disabled) return
endLongPress()
clearPressTimer()
if (pointer.current.captured && pointer.current.id != null) { if (pointer.current.captured && pointer.current.id != null) {
try { try {

View File

@ -8,33 +8,23 @@ type SwitchSize = 'default' | 'short'
type SwitchVariant = 'simple' | 'icon' type SwitchVariant = 'simple' | 'icon'
export type SwitchProps = { export type SwitchProps = {
/** Controlled */
checked: boolean checked: boolean
onChange: (checked: boolean) => void onChange: (checked: boolean) => void
/** Optional wiring */
id?: string id?: string
name?: string name?: string
disabled?: boolean disabled?: boolean
required?: boolean required?: boolean
/** Labeling / a11y */
ariaLabel?: string ariaLabel?: string
ariaLabelledby?: string ariaLabelledby?: string
ariaDescribedby?: string ariaDescribedby?: string
/** UI */
size?: SwitchSize size?: SwitchSize
variant?: SwitchVariant variant?: SwitchVariant
className?: string className?: string
} }
/**
* Switch / Toggle (Tailwind, ohne Headless UI)
* - size="default" (w-11) wie Simple toggle
* - size="short" (h-5 w-10) wie Short toggle
* - variant="icon" zeigt X/Check Icon im Thumb
*/
export default function Switch({ export default function Switch({
checked, checked,
onChange, onChange,
@ -60,27 +50,16 @@ export default function Switch({
) )
if (size === 'short') { if (size === 'short') {
// Short toggle Beispiel
return ( return (
<div <div
className={clsx( className={clsx(
'group relative inline-flex h-5 w-10 shrink-0 items-center justify-center rounded-full outline-offset-2 outline-indigo-600 has-focus-visible:outline-2 dark:outline-indigo-500', 'group relative inline-flex h-5 w-10 shrink-0 rounded-full bg-gray-200 p-0.5 inset-ring inset-ring-gray-900/5 outline-offset-2 outline-indigo-600 transition-colors duration-200 ease-in-out has-checked:bg-indigo-600 has-focus-visible:outline-2 dark:bg-white/5 dark:inset-ring-white/10 dark:outline-indigo-500 dark:has-checked:bg-indigo-500',
disabled && 'opacity-60', disabled && 'opacity-60',
className className
)} )}
> >
<span <span className="size-4 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked:translate-x-5" />
className={clsx(
'absolute mx-auto h-4 w-9 rounded-full bg-gray-200 inset-ring inset-ring-gray-900/5 transition-colors duration-200 ease-in-out dark:bg-gray-800/50 dark:inset-ring-white/10',
checked && 'bg-indigo-600 dark:bg-indigo-500'
)}
/>
<span
className={clsx(
'absolute left-0 size-5 rounded-full border border-gray-300 bg-white shadow-xs transition-transform duration-200 ease-in-out dark:shadow-none',
checked && 'translate-x-5'
)}
/>
<input <input
id={id} id={id}
name={name} name={name}
@ -98,24 +77,16 @@ export default function Switch({
) )
} }
// Default size (simple / icon) Beispiele
return ( return (
<div <div
className={clsx( className={clsx(
'group relative inline-flex w-11 shrink-0 rounded-full bg-gray-200 p-0.5 inset-ring inset-ring-gray-900/5 outline-offset-2 outline-indigo-600 transition-colors duration-200 ease-in-out has-focus-visible:outline-2 dark:bg-white/5 dark:inset-ring-white/10 dark:outline-indigo-500', 'group relative inline-flex w-11 shrink-0 rounded-full bg-gray-200 p-0.5 inset-ring inset-ring-gray-900/5 outline-offset-2 outline-indigo-600 transition-colors duration-200 ease-in-out has-checked:bg-indigo-600 has-focus-visible:outline-2 dark:bg-white/5 dark:inset-ring-white/10 dark:outline-indigo-500 dark:has-checked:bg-indigo-500',
checked && 'bg-indigo-600 dark:bg-indigo-500',
disabled && 'opacity-60', disabled && 'opacity-60',
className className
)} )}
> >
{variant === 'icon' ? ( {variant === 'icon' ? (
<span <span className="relative size-5 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked:translate-x-5">
className={clsx(
'relative size-5 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out',
checked && 'translate-x-5'
)}
>
{/* Off icon */}
<span <span
aria-hidden="true" aria-hidden="true"
className={clsx( className={clsx(
@ -134,7 +105,6 @@ export default function Switch({
</svg> </svg>
</span> </span>
{/* On icon */}
<span <span
aria-hidden="true" aria-hidden="true"
className={clsx( className={clsx(
@ -148,12 +118,7 @@ export default function Switch({
</span> </span>
</span> </span>
) : ( ) : (
<span <span className="size-5 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked:translate-x-5" />
className={clsx(
'size-5 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out',
checked && 'translate-x-5'
)}
/>
)} )}
<input <input

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,236 @@
// frontend\src\components\ui\previewSprite.ts
'use client'
import type { CSSProperties } from 'react'
export const DEFAULT_SPRITE_STEP_SECONDS = 5
export 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
}
export 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
}
export function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n))
}
export 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 type PreviewSpriteInfo = {
pathRaw?: string
path?: string
url?: string
count: number
cols: number
rows: number
stepSeconds: number
version: number
hasScrubberUi: boolean
hasSpriteScrubber: boolean
}
export function readPreviewSpriteInfo(
metaRaw: unknown,
opts?: {
fallbackPath?: string
fallbackCount?: number
versionFallback?: number
}
): PreviewSpriteInfo {
const meta = parseJobMeta(metaRaw)
const pathRaw = firstNonEmptyString(
meta?.preview?.sprite?.path,
meta?.preview?.sprite?.url,
meta?.preview?.sprite?.src,
meta?.previewSprite?.path,
(meta as any)?.previewSpritePath,
opts?.fallbackPath
)
const path = pathRaw ? pathRaw.replace(/\/+$/, '') : undefined
const stepSecondsRaw =
meta?.preview?.sprite?.stepSeconds ??
meta?.previewSprite?.stepSeconds ??
(meta as any)?.previewSpriteStepSeconds
const stepSeconds =
typeof stepSecondsRaw === 'number' &&
Number.isFinite(stepSecondsRaw) &&
stepSecondsRaw > 0
? stepSecondsRaw
: DEFAULT_SPRITE_STEP_SECONDS
const countRaw =
meta?.preview?.sprite?.count ??
meta?.previewSprite?.count ??
(meta as any)?.previewSpriteCount ??
opts?.fallbackCount
const colsRaw =
meta?.preview?.sprite?.cols ??
meta?.previewSprite?.cols ??
(meta as any)?.previewSpriteCols
const rowsRaw =
meta?.preview?.sprite?.rows ??
meta?.previewSprite?.rows ??
(meta as any)?.previewSpriteRows
const count =
typeof countRaw === 'number' && Number.isFinite(countRaw)
? Math.max(0, Math.floor(countRaw))
: 0
const [inferredCols, inferredRows] =
count > 1 ? chooseSpriteGrid(count) : [0, 0]
const explicitCols = Number(colsRaw)
const explicitRows = Number(rowsRaw)
const explicitGridLooksValid =
Number.isFinite(explicitCols) &&
Number.isFinite(explicitRows) &&
explicitCols > 0 &&
explicitRows > 0 &&
!(count > 1 && explicitCols === 1 && explicitRows === 1) &&
(count <= 1 || explicitCols * explicitRows >= count)
const cols = explicitGridLooksValid ? Math.floor(explicitCols) : inferredCols
const rows = explicitGridLooksValid ? Math.floor(explicitRows) : inferredRows
const version =
(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) ??
(opts?.versionFallback ?? 0)
const url =
path && version
? `${path}?v=${encodeURIComponent(String(version))}`
: path || undefined
const hasScrubberUi =
Boolean(pathRaw) &&
Boolean(url) &&
count > 1
const hasSpriteScrubber =
hasScrubberUi &&
cols > 0 &&
rows > 0
return {
pathRaw,
path,
url,
count,
cols,
rows,
stepSeconds,
version,
hasScrubberUi,
hasSpriteScrubber,
}
}
export function buildSpriteFrameStyle(args: {
spriteUrl?: string
spriteCols: number
spriteRows: number
spriteCount: number
activeIndex?: number
}): CSSProperties | undefined {
const { spriteUrl, spriteCols, spriteRows, spriteCount, activeIndex } = args
if (!spriteUrl) return undefined
if (!(spriteCols > 0 && spriteRows > 0 && spriteCount > 1)) return undefined
if (typeof activeIndex !== 'number') return undefined
const idx = clamp(activeIndex, 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}%`,
}
}
export function scrubProgressRatioFromIndex(
activeIndex: number | undefined,
count: number
): number | undefined {
if (typeof activeIndex !== 'number') return undefined
if (!(count > 1)) return undefined
return clamp(activeIndex / (count - 1), 0, 1)
}
export function previewScrubberStepSeconds(metaRaw: unknown, fallback = DEFAULT_SPRITE_STEP_SECONDS): number {
const meta = parseJobMeta(metaRaw)
const raw =
meta?.preview?.sprite?.stepSeconds ??
meta?.previewSprite?.stepSeconds ??
(meta as any)?.previewSpriteStepSeconds
return typeof raw === 'number' && Number.isFinite(raw) && raw > 0
? raw
: fallback
}

View File

@ -1,30 +1,167 @@
// frontend\src\types.ts // frontend/src/types.ts
export type ViewMode = 'table' | 'cards' | 'gallery'
export type SelectedItem = {
key: string
file: string
}
export 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 }
export type PersistedUndoState = {
v: 1
action: UndoAction
ts: number
}
export type PostWorkKeyStatus = { export type PostWorkKeyStatus = {
state: 'queued' | 'running' | 'missing' state: 'queued' | 'running' | 'missing'
position?: number // 1..n (nur queued) position?: number
waiting?: number // Anzahl wartend waiting?: number
running?: number // Anzahl running running?: number
maxParallel?: number // cap(ffmpegSem) maxParallel?: number
} }
export type VideoMeta = { export type InlinePlayState = { key: string; nonce: number } | null
export type FinishedPostworkState = 'queued' | 'running' | 'done' | 'error' | 'missing'
export type FinishedPostworkQueue = 'postwork' | 'enrich'
export type FinishedPostworkBadge = {
file: string
assetId?: string
queue: FinishedPostworkQueue
state: FinishedPostworkState
phase?: string
label?: string
position?: number
waiting?: number
running?: number
maxParallel?: number
ts: number
}
export type FinishedPostworkStepSummary = {
key: string
queue: FinishedPostworkQueue
phase: string
label: string
state: FinishedPostworkState
ts: number
position?: number
waiting?: number
running?: number
maxParallel?: number
}
export type FinishedPostworkSummary = {
file: string
assetId?: string
state: FinishedPostworkState
label: string
steps: FinishedPostworkStepSummary[]
ts: number
}
export type PreviewSpriteMeta = {
path?: string
exists?: boolean
count?: number
cols?: number
rows?: number
stepSeconds?: number
cellWidth?: number
cellHeight?: number
}
export type PreviewThumbMeta = {
path?: string
exists?: boolean
}
export type PreviewMeta = {
sprite?: PreviewSpriteMeta
thumb?: PreviewThumbMeta
clips?: Array<unknown>
}
export type FinishedPostworkHistoryEntry = {
queue?: FinishedPostworkQueue
phase?: string
state?: FinishedPostworkState
label?: string
position?: number
waiting?: number
running?: number
maxParallel?: number
ts?: number
}
export type RecordJobAnalysisAI = {
goal?: string
mode?: string
hits?: Array<unknown>
segments?: Array<unknown>
analyzedAtUnix?: number
}
export type RecordJobAnalysisMeta = {
ai?: RecordJobAnalysisAI
}
export type RecordJobPostworkMeta = {
history?: FinishedPostworkHistoryEntry[]
completed?: {
meta?: boolean
teaser?: boolean
thumb?: boolean
sprites?: boolean
analyze?: boolean
[key: string]: boolean | undefined
}
}
export type RecordJobMeta = {
version?: number version?: number
// entspricht meta.json
durationSeconds: number
fileSize: number
fileModUnix: number
videoWidth?: number
videoHeight?: number
fps?: number
resolution?: string
sourceUrl?: string
updatedAtUnix?: number updatedAtUnix?: number
} fileModUnix?: number
sourceUrl?: string
file?: {
durationSeconds?: number
size?: number
modUnix?: number
sourceUrl?: string
video?: {
width?: number
height?: number
}
}
media?: {
durationSeconds?: number
video?: {
width?: number
height?: number
fps?: number
resolution?: string
}
}
preview?: PreviewMeta
previewSprite?: PreviewSpriteMeta
postwork?: RecordJobPostworkMeta
analysis?: RecordJobAnalysisMeta
modelImage?: string
modelImageUrl?: string
}
export type RecordJob = { export type RecordJob = {
id: string id: string
@ -40,12 +177,11 @@ export type RecordJob = {
videoHeight?: number videoHeight?: number
fps?: number fps?: number
meta?: VideoMeta meta?: RecordJobMeta | string
phase?: string phase?: string
progress?: number progress?: number
// ✅ NEU: Postwork-Queue Status/Position
postWorkKey?: string postWorkKey?: string
postWork?: PostWorkKeyStatus postWork?: PostWorkKeyStatus
@ -82,3 +218,169 @@ export type Model = {
updatedAt?: string updatedAt?: string
lastStream?: string lastStream?: string
} }
export type StoredModelFlags = {
id: string
modelKey: string
favorite?: boolean
liked?: boolean | null
watching?: boolean | null
tags?: string
}
export type GalleryModelFlags = StoredModelFlags & {
image?: string
imageUrl?: string | null
previewScrubberPath?: string
previewScrubberCount?: number
}
export type RecorderSettingsState = {
recordDir: string
doneDir: string
ffmpegPath?: string
autoAddToDownloadList?: boolean
autoStartAddedDownloads?: boolean
useChaturbateApi?: boolean
useMyFreeCamsWatcher?: boolean
autoDeleteSmallDownloads?: boolean
autoDeleteSmallDownloadsBelowMB?: number
blurPreviews?: boolean
teaserPlayback?: 'still' | 'hover' | 'all'
teaserAudio?: boolean
lowDiskPauseBelowGB?: number
enableNotifications?: boolean
}
export type JobEvent = {
type?: 'job_upsert' | 'job_remove'
model?: string
jobId?: string
status?: string
phase?: string
progress?: number
sourceUrl?: string
output?: string
startedAt?: string
startedAtMs?: number
endedAt?: string
endedAtMs?: number
sizeBytes?: number
durationSeconds?: number
previewState?: string
roomStatus?: string
isOnline?: boolean
modelImageUrl?: string
modelChatRoomUrl?: string
ts?: number
postWorkKey?: string
postWork?: {
state?: string
position?: number
waiting?: number
running?: number
maxParallel?: number
}
file?: string
assetId?: string
queue?: FinishedPostworkQueue
state?: FinishedPostworkState
label?: string
position?: number
waiting?: number
running?: number
maxParallel?: number
}
export type StoredModel = {
id: string
input: string
host?: string
modelKey: string
watching: boolean
favorite?: boolean
liked?: boolean | null
isUrl?: boolean
path?: string
roomStatus?: string
isOnline?: boolean
chatRoomUrl?: string
imageUrl?: string
lastOnlineAt?: string
lastOfflineAt?: string
lastRoomSyncAt?: string
}
export type PendingWatchedRoom = {
id: string
modelKey: string
url: string
currentShow: string
imageUrl?: string
}
export type ChaturbateOnlineRoomItem = {
username?: string
current_show?: string
chat_room_url?: string
image_url?: string
}
export type ChaturbateOnlineResponse = {
enabled?: boolean
fetchedAt?: string
count?: number
total?: number
lastError?: string
rooms?: ChaturbateOnlineRoomItem[]
}
export type AutostartState = {
paused?: boolean
pausedByUser?: boolean
pausedByDisk?: boolean
}
export type DoneSortMode =
| 'completed_desc'
| 'completed_asc'
| 'file_asc'
| 'file_desc'
| 'duration_desc'
| 'duration_asc'
| 'size_desc'
| 'size_asc'
export type FileMutationKind = 'delete' | 'keep' | 'rename'
export type RunFileMutationOptions<T> = {
kind: FileMutationKind
job: RecordJob
file: string
rowKey: string
// UI / State
setBusy?: (v: boolean) => void
isBusyNow?: () => boolean
optimisticRemove?: boolean
alreadyRemoved?: boolean
// Ausführung
run: () => Promise<T>
// Hooks
onSuccess?: (result: T) => Promise<void> | void
onError?: (err: unknown) => Promise<void> | void
// Messages
labels: {
invalidTitle: string
invalidBody: string
inUseTitle: string
failTitle: string
failPrefix?: string
}
}