# Conflicts:
#	backend/recorder_settings.json
#	frontend/src/components/ui/FinishedDownloads.tsx
This commit is contained in:
Chris 2026-03-28 12:42:15 +01:00
commit c5739f7d44
52 changed files with 9589 additions and 4321 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
} }
} }
@ -617,13 +613,11 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit {
for i := 1; i < len(cp); i++ { for i := 1; i < len(cp); i++ {
n := cp[i] n := cp[i]
// Nur direkt aufeinanderfolgende Treffer mit gleichem Label zusammenfassen // Direkt aufeinanderfolgende Treffer mit gleichem Label immer zusammenfassen.
const mergeGapSeconds = 1.0 // Sobald ein anderes Label dazwischen liegt, wird automatisch nicht gemergt.
sameLabel := strings.EqualFold(cur.Label, n.Label) sameLabel := strings.EqualFold(cur.Label, n.Label)
touchesOrNear := n.Start <= cur.End+mergeGapSeconds
if sameLabel && touchesOrNear { if sameLabel {
if n.Start < cur.Start { if n.Start < cur.Start {
cur.Start = n.Start cur.Start = n.Start
} }
@ -660,18 +654,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
} }

BIN
backend/assets/img/cb.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
backend/assets/img/mfc.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@ -3,7 +3,6 @@ package main
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"math" "math"
@ -164,10 +163,9 @@ type EnsureAssetsResult struct {
} }
type EnsurePrimaryAssetsResult struct { type EnsurePrimaryAssetsResult struct {
Skipped bool Skipped bool
ThumbGenerated bool ThumbGenerated bool
PreviewGenerated bool MetaOK bool
MetaOK bool
} }
// Public wrappers (kompatibel zu deinem bisherigen API) // Public wrappers (kompatibel zu deinem bisherigen API)
@ -231,7 +229,7 @@ func ensurePrimaryAssetsForVideoWithProgressCtx(
return out, nil return out, nil
} }
_, thumbPath, previewPath, _, metaPath, perr := assetPathsForID(id) _, thumbPath, _, _, metaPath, perr := assetPathsForID(id)
if perr != nil { if perr != nil {
return out, perr return out, perr
} }
@ -256,27 +254,17 @@ func ensurePrimaryAssetsForVideoWithProgressCtx(
return false return false
}() }()
previewBefore := func() bool {
if pfi, err := os.Stat(previewPath); err == nil && !pfi.IsDir() && pfi.Size() > 0 {
return true
}
return false
}()
// Basis-Meta nur für Dauer/Props sicherstellen // Basis-Meta nur für Dauer/Props sicherstellen
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
out.MetaOK = meta.ok out.MetaOK = meta.ok
if thumbBefore && previewBefore { if thumbBefore {
out.Skipped = true out.Skipped = true
progress(1) progress(1)
return out, nil return out, nil
} }
const ( const thumbsW = 1.0
thumbsW = 0.25
previewW = 0.75
)
progress(0) progress(0)
@ -330,63 +318,299 @@ func ensurePrimaryAssetsForVideoWithProgressCtx(
progress(thumbsW) progress(thumbsW)
} }
// ---------------- _ = sourceInputInvalid
// Preview.mp4 progress(1)
// ---------------- return out, nil
}
func ensureMetaForVideoCtx(
ctx context.Context,
videoPath string,
sourceURL string,
) (bool, error) {
videoPath = strings.TrimSpace(videoPath)
if videoPath == "" {
return false, nil
}
fi, err := os.Stat(videoPath)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return false, nil
}
id := assetIDFromVideoPath(videoPath)
if id == "" {
return false, nil
}
_, _, _, _, metaPath, perr := assetPathsForID(id)
if perr != nil {
return false, perr
}
meta, err := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
if err != nil {
return false, err
}
return meta.ok, nil
}
func ensureTeaserForVideoCtx(
ctx context.Context,
videoPath string,
sourceURL string,
) (bool, error) {
videoPath = strings.TrimSpace(videoPath)
if videoPath == "" {
return false, nil
}
fi, err := os.Stat(videoPath)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return false, nil
}
id := assetIDFromVideoPath(videoPath)
if id == "" {
return false, nil
}
_, _, previewPath, _, metaPath, perr := assetPathsForID(id)
if perr != nil {
return false, perr
}
if pfi, err := os.Stat(previewPath); err == nil && pfi != nil && !pfi.IsDir() && pfi.Size() > 0 {
return false, nil
}
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
const ( const (
previewClipLenSec = 0.75 previewClipLenSec = 0.75
previewMaxClips = 12 previewMaxClips = 12
) )
if previewBefore { genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
progress(1) defer cancel()
} else {
func() {
genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel()
progress(thumbsW + 0.02) if err := genSem.Acquire(genCtx); err != nil {
return false, err
}
defer genSem.Release()
if err := genSem.Acquire(genCtx); err != nil { if err := generateTeaserClipsMP4WithProgress(
return genCtx,
} videoPath,
defer genSem.Release() previewPath,
previewClipLenSec,
progress(thumbsW + 0.05) previewMaxClips,
nil,
if err := generateTeaserClipsMP4WithProgress( ); err != nil {
genCtx, if isFFmpegInputInvalidError(err) {
videoPath, return false, nil
previewPath, }
previewClipLenSec, return false, err
previewMaxClips,
func(r float64) {
if r < 0 {
r = 0
}
if r > 1 {
r = 1
}
progress(thumbsW + r*previewW)
},
); err != nil {
if isFFmpegInputInvalidError(err) {
sourceInputInvalid = true
fmt.Printf("⚠️ preview clips skipped (invalid/incomplete input): %s\n", videoPath)
return
}
fmt.Println("⚠️ preview clips:", err)
return
}
out.PreviewGenerated = true
}()
} }
_ = sourceInputInvalid var computedPreviewClips []previewClip
progress(1) if meta.durSec > 0 {
return out, nil opts := TeaserPreviewOptions{
Segments: previewMaxClips,
SegmentDuration: previewClipLenSec,
}
starts, segDur, _ := computeTeaserStarts(meta.durSec, opts)
clips := make([]previewClip, 0, len(starts))
for _, s := range starts {
clips = append(clips, previewClip{
StartSeconds: math.Round(s*1000) / 1000,
DurationSeconds: math.Round(segDur*1000) / 1000,
})
}
computedPreviewClips = clips
}
var spriteMeta *previewSpriteMeta
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.Preview.Sprite != nil {
spriteMeta = oldMeta.Preview.Sprite
}
if meta.durSec > 0 {
_ = writeVideoMetaWithPreviewClipsAndSprite(
metaPath,
fi,
meta.durSec,
meta.vw,
meta.vh,
meta.fps,
meta.sourceURL,
computedPreviewClips,
spriteMeta,
)
}
return true, nil
}
func ensureSpritesForVideoCtx(
ctx context.Context,
videoPath string,
sourceURL string,
) (bool, error) {
videoPath = strings.TrimSpace(videoPath)
if videoPath == "" {
return false, nil
}
fi, err := os.Stat(videoPath)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return false, nil
}
id := assetIDFromVideoPath(videoPath)
if id == "" {
return false, nil
}
_, _, _, spritePath, metaPath, perr := assetPathsForID(id)
if perr != nil {
return false, perr
}
if sfi, err := os.Stat(spritePath); err == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 {
return false, nil
}
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
if !(meta.durSec > 0) {
return false, nil
}
genCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
if err := genSem.Acquire(genCtx); err != nil {
return false, err
}
defer genSem.Release()
cols, rows, count, cellW, cellH := fixedPreviewSpriteLayout()
stepSec := previewSpriteStepSeconds(meta.durSec)
if err := generatePreviewSpriteJPG(
genCtx,
videoPath,
spritePath,
cols,
rows,
stepSec,
cellW,
cellH,
); err != nil {
if sfi, statErr := os.Stat(spritePath); statErr == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 {
return false, nil
}
return false, err
}
var computedPreviewClips []previewClip
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && len(oldMeta.Preview.Clips) > 0 {
computedPreviewClips = oldMeta.Preview.Clips
}
spriteMeta := &previewSpriteMeta{
Path: fmt.Sprintf("/api/preview-sprite/%s", id),
Count: count,
Cols: cols,
Rows: rows,
StepSeconds: stepSec,
CellWidth: cellW,
CellHeight: cellH,
}
_ = writeVideoMetaWithPreviewClipsAndSprite(
metaPath,
fi,
meta.durSec,
meta.vw,
meta.vh,
meta.fps,
meta.sourceURL,
computedPreviewClips,
spriteMeta,
)
return true, nil
}
func ensureAnalyzeForVideoCtx(
ctx context.Context,
videoPath string,
sourceURL string,
goal string,
) (bool, error) {
videoPath = strings.TrimSpace(videoPath)
if videoPath == "" {
return false, nil
}
if hasAIResultsForOutput(videoPath) {
return false, nil
}
fi, err := os.Stat(videoPath)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return false, nil
}
id := assetIDFromVideoPath(videoPath)
if id == "" {
return false, nil
}
_, _, _, spritePath, metaPath, perr := assetPathsForID(id)
if perr != nil {
return false, perr
}
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
if sfi, err := os.Stat(spritePath); err != nil || sfi == nil || sfi.IsDir() || sfi.Size() <= 0 {
return false, nil
}
goal = strings.ToLower(strings.TrimSpace(goal))
if goal == "" {
goal = "nsfw"
}
actx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
durationSec, _ := durationSecondsForAnalyze(actx, videoPath)
hits, aerr := analyzeVideoFromSprite(actx, videoPath, goal)
if aerr != nil {
return false, nil
}
segments := buildSegmentsFromAnalyzeHits(hits, durationSec)
ai := &aiAnalysisMeta{
Goal: goal,
Mode: "sprite",
Hits: hits,
Segments: segments,
AnalyzedAtUnix: time.Now().Unix(),
}
if werr := writeVideoAIForFile(actx, videoPath, meta.sourceURL, ai); werr != nil {
return false, nil
}
return true, nil
} }
func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string) error { func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string) error {
@ -405,154 +629,27 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string)
return nil return nil
} }
_, _, _, spritePath, metaPath, perr := assetPathsForID(id) if _, err := ensureTeaserForVideoCtx(ctx, videoPath, sourceURL); err != nil {
if perr != nil { return fmt.Errorf("deferred teaser failed for %s: %w", videoPath, err)
return perr
} }
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) if _, err := ensureSpritesForVideoCtx(ctx, videoPath, sourceURL); err != nil {
return fmt.Errorf("deferred sprite failed for %s: %w", videoPath, err)
// vorhandene previewClips / spriteMeta aus bestehender meta.json übernehmen
var computedPreviewClips []previewClip
var spriteMeta *previewSpriteMeta
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil {
if len(oldMeta.PreviewClips) > 0 {
computedPreviewClips = oldMeta.PreviewClips
}
if oldMeta.PreviewSprite != nil {
spriteMeta = oldMeta.PreviewSprite
}
} }
// previewClips aus Dauer ableiten, falls noch nicht vorhanden spriteTruth := regeneratePhaseTruthForVideo(videoPath, id)
if len(computedPreviewClips) == 0 && meta.durSec > 0 { if !spriteTruth.SpritesReady {
const ( return fmt.Errorf("preview-sprite.jpg fehlt nach deferred generation: %s", id)
previewClipLenSec = 0.75
previewMaxClips = 12
)
opts := TeaserPreviewOptions{
Segments: previewMaxClips,
SegmentDuration: previewClipLenSec,
}
starts, segDur, _ := computeTeaserStarts(meta.durSec, opts)
clips := make([]previewClip, 0, len(starts))
for _, s := range starts {
clips = append(clips, previewClip{
StartSeconds: math.Round(s*1000) / 1000,
DurationSeconds: math.Round(segDur*1000) / 1000,
})
}
computedPreviewClips = clips
} }
// Sprite erzeugen, wenn noch nicht vorhanden analyzeReady, err := ensureAnalyzeForVideoCtx(ctx, videoPath, sourceURL, "nsfw")
spriteExists := false if err != nil {
if sfi, err := os.Stat(spritePath); err == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 { return fmt.Errorf("deferred analyze failed for %s: %w", videoPath, err)
spriteExists = true
} }
if !spriteExists && meta.durSec > 0 { analyzeTruth := regeneratePhaseTruthForVideo(videoPath, id)
genCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) if !analyzeReady && !analyzeTruth.AnalyzeReady {
defer cancel() return fmt.Errorf("KI-Analyse fehlt nach deferred analyze: %s", id)
if err := genSem.Acquire(genCtx); err == nil {
func() {
defer genSem.Release()
cols, rows, count, cellW, cellH := fixedPreviewSpriteLayout()
stepSec := previewSpriteStepSeconds(meta.durSec)
if err := generatePreviewSpriteJPG(
genCtx,
videoPath,
spritePath,
cols,
rows,
stepSec,
cellW,
cellH,
); err != nil {
fmt.Printf("⚠️ preview sprite failed for %s: %v\n", videoPath)
return
}
spriteMeta = &previewSpriteMeta{
Path: fmt.Sprintf("/api/preview-sprite/%s", id),
Count: count,
Cols: cols,
Rows: rows,
StepSeconds: stepSec,
CellWidth: cellW,
CellHeight: cellH,
}
}()
}
}
// Falls Sprite existiert, aber spriteMeta noch fehlt
if spriteMeta == nil {
if sfi, err := os.Stat(spritePath); err == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 && meta.durSec > 0 {
cols, rows, count, cellW, cellH := fixedPreviewSpriteLayout()
stepSec := previewSpriteStepSeconds(meta.durSec)
spriteMeta = &previewSpriteMeta{
Path: fmt.Sprintf("/api/preview-sprite/%s", id),
Count: count,
Cols: cols,
Rows: rows,
StepSeconds: stepSec,
CellWidth: cellW,
CellHeight: cellH,
}
}
}
// meta.json mit previewClips + sprite ergänzen
if meta.durSec > 0 {
_ = writeVideoMetaWithPreviewClipsAndSprite(
metaPath,
fi,
meta.durSec,
meta.vw,
meta.vh,
meta.fps,
meta.sourceURL,
computedPreviewClips,
spriteMeta,
)
}
// AI erst NACH Sprite
if !hasAIResultsForOutput(videoPath) {
if spriteMeta != nil {
actx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
durationSec, _ := durationSecondsForAnalyze(actx, videoPath)
hits, aerr := analyzeVideoFromSprite(actx, videoPath, "nsfw")
if aerr != nil {
fmt.Println("⚠️ deferred analyze:", aerr)
return nil
}
segments := buildSegmentsFromAnalyzeHits(hits, durationSec)
ai := &aiAnalysisMeta{
Goal: "nsfw",
Mode: "sprite",
Hits: hits,
Segments: segments,
AnalyzedAtUnix: time.Now().Unix(),
}
if werr := writeVideoAIForFile(actx, videoPath, sourceURL, ai); werr != nil {
fmt.Println("⚠️ writeVideoAIForFile:", werr)
}
}
} }
return nil return nil
@ -582,7 +679,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, videoPath, sourceURL string) boo
"enrich", "enrich",
st.State, st.State,
"assets", "assets",
"Ergänze Sprite / AI / Meta…", "Wird verarbeitet…",
st.Position, st.Position,
st.Waiting, st.Waiting,
st.Running, st.Running,
@ -611,7 +708,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, videoPath, sourceURL string) boo
"enrich", "enrich",
"done", "done",
"", "",
"Sprite / AI / Meta fertig", "Fertig",
0, 0,
0, 0,
0, 0,
@ -631,7 +728,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, videoPath, sourceURL string) boo
"enrich", "enrich",
st.State, st.State,
"assets", "assets",
"Warte auf Sprite / AI / Meta…", "Wartet",
st.Position, st.Position,
st.Waiting, st.Waiting,
st.Running, st.Running,
@ -727,7 +824,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
} }
@ -841,11 +938,8 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
}); err != nil { }); err != nil {
if isFFmpegInputInvalidError(err) { if isFFmpegInputInvalidError(err) {
sourceInputInvalid = true sourceInputInvalid = true
fmt.Printf("⚠️ preview clips skipped (invalid/incomplete input): %s\n", videoPath)
return return
} }
fmt.Println("⚠️ preview clips:", err)
return return
} }
@ -907,7 +1001,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
return return
} }
genCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) genCtx, cancel := context.WithTimeout(ctx, 4*time.Minute)
defer cancel() defer cancel()
if err := genSem.Acquire(genCtx); err != nil { if err := genSem.Acquire(genCtx); err != nil {
@ -932,7 +1026,6 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
return return
} }
fmt.Printf("⚠️ preview sprite failed for %s: %v\n", videoPath)
return return
} }
@ -945,16 +1038,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
} }
} }
@ -991,19 +1084,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

@ -3,6 +3,7 @@
package main package main
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"os" "os"
@ -107,36 +108,45 @@ func generatePreviewSpriteJPG(
cols, rows, cols, rows,
) )
cmd := exec.CommandContext( args := []string{
ctx,
ffmpegPath,
"-hide_banner", "-hide_banner",
"-y", "-y",
}
args = append(args, ffmpegInputTol...)
args = append(args,
"-i", videoPath, "-i", videoPath,
"-an", "-an",
"-sn", "-sn",
"-threads", "0", "-threads", "0",
"-vf", vf, "-vf", vf,
"-frames:v", "1", "-frames:v", "1",
"-update", "1",
"-c:v", "mjpeg", "-c:v", "mjpeg",
"-q:v", "4", "-q:v", "4",
"-f", "image2",
tmpPath, tmpPath,
) )
out, err := cmd.CombinedOutput() cmd := exec.CommandContext(ctx, ffmpegPath, args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil { if err != nil {
msg := strings.TrimSpace(string(out)) msgErr := strings.TrimSpace(stderr.String())
if msg == "" { if msgErr == "" {
msg = "(keine ffmpeg-ausgabe)" msgErr = "(kein stderr)"
} }
return fmt.Errorf( return fmt.Errorf(
"ffmpeg sprite failed for %q -> %q: %w | args=%q | output=%s", "ffmpeg sprite failed for %q -> %q: %w | ctxErr=%v | args=%q | stderr=%s",
videoPath, videoPath,
tmpPath, tmpPath,
err, err,
ctx.Err(),
strings.Join(cmd.Args, " "), strings.Join(cmd.Args, " "),
msg, msgErr,
) )
} }

View File

@ -300,8 +300,6 @@ func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []Chaturbate
img, img,
fetchedAt, fetchedAt,
) )
publishJobUpsertsForModelKey(modelKey)
} }
// bekannte Chaturbate-Models, die NICHT im Online-Snapshot sind => offline setzen // bekannte Chaturbate-Models, die NICHT im Online-Snapshot sind => offline setzen
@ -330,33 +328,6 @@ func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []Chaturbate
"", "",
fetchedAt, fetchedAt,
) )
publishJobUpsertsForModelKey(modelKey)
}
}
func publishJobUpsertsForModelKey(modelKey string) {
modelKey = strings.ToLower(strings.TrimSpace(modelKey))
if modelKey == "" {
return
}
jobsMu.Lock()
list := make([]*RecordJob, 0, len(jobs))
for _, j := range jobs {
if j == nil || j.Hidden {
continue
}
if sseModelEventNameForJob(j) != modelKey {
continue
}
c := *j
list = append(list, &c)
}
jobsMu.Unlock()
for _, j := range list {
publishJobUpsert(j)
} }
} }
@ -736,7 +707,6 @@ func refreshRunningJobsHLS(userLower string, newHls string, cookie string, ua st
} }
toStop := make([]*RecordJob, 0, 4) toStop := make([]*RecordJob, 0, 4)
changedJobs := make([]*RecordJob, 0, 4)
jobsMu.Lock() jobsMu.Lock()
for _, j := range jobs { for _, j := range jobs {
@ -759,20 +729,12 @@ func refreshRunningJobsHLS(userLower string, newHls string, cookie string, ua st
j.PreviewStateMsg = "" j.PreviewStateMsg = ""
toStop = append(toStop, j) toStop = append(toStop, j)
} }
if !j.Hidden {
changedJobs = append(changedJobs, j)
}
} }
jobsMu.Unlock() jobsMu.Unlock()
for _, j := range toStop { for _, j := range toStop {
stopPreview(j) stopPreview(j)
} }
for _, j := range changedJobs {
publishJobUpsert(j)
}
} }
func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) { func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {

View File

@ -86,7 +86,6 @@ func stopJobsInternal(list []*RecordJob) {
} }
pl := make([]payload, 0, len(list)) pl := make([]payload, 0, len(list))
visibleJobs := make([]*RecordJob, 0, len(list))
jobsMu.Lock() jobsMu.Lock()
for _, job := range list { for _, job := range list {
@ -97,17 +96,9 @@ func stopJobsInternal(list []*RecordJob) {
job.Progress = 10 job.Progress = 10
pl = append(pl, payload{cmd: job.previewCmd, cancel: job.cancel}) pl = append(pl, payload{cmd: job.previewCmd, cancel: job.cancel})
job.previewCmd = nil job.previewCmd = nil
if !job.Hidden {
visibleJobs = append(visibleJobs, job)
}
} }
jobsMu.Unlock() jobsMu.Unlock()
for _, job := range visibleJobs {
publishJobUpsert(job)
}
for _, p := range pl { for _, p := range pl {
if p.cmd != nil && p.cmd.Process != nil { if p.cmd != nil && p.cmd.Process != nil {
_ = p.cmd.Process.Kill() _ = p.cmd.Process.Kill()
@ -116,10 +107,6 @@ func stopJobsInternal(list []*RecordJob) {
p.cancel() p.cancel()
} }
} }
for _, job := range visibleJobs {
publishJobUpsert(job)
}
} }
func stopAllStoppableJobs() int { func stopAllStoppableJobs() int {

View File

@ -350,13 +350,8 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h
job.PreviewState = "" job.PreviewState = ""
job.PreviewStateAt = "" job.PreviewStateAt = ""
job.PreviewStateMsg = "" job.PreviewStateMsg = ""
hidden := job.Hidden
jobsMu.Unlock() jobsMu.Unlock()
if !hidden {
publishJobUpsert(job)
}
commonIn := []string{"-y"} commonIn := []string{"-y"}
if strings.TrimSpace(userAgent) != "" { if strings.TrimSpace(userAgent) != "" {
commonIn = append(commonIn, "-user_agent", userAgent) commonIn = append(commonIn, "-user_agent", userAgent)
@ -423,13 +418,8 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h
job.PreviewStateMsg = st job.PreviewStateMsg = st
} }
} }
hidden := job.Hidden
jobsMu.Unlock() jobsMu.Unlock()
if !hidden {
publishJobUpsert(job)
}
//fmt.Printf("⚠️ preview hq ffmpeg failed: %v (%s)\n", err, st) //fmt.Printf("⚠️ preview hq ffmpeg failed: %v (%s)\n", err, st)
} }

View File

@ -11,22 +11,18 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"math"
"net/http" "net/http"
"net/url"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/grafov/m3u8" "github.com/grafov/m3u8"
gocpu "github.com/shirou/gopsutil/v3/cpu"
godisk "github.com/shirou/gopsutil/v3/disk"
) )
var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`) var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`)
@ -213,13 +209,6 @@ var (
jobsMu = sync.Mutex{} jobsMu = sync.Mutex{}
) )
var serverStartedAt = time.Now()
var lastCPUUsageBits uint64 // atomic float64 bits
func setLastCPUUsage(v float64) { atomic.StoreUint64(&lastCPUUsageBits, math.Float64bits(v)) }
func getLastCPUUsage() float64 { return math.Float64frombits(atomic.LoadUint64(&lastCPUUsageBits)) }
func startPreviewIdleKiller() { func startPreviewIdleKiller() {
t := time.NewTicker(5 * time.Second) t := time.NewTicker(5 * time.Second)
go func() { go func() {
@ -254,8 +243,8 @@ func startPreviewIdleKiller() {
} }
func init() { func init() {
initFFmpegSemaphores() initFFmpegLimiters()
startAdaptiveSemController(context.Background()) startCPUUsageSampler(context.Background())
startPreviewIdleKiller() startPreviewIdleKiller()
initSSE() initSSE()
@ -271,7 +260,6 @@ func publishJob(jobID string) bool {
j.Hidden = false j.Hidden = false
jobsMu.Unlock() jobsMu.Unlock()
publishJobUpsert(j)
return true return true
} }
@ -328,228 +316,6 @@ func detectFFprobePath() string {
return "ffprobe" return "ffprobe"
} }
// ---------- Dynamic Semaphore (resizeable by load controller) ----------
type DynSem struct {
mu sync.Mutex
in int
max int
cap int
}
func NewDynSem(initial, cap int) *DynSem {
if cap < 1 {
cap = 1
}
if initial < 1 {
initial = 1
}
if initial > cap {
initial = cap
}
return &DynSem{max: initial, cap: cap}
}
func (s *DynSem) Acquire(ctx context.Context) error {
for {
if ctx != nil && ctx.Err() != nil {
return ctx.Err()
}
s.mu.Lock()
if s.in < s.max {
s.in++
s.mu.Unlock()
return nil
}
s.mu.Unlock()
time.Sleep(25 * time.Millisecond)
}
}
func (s *DynSem) Release() {
s.mu.Lock()
if s.in > 0 {
s.in--
}
s.mu.Unlock()
}
func (s *DynSem) SetMax(n int) {
if n < 1 {
n = 1
}
if n > s.cap {
n = s.cap
}
s.mu.Lock()
s.max = n
s.mu.Unlock()
}
func (s *DynSem) Max() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.max
}
func (s *DynSem) Cap() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.cap
}
func (s *DynSem) InUse() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.in
}
var (
genSem *DynSem
previewSem *DynSem
thumbSem *DynSem
durSem *DynSem
)
func clamp(n, lo, hi int) int {
if n < lo {
return lo
}
if n > hi {
return hi
}
return n
}
func envInt(name string) (int, bool) {
v := strings.TrimSpace(os.Getenv(name))
if v == "" {
return 0, false
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, false
}
return n, true
}
func initFFmpegSemaphores() {
cpu := runtime.NumCPU()
if cpu <= 0 {
cpu = 2
}
// Defaults (heuristisch)
previewN := clamp((cpu+1)/2, 1, 6) // x264 live -> konservativ
thumbN := clamp(cpu, 2, 12) // Frames -> darf höher
genN := clamp((cpu+3)/4, 1, 4) // preview.mp4 clips -> eher klein
durN := clamp(cpu, 2, 16) // ffprobe: darf höher, aber nicht unbegrenzt
// ENV Overrides (optional)
if n, ok := envInt("PREVIEW_WORKERS"); ok {
previewN = clamp(n, 1, 32)
}
if n, ok := envInt("THUMB_WORKERS"); ok {
thumbN = clamp(n, 1, 64)
}
if n, ok := envInt("GEN_WORKERS"); ok {
genN = clamp(n, 1, 16)
}
if n, ok := envInt("DUR_WORKERS"); ok {
durN = clamp(n, 1, 64)
}
// Caps (Obergrenzen) können via ENV überschrieben werden
previewCap := clamp(cpu, 2, 12)
thumbCap := clamp(cpu*2, 4, 32)
genCap := clamp((cpu+1)/2, 2, 12)
durCap := clamp(cpu*2, 4, 32)
if n, ok := envInt("PREVIEW_CAP"); ok {
previewCap = clamp(n, 1, 64)
}
if n, ok := envInt("THUMB_CAP"); ok {
thumbCap = clamp(n, 1, 128)
}
if n, ok := envInt("GEN_CAP"); ok {
genCap = clamp(n, 1, 64)
}
if n, ok := envInt("DUR_CAP"); ok {
durCap = clamp(n, 1, 128)
}
// Initial max (Startwerte)
previewSem = NewDynSem(previewN, previewCap)
thumbSem = NewDynSem(thumbN, thumbCap)
genSem = NewDynSem(genN, genCap)
durSem = NewDynSem(durN, durCap)
/*
fmt.Printf(
"🔧 semaphores(init): preview=%d/%d thumb=%d/%d gen=%d/%d dur=%d/%d (cpu=%d)\n",
previewSem.Max(), previewSem.Cap(),
thumbSem.Max(), thumbSem.Cap(),
genSem.Max(), genSem.Cap(),
durSem.Max(), durSem.Cap(),
cpu,
)
fmt.Printf(
"🔧 semaphores: preview=%d thumb=%d gen=%d dur=%d (cpu=%d)\n",
previewN, thumbN, genN, durN, cpu,
)
*/
}
func startAdaptiveSemController(ctx context.Context) {
targetHi := 85.0
targetLo := 65.0
if v := strings.TrimSpace(os.Getenv("CPU_TARGET_HI")); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
targetHi = f
}
}
if v := strings.TrimSpace(os.Getenv("CPU_TARGET_LO")); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
targetLo = f
}
}
// Warmup (erste Messung kann 0 sein)
_, _ = gocpu.Percent(200*time.Millisecond, false)
t := time.NewTicker(2 * time.Second)
go func() {
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
p, err := gocpu.Percent(0, false)
if err != nil || len(p) == 0 {
continue
}
usage := p[0]
setLastCPUUsage(usage)
// Preview ist am teuersten → konservativ
if usage > targetHi {
previewSem.SetMax(previewSem.Max() - 1)
genSem.SetMax(genSem.Max() - 1)
thumbSem.SetMax(thumbSem.Max() - 1)
} else if usage < targetLo {
previewSem.SetMax(previewSem.Max() + 1)
genSem.SetMax(genSem.Max() + 1)
thumbSem.SetMax(thumbSem.Max() + 1)
}
}
}
}()
}
type durEntry struct { type durEntry struct {
size int64 size int64
mod time.Time mod time.Time
@ -565,185 +331,6 @@ var startedAtFromFilenameRe = regexp.MustCompile(
`^(.+)_([0-9]{1,2})_([0-9]{1,2})_([0-9]{4})__([0-9]{1,2})-([0-9]{2})-([0-9]{2})$`, `^(.+)_([0-9]{1,2})_([0-9]{1,2})_([0-9]{4})__([0-9]{1,2})-([0-9]{2})-([0-9]{2})$`,
) )
func buildPerfSnapshot() map[string]any {
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
s := getSettings()
recordDir, _ := resolvePathRelativeToApp(s.RecordDir)
var diskFreeBytes uint64
var diskTotalBytes uint64
var diskUsedPercent float64
diskPath := recordDir
if recordDir != "" {
if u, err := godisk.Usage(recordDir); err == nil && u != nil {
diskFreeBytes = u.Free
diskTotalBytes = u.Total
diskUsedPercent = u.UsedPercent
}
}
// ✅ Dynamische Disk-Schwellen (2× inFlight, Resume = +3GB)
pauseGB, resumeGB, inFlight, pauseNeed, resumeNeed := computeDiskThresholds()
resp := map[string]any{
"ts": time.Now().UTC().Format(time.RFC3339Nano),
"serverMs": time.Now().UTC().UnixMilli(), // ✅ für "Ping" im Frontend (Approx)
"uptimeSec": time.Since(serverStartedAt).Seconds(),
"cpuPercent": func() float64 {
v := getLastCPUUsage()
if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 {
return 0
}
return v
}(),
"diskPath": diskPath,
"diskFreeBytes": diskFreeBytes,
"diskTotalBytes": diskTotalBytes,
"diskUsedPercent": diskUsedPercent,
"diskEmergency": atomic.LoadInt32(&diskEmergency) == 1,
// ✅ statt LowDiskPauseBelowGB aus Settings
"diskPauseBelowGB": pauseGB,
"diskResumeAboveGB": resumeGB,
// ✅ optional, aber sehr hilfreich (Debug/UI)
"diskInFlightBytes": inFlight,
"diskInFlightHuman": formatBytesSI(u64ToI64(inFlight)),
"diskPauseNeedBytes": pauseNeed,
"diskPauseNeedHuman": formatBytesSI(u64ToI64(pauseNeed)),
"diskResumeNeedBytes": resumeNeed,
"diskResumeNeedHuman": formatBytesSI(u64ToI64(resumeNeed)),
"goroutines": runtime.NumGoroutine(),
"mem": map[string]any{
"alloc": ms.Alloc,
"heapAlloc": ms.HeapAlloc,
"heapInuse": ms.HeapInuse,
"sys": ms.Sys,
"numGC": ms.NumGC,
},
}
sem := map[string]any{}
if genSem != nil {
sem["gen"] = map[string]any{"inUse": genSem.InUse(), "cap": genSem.Cap(), "max": genSem.Max()}
}
if previewSem != nil {
sem["preview"] = map[string]any{"inUse": previewSem.InUse(), "cap": previewSem.Cap(), "max": previewSem.Max()}
}
if thumbSem != nil {
sem["thumb"] = map[string]any{"inUse": thumbSem.InUse(), "cap": thumbSem.Cap(), "max": thumbSem.Max()}
}
if durSem != nil {
sem["dur"] = map[string]any{"inUse": durSem.InUse(), "cap": durSem.Cap(), "max": durSem.Max()}
}
if len(sem) > 0 {
resp["sem"] = sem
}
return resp
}
func pingHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
}
func perfHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
resp := buildPerfSnapshot()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(resp)
}
func perfStreamHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
fl, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming nicht unterstützt", http.StatusInternalServerError)
return
}
// Optional: client kann Intervall mitgeben: /api/perf/stream?ms=5000
ms := 5000
if q := r.URL.Query().Get("ms"); q != "" {
if v, err := strconv.Atoi(q); err == nil {
// clamp: 1000..30000
if v < 1000 {
v = 1000
}
if v > 30000 {
v = 30000
}
ms = v
}
}
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Connection", "keep-alive")
// hilfreich hinter nginx/proxies:
w.Header().Set("X-Accel-Buffering", "no")
ctx := r.Context()
// sofort erstes Event schicken
send := func() error {
payload := buildPerfSnapshot()
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(payload); err != nil {
return err
}
// event: perf
_, _ = io.WriteString(w, "event: perf\n")
_, _ = io.WriteString(w, "data: ")
_, _ = w.Write(buf.Bytes())
_, _ = io.WriteString(w, "\n")
fl.Flush()
return nil
}
// initial
_ = send()
t := time.NewTicker(time.Duration(ms) * time.Millisecond)
hb := time.NewTicker(15 * time.Second) // heartbeat gegen Proxy timeouts
defer t.Stop()
defer hb.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
_ = send()
case <-hb.C:
// SSE Kommentar als Heartbeat
_, _ = io.WriteString(w, ": keep-alive\n\n")
fl.Flush()
}
}
}
func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) { func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) {
// returns: (shouldDelete, sizeBytes, thresholdBytes) // returns: (shouldDelete, sizeBytes, thresholdBytes)
@ -796,12 +383,7 @@ func setJobPhase(job *RecordJob, phase string, progress int) {
jobsMu.Lock() jobsMu.Lock()
job.Phase = phase job.Phase = phase
job.Progress = progress job.Progress = progress
hidden := job.Hidden
jobsMu.Unlock() jobsMu.Unlock()
if !hidden {
publishJobUpsert(job)
}
} }
func durationSecondsCached(ctx context.Context, path string) (float64, error) { func durationSecondsCached(ctx context.Context, path string) (float64, error) {
@ -923,10 +505,46 @@ func detectFFmpegPath() string {
return "ffmpeg" return "ffmpeg"
} }
func removeGeneratedForID(id string) { func isAssetIDInUse(id string) bool {
// ✅ canonical id: wie beim Erzeugen der generated Ordner
id = strings.TrimSpace(id) id = strings.TrimSpace(id)
if id == "" { if id == "" {
return false
}
jobsMu.Lock()
defer jobsMu.Unlock()
for _, j := range jobs {
if j == nil {
continue
}
aid := strings.TrimSpace(assetIDForJob(j))
if aid == "" || !strings.EqualFold(aid, id) {
continue
}
// Alles was noch nicht terminal ist, gilt als "in Benutzung"
if !isTerminalJobStatus(j.Status) {
return true
}
// Auch Postwork/Queue lieber schützen
if isPostworkJob(j) {
return true
}
}
return false
}
func removeGeneratedForID(id string) {
id = strings.TrimSpace(id)
if id == "" {
return
}
if isAssetIDInUse(id) {
return return
} }
@ -948,11 +566,6 @@ func removeGeneratedForID(id string) {
_ = os.RemoveAll(filepath.Join(root, id)) _ = os.RemoveAll(filepath.Join(root, id))
} }
// (optional aber sinnvoll) 1b) Legacy: generated/<id>/ (falls noch alte Assets existieren)
if root, _ := generatedRoot(); strings.TrimSpace(root) != "" {
_ = os.RemoveAll(filepath.Join(root, id))
}
// 2) Temp Preview Segmente (HLS) wegräumen // 2) Temp Preview Segmente (HLS) wegräumen
// (%TEMP%/rec_preview/<assetID>) // (%TEMP%/rec_preview/<assetID>)
_ = os.RemoveAll(filepath.Join(os.TempDir(), "rec_preview", id)) _ = os.RemoveAll(filepath.Join(os.TempDir(), "rec_preview", id))
@ -1695,8 +1308,6 @@ func renameJobsOutputBasename(oldFile, newFile string) {
return return
} }
changedJobs := make([]*RecordJob, 0, 4)
jobsMu.Lock() jobsMu.Lock()
for _, j := range jobs { for _, j := range jobs {
if j == nil { if j == nil {
@ -1708,21 +1319,14 @@ func renameJobsOutputBasename(oldFile, newFile string) {
} }
if filepath.Base(out) == oldFile { if filepath.Base(out) == oldFile {
j.Output = filepath.Join(filepath.Dir(out), newFile) j.Output = filepath.Join(filepath.Dir(out), newFile)
if !j.Hidden {
changedJobs = append(changedJobs, j)
}
} }
} }
jobsMu.Unlock() jobsMu.Unlock()
for _, j := range changedJobs {
publishJobUpsert(j)
}
} }
// nimmt jetzt *HTTPClient entgegen // nimmt jetzt *HTTPClient entgegen
func FetchPlaylist(ctx context.Context, hc *HTTPClient, hlsSource, httpCookie string) (*Playlist, error) { func FetchPlaylist(ctx context.Context, hc *HTTPClient, hlsSource, httpCookie string) (*Playlist, error) {
if hlsSource == "" { if strings.TrimSpace(hlsSource) == "" {
return nil, errors.New("HLS-URL leer") return nil, errors.New("HLS-URL leer")
} }
@ -1737,51 +1341,122 @@ func FetchPlaylist(ctx context.Context, hc *HTTPClient, hlsSource, httpCookie st
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("master playlist bad status: %d %s", resp.StatusCode, hlsSource)
}
playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
if err != nil || listType != m3u8.MASTER { if err != nil {
return nil, fmt.Errorf("master playlist decode failed: %w", err)
}
if listType != m3u8.MASTER {
return nil, errors.New("keine gültige Master-Playlist") return nil, errors.New("keine gültige Master-Playlist")
} }
master := playlist.(*m3u8.MasterPlaylist) master, ok := playlist.(*m3u8.MasterPlaylist)
var bestURI string if !ok || master == nil {
return nil, errors.New("master playlist parse fehlgeschlagen")
}
baseURL, err := url.Parse(hlsSource)
if err != nil {
return nil, fmt.Errorf("master url parse failed: %w", err)
}
var bestVariant *m3u8.Variant
var bestWidth int var bestWidth int
var bestFramerate int var bestFramerate int
for _, variant := range master.Variants { parseFramerate := func(v *m3u8.Variant) int {
if variant == nil || variant.Resolution == "" { if v == nil {
continue return 0
} }
parts := strings.Split(variant.Resolution, "x") // 1) Falls BANDWIDTH/FRAME-RATE Infos im Namen kodiert sind
name := strings.ToUpper(strings.TrimSpace(v.Name))
switch {
case strings.Contains(name, "FPS:60"), strings.Contains(name, "FPS=60"), strings.Contains(name, "60FPS"):
return 60
case strings.Contains(name, "FPS:50"), strings.Contains(name, "FPS=50"), strings.Contains(name, "50FPS"):
return 50
case strings.Contains(name, "FPS:30"), strings.Contains(name, "FPS=30"), strings.Contains(name, "30FPS"):
return 30
}
// 2) Fallback Standard
return 30
}
parseWidth := func(v *m3u8.Variant) int {
if v == nil {
return 0
}
res := strings.TrimSpace(v.Resolution)
if res == "" {
return 0
}
parts := strings.Split(res, "x")
if len(parts) != 2 { if len(parts) != 2 {
continue return 0
} }
width, err := strconv.Atoi(parts[0]) w, err := strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil { if err != nil {
return 0
}
return w
}
for _, variant := range master.Variants {
if variant == nil || strings.TrimSpace(variant.URI) == "" {
continue continue
} }
fr := 30 width := parseWidth(variant)
if strings.Contains(variant.Name, "FPS:60.0") { fr := parseFramerate(variant)
fr = 60
}
if width > bestWidth || (width == bestWidth && fr > bestFramerate) { // auch Varianten ohne Resolution zulassen, aber niedriger priorisieren
if bestVariant == nil ||
width > bestWidth ||
(width == bestWidth && fr > bestFramerate) {
bestVariant = variant
bestWidth = width bestWidth = width
bestFramerate = fr bestFramerate = fr
bestURI = variant.URI
} }
} }
if bestURI == "" { if bestVariant == nil {
return nil, errors.New("keine gültige Auflösung gefunden") return nil, errors.New("keine gültige Variante gefunden")
}
variantRef, err := url.Parse(strings.TrimSpace(bestVariant.URI))
if err != nil {
return nil, fmt.Errorf("variant uri parse failed: %w", err)
}
playlistURL := baseURL.ResolveReference(variantRef).String()
playlistParsed, err := url.Parse(playlistURL)
if err != nil {
return nil, fmt.Errorf("playlist url parse failed: %w", err)
}
rootParsed := *playlistParsed
rootParsed.RawQuery = ""
rootParsed.Fragment = ""
lastSlash := strings.LastIndex(rootParsed.Path, "/")
if lastSlash >= 0 {
rootParsed.Path = rootParsed.Path[:lastSlash+1]
} else {
rootParsed.Path = "/"
} }
root := hlsSource[:strings.LastIndex(hlsSource, "/")+1]
return &Playlist{ return &Playlist{
PlaylistURL: root + bestURI, PlaylistURL: playlistURL,
RootURL: root, RootURL: rootParsed.String(),
Resolution: bestWidth, Resolution: bestWidth,
Framerate: bestFramerate, Framerate: bestFramerate,
}, nil }, nil

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 videoFileMeta struct {
Size int64 `json:"size"`
ModUnix int64 `json:"modUnix"`
SourceURL string `json:"sourceUrl,omitempty"`
}
type videoStreamMeta struct {
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
FPS float64 `json:"fps,omitempty"`
Resolution string `json:"resolution,omitempty"`
}
type videoMediaMeta struct {
DurationSeconds float64 `json:"durationSeconds"`
Video videoStreamMeta `json:"video"`
}
type previewThumbMeta struct {
Path string `json:"path,omitempty"`
Exists bool `json:"exists,omitempty"`
}
type previewMeta struct {
Clips []previewClip `json:"clips,omitempty"`
Thumb *previewThumbMeta `json:"thumb,omitempty"`
Sprite *previewSpriteMeta `json:"sprite,omitempty"`
}
type analysisMeta struct {
AI *aiAnalysisMeta `json:"ai,omitempty"`
}
type postworkHistoryEntry struct {
Queue string `json:"queue"`
Phase string `json:"phase"`
State string `json:"state"`
Label string `json:"label,omitempty"`
Position int `json:"position,omitempty"`
Waiting int `json:"waiting,omitempty"`
Running int `json:"running,omitempty"`
MaxParallel int `json:"maxParallel,omitempty"`
TS int64 `json:"ts"`
}
type postworkCompletedMeta struct {
Meta bool `json:"meta,omitempty"`
Teaser bool `json:"teaser,omitempty"`
Thumb bool `json:"thumb,omitempty"`
Sprites bool `json:"sprites,omitempty"`
Analyze bool `json:"analyze,omitempty"`
}
type postworkMeta struct {
Completed *postworkCompletedMeta `json:"completed,omitempty"`
History []postworkHistoryEntry `json:"history,omitempty"`
}
type videoMeta struct { type videoMeta struct {
Version int `json:"version"` Version int `json:"version"`
DurationSeconds float64 `json:"durationSeconds"`
FileSize int64 `json:"fileSize"`
FileModUnix int64 `json:"fileModUnix"`
VideoWidth int `json:"videoWidth,omitempty"`
VideoHeight int `json:"videoHeight,omitempty"`
FPS float64 `json:"fps,omitempty"`
Resolution string `json:"resolution,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
PreviewClips []previewClip `json:"previewClips,omitempty"`
PreviewSprite *previewSpriteMeta `json:"previewSprite,omitempty"`
AI *aiAnalysisMeta `json:"ai,omitempty"`
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,
DurationSeconds: dur, UpdatedAtUnix: time.Now().Unix(),
FileSize: fi.Size(), File: videoFileMeta{
FileModUnix: fi.ModTime().Unix(), Size: fi.Size(),
VideoWidth: w, ModUnix: fi.ModTime().Unix(),
VideoHeight: h, SourceURL: strings.TrimSpace(sourceURL),
FPS: fps, },
Resolution: formatResolution(w, h), Media: videoMediaMeta{
SourceURL: strings.TrimSpace(sourceURL), DurationSeconds: dur,
UpdatedAtUnix: time.Now().Unix(), Video: videoStreamMeta{
Width: w,
PreviewClips: nil, Height: h,
PreviewSprite: nil, FPS: fps,
AI: nil, Resolution: formatResolution(w, h),
},
},
} }
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,
DurationSeconds: dur, UpdatedAtUnix: time.Now().Unix(),
FileSize: fi.Size(), File: videoFileMeta{
FileModUnix: fi.ModTime().Unix(), Size: fi.Size(),
VideoWidth: w, ModUnix: fi.ModTime().Unix(),
VideoHeight: h, SourceURL: strings.TrimSpace(sourceURL),
FPS: fps, },
Resolution: formatResolution(w, h), Media: videoMediaMeta{
SourceURL: strings.TrimSpace(sourceURL), DurationSeconds: dur,
PreviewClips: clips, Video: videoStreamMeta{
UpdatedAtUnix: time.Now().Unix(), Width: w,
Height: h,
FPS: fps,
Resolution: formatResolution(w, h),
},
},
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,
DurationSeconds: dur, UpdatedAtUnix: time.Now().Unix(),
FileSize: fi.Size(), File: videoFileMeta{
FileModUnix: fi.ModTime().Unix(), Size: fi.Size(),
VideoWidth: w, ModUnix: fi.ModTime().Unix(),
VideoHeight: h, SourceURL: strings.TrimSpace(sourceURL),
FPS: fps, },
Resolution: formatResolution(w, h), Media: videoMediaMeta{
SourceURL: strings.TrimSpace(sourceURL), DurationSeconds: dur,
PreviewClips: clips, Video: videoStreamMeta{
PreviewSprite: sprite, Width: w,
UpdatedAtUnix: time.Now().Unix(), Height: h,
FPS: fps,
Resolution: formatResolution(w, h),
},
},
Preview: previewMeta{
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,
DurationSeconds: dur, UpdatedAtUnix: time.Now().Unix(),
FileSize: fi.Size(), File: videoFileMeta{
FileModUnix: fi.ModTime().Unix(), Size: fi.Size(),
VideoWidth: w, ModUnix: fi.ModTime().Unix(),
VideoHeight: h, SourceURL: strings.TrimSpace(sourceURL),
FPS: fps, },
Resolution: formatResolution(w, h), Media: videoMediaMeta{
SourceURL: strings.TrimSpace(sourceURL), DurationSeconds: dur,
UpdatedAtUnix: time.Now().Unix(), Video: videoStreamMeta{
Width: w,
PreviewClips: nil, Height: h,
PreviewSprite: nil, FPS: fps,
AI: ai, Resolution: formatResolution(w, h),
},
},
Analysis: analysisMeta{
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(),
})
}

343
backend/performance.go Normal file
View File

@ -0,0 +1,343 @@
// backend\performance.go
package main
import (
"bytes"
"context"
"encoding/json"
"io"
"math"
"net/http"
"os"
"runtime"
"strconv"
"strings"
"sync/atomic"
"time"
gocpu "github.com/shirou/gopsutil/v3/cpu"
godisk "github.com/shirou/gopsutil/v3/disk"
)
// Server-Startzeit für Perf/Uptime
var serverStartedAt = time.Now()
var lastCPUUsageBits uint64 // atomic float64 bits
func setLastCPUUsage(v float64) {
atomic.StoreUint64(&lastCPUUsageBits, math.Float64bits(v))
}
func getLastCPUUsage() float64 {
return math.Float64frombits(atomic.LoadUint64(&lastCPUUsageBits))
}
func startCPUUsageSampler(ctx context.Context) {
_, _ = gocpu.Percent(200*time.Millisecond, false)
t := time.NewTicker(2 * time.Second)
go func() {
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
p, err := gocpu.Percent(0, false)
if err != nil || len(p) == 0 {
continue
}
usage := p[0]
if math.IsNaN(usage) || math.IsInf(usage, 0) || usage < 0 {
continue
}
if usage > 100 {
usage = 100
}
setLastCPUUsage(usage)
}
}
}()
}
// ---------- Feste ffmpeg/ffprobe Limiter ----------
type FixedLimiter chan struct{}
func NewFixedLimiter(n int) FixedLimiter {
if n < 1 {
n = 1
}
return make(FixedLimiter, n)
}
func (l FixedLimiter) Acquire(ctx context.Context) error {
if l == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
select {
case l <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (l FixedLimiter) Release() {
if l == nil {
return
}
select {
case <-l:
default:
}
}
func (l FixedLimiter) Limit() int {
return cap(l)
}
func (l FixedLimiter) InUse() int {
return len(l)
}
var (
previewSem FixedLimiter
thumbSem FixedLimiter
genSem FixedLimiter
durSem FixedLimiter
)
func envInt(name string) (int, bool) {
v := strings.TrimSpace(os.Getenv(name))
if v == "" {
return 0, false
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, false
}
return n, true
}
func initFFmpegLimiters() {
previewN := 2
thumbN := 4
genN := 2
durN := 8
if n, ok := envInt("PREVIEW_WORKERS"); ok && n > 0 {
previewN = n
}
if n, ok := envInt("THUMB_WORKERS"); ok && n > 0 {
thumbN = n
}
if n, ok := envInt("GEN_WORKERS"); ok && n > 0 {
genN = n
}
if n, ok := envInt("DUR_WORKERS"); ok && n > 0 {
durN = n
}
previewSem = NewFixedLimiter(previewN)
thumbSem = NewFixedLimiter(thumbN)
genSem = NewFixedLimiter(genN)
durSem = NewFixedLimiter(durN)
setLastCPUUsage(0)
startCPUUsageSampler(context.Background())
}
func buildPerfSnapshot() map[string]any {
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
s := getSettings()
recordDir, _ := resolvePathRelativeToApp(s.RecordDir)
var diskFreeBytes uint64
var diskTotalBytes uint64
var diskUsedPercent float64
diskPath := recordDir
if recordDir != "" {
if u, err := godisk.Usage(recordDir); err == nil && u != nil {
diskFreeBytes = u.Free
diskTotalBytes = u.Total
diskUsedPercent = u.UsedPercent
}
}
// ✅ Dynamische Disk-Schwellen (2× inFlight, Resume = +3GB)
pauseGB, resumeGB, inFlight, pauseNeed, resumeNeed := computeDiskThresholds()
resp := map[string]any{
"ts": time.Now().UTC().Format(time.RFC3339Nano),
"serverMs": time.Now().UTC().UnixMilli(),
"uptimeSec": time.Since(serverStartedAt).Seconds(),
"cpuPercent": func() float64 {
v := getLastCPUUsage()
if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 {
return 0
}
if v > 100 {
return 100
}
return v
}(),
"diskPath": diskPath,
"diskFreeBytes": diskFreeBytes,
"diskTotalBytes": diskTotalBytes,
"diskUsedPercent": diskUsedPercent,
"diskEmergency": atomic.LoadInt32(&diskEmergency) == 1,
"diskPauseBelowGB": pauseGB,
"diskResumeAboveGB": resumeGB,
"diskInFlightBytes": inFlight,
"diskInFlightHuman": formatBytesSI(u64ToI64(inFlight)),
"diskPauseNeedBytes": pauseNeed,
"diskPauseNeedHuman": formatBytesSI(u64ToI64(pauseNeed)),
"diskResumeNeedBytes": resumeNeed,
"diskResumeNeedHuman": formatBytesSI(u64ToI64(resumeNeed)),
"goroutines": runtime.NumGoroutine(),
"mem": map[string]any{
"alloc": ms.Alloc,
"heapAlloc": ms.HeapAlloc,
"heapInuse": ms.HeapInuse,
"sys": ms.Sys,
"numGC": ms.NumGC,
},
}
sem := map[string]any{}
if previewSem != nil {
sem["preview"] = map[string]any{
"inUse": previewSem.InUse(),
"limit": previewSem.Limit(),
}
}
if thumbSem != nil {
sem["thumb"] = map[string]any{
"inUse": thumbSem.InUse(),
"limit": thumbSem.Limit(),
}
}
if genSem != nil {
sem["gen"] = map[string]any{
"inUse": genSem.InUse(),
"limit": genSem.Limit(),
}
}
if durSem != nil {
sem["dur"] = map[string]any{
"inUse": durSem.InUse(),
"limit": durSem.Limit(),
}
}
if len(sem) > 0 {
resp["sem"] = sem
}
return resp
}
func pingHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
}
func perfHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
resp := buildPerfSnapshot()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(resp)
}
func perfStreamHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
fl, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming nicht unterstützt", http.StatusInternalServerError)
return
}
ms := 5000
if q := r.URL.Query().Get("ms"); q != "" {
if v, err := strconv.Atoi(q); err == nil {
if v < 1000 {
v = 1000
}
if v > 30000 {
v = 30000
}
ms = v
}
}
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
ctx := r.Context()
send := func() error {
payload := buildPerfSnapshot()
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(payload); err != nil {
return err
}
_, _ = io.WriteString(w, "event: perf\n")
_, _ = io.WriteString(w, "data: ")
_, _ = w.Write(buf.Bytes())
_, _ = io.WriteString(w, "\n")
fl.Flush()
return nil
}
_ = send()
t := time.NewTicker(time.Duration(ms) * time.Millisecond)
hb := time.NewTicker(15 * time.Second)
defer t.Stop()
defer hb.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
_ = send()
case <-hb.C:
_, _ = io.WriteString(w, ": keep-alive\n\n")
fl.Flush()
}
}
}

View File

@ -27,6 +27,7 @@ type PostWorkQueue struct {
// ✅ für UI: Warteschlange + Running-Keys tracken // ✅ für UI: Warteschlange + Running-Keys tracken
waitingKeys []string // FIFO der Keys, die noch NICHT gestartet sind waitingKeys []string // FIFO der Keys, die noch NICHT gestartet sind
runningKeys map[string]struct{} // Keys, die gerade wirklich laufen (Semaphor gehalten) runningKeys map[string]struct{} // Keys, die gerade wirklich laufen (Semaphor gehalten)
cancelByKey map[string]context.CancelFunc
} }
func NewPostWorkQueue(queueSize int, maxParallelFFmpeg int) *PostWorkQueue { func NewPostWorkQueue(queueSize int, maxParallelFFmpeg int) *PostWorkQueue {
@ -45,6 +46,7 @@ func NewPostWorkQueue(queueSize int, maxParallelFFmpeg int) *PostWorkQueue {
waitingKeys: make([]string, 0, queueSize), waitingKeys: make([]string, 0, queueSize),
runningKeys: make(map[string]struct{}), runningKeys: make(map[string]struct{}),
cancelByKey: make(map[string]context.CancelFunc),
} }
} }
@ -80,6 +82,23 @@ func (pq *PostWorkQueue) Enqueue(task PostWorkTask) bool {
} }
} }
func (pq *PostWorkQueue) Cancel(key string) bool {
if strings.TrimSpace(key) == "" {
return false
}
pq.mu.Lock()
cancel := pq.cancelByKey[key]
pq.mu.Unlock()
if cancel != nil {
cancel()
return true
}
return false
}
func (pq *PostWorkQueue) removeWaitingKeyLocked(key string) { func (pq *PostWorkQueue) removeWaitingKeyLocked(key string) {
for i, k := range pq.waitingKeys { for i, k := range pq.waitingKeys {
if k == key { if k == key {
@ -171,7 +190,18 @@ func (pq *PostWorkQueue) workerLoop(id int) {
// 3) Optional: Task timeout (gegen hängende ffmpeg) // 3) Optional: Task timeout (gegen hängende ffmpeg)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
defer cancel()
pq.mu.Lock()
pq.cancelByKey[task.Key] = cancel
pq.mu.Unlock()
defer func() {
cancel()
pq.mu.Lock()
delete(pq.cancelByKey, task.Key)
pq.mu.Unlock()
}()
// 4) Run // 4) Run
if task.Run != nil { if task.Run != nil {
@ -291,9 +321,9 @@ func startEnrichStatusRefresher() {
continue continue
} }
label := "Warte auf Sprite / AI / Meta…" label := "Wartet"
if st.State == "running" { if st.State == "running" {
label = "Ergänze Sprite / AI / Meta…" label = "Wird verarbeitet…"
} }
publishFinishedPostworkStateForJob( publishFinishedPostworkStateForJob(
@ -388,10 +418,6 @@ func startPostWorkStatusRefresher() {
} }
} }
jobsMu.Unlock() jobsMu.Unlock()
for _, job := range changedJobs {
publishJobUpsert(job)
}
} }
}() }()
} }

View File

@ -6,6 +6,7 @@ import (
"bytes" "bytes"
"context" "context"
"crypto/sha1" "crypto/sha1"
"embed"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
@ -58,6 +59,9 @@ func setCoverModelStore(s *ModelStore) { coverModelStore = s }
var errCoverNotSupported = errors.New("cover not supported") var errCoverNotSupported = errors.New("cover not supported")
//go:embed assets/img/cb.jpg assets/img/mfc.jpg
var embeddedFallbackImages embed.FS
// ============================================================ // ============================================================
// Covers: generated/covers/<category>.<ext> + info.json // Covers: generated/covers/<category>.<ext> + info.json
// Routes: // Routes:
@ -1323,51 +1327,121 @@ func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, er
} }
func latestPreviewSegment(previewDir string) (string, error) { func latestPreviewSegment(previewDir string) (string, error) {
entries, err := os.ReadDir(previewDir) segDir := previewSegmentsDir(previewDir)
entries, err := os.ReadDir(segDir)
if err != nil { if err != nil {
return "", err return "", err
} }
var best string var best string
for _, e := range entries { for _, e := range entries {
if e.IsDir() { if e.IsDir() {
continue continue
} }
name := e.Name()
if !strings.HasPrefix(name, "seg_low_") && !strings.HasPrefix(name, "seg_hq_") { name := strings.ToLower(e.Name())
if !strings.HasPrefix(name, "seg_preview_") {
continue continue
} }
if best == "" || name > best { if best == "" || name > best {
best = name best = e.Name()
} }
} }
if best == "" { if best == "" {
return "", fmt.Errorf("kein Preview-Segment in %s", previewDir) return "", fmt.Errorf("kein Preview-Segment in %s", segDir)
} }
return filepath.Join(previewDir, best), nil return filepath.Join(segDir, best), nil
}
func buildPreviewInputFromDir(previewDir string) (string, func(), error) {
segDir := previewSegmentsDir(previewDir)
if strings.TrimSpace(segDir) == "" {
return "", nil, fmt.Errorf("segments dir fehlt für %s", previewDir)
}
segPath, err := latestPreviewSegment(previewDir)
if err != nil {
return "", nil, err
}
ext := strings.ToLower(filepath.Ext(segPath))
// TS kann direkt gelesen werden
if ext == ".ts" {
return segPath, func() {}, nil
}
// fMP4: init.mp4 + segment.m4s zu temp zusammensetzen
initPath := filepath.Join(segDir, "init.mp4")
if _, err := os.Stat(initPath); err != nil {
return "", nil, fmt.Errorf("init.mp4 fehlt in %s", segDir)
}
initBytes, err := os.ReadFile(initPath)
if err != nil {
return "", nil, fmt.Errorf("init lesen fehlgeschlagen: %w", err)
}
segBytes, err := os.ReadFile(segPath)
if err != nil {
return "", nil, fmt.Errorf("segment lesen fehlgeschlagen: %w", err)
}
tmp, err := os.CreateTemp(segDir, "preview_input_*.mp4")
if err != nil {
return "", nil, err
}
tmpPath := tmp.Name()
cleanup := func() {
_ = tmp.Close()
_ = os.Remove(tmpPath)
}
if _, err := tmp.Write(initBytes); err != nil {
cleanup()
return "", nil, err
}
if _, err := tmp.Write(segBytes); err != nil {
cleanup()
return "", nil, err
}
if err := tmp.Close(); err != nil {
cleanup()
return "", nil, err
}
return tmpPath, cleanup, nil
} }
func extractLastFrameFromPreviewDirThumbJPG(previewDir string) ([]byte, error) { func extractLastFrameFromPreviewDirThumbJPG(previewDir string) ([]byte, error) {
seg, err := latestPreviewSegment(previewDir) inputPath, cleanup, err := buildPreviewInputFromDir(previewDir)
if err != nil { if err != nil {
return nil, err return nil, err
} }
img, err := extractLastFrameJPGScaled(seg, 320, 70) defer cleanup()
img, err := extractLastFrameJPGScaled(inputPath, 320, 70)
if err == nil && len(img) > 0 { if err == nil && len(img) > 0 {
return img, nil return img, nil
} }
return extractFirstFrameJPGScaled(seg, 320, 70) return extractFirstFrameJPGScaled(inputPath, 320, 70)
} }
func extractLastFrameFromPreviewDirJPG(previewDir string) ([]byte, error) { func extractLastFrameFromPreviewDirJPG(previewDir string) ([]byte, error) {
seg, err := latestPreviewSegment(previewDir) inputPath, cleanup, err := buildPreviewInputFromDir(previewDir)
if err != nil { if err != nil {
return nil, err return nil, err
} }
img, err := extractLastFrameJPG(seg) defer cleanup()
if err != nil {
return extractFirstFrameJPGScaled(seg, 720, 75) img, err := extractLastFrameJPG(inputPath)
if err == nil && len(img) > 0 {
return img, nil
} }
return img, nil return extractFirstFrameJPGScaled(inputPath, 720, 75)
} }
func serveLivePreviewJPGFile(w http.ResponseWriter, r *http.Request, path string) { func serveLivePreviewJPGFile(w http.ResponseWriter, r *http.Request, path string) {
@ -1431,6 +1505,99 @@ func serveLivePreviewJPGBytes(w http.ResponseWriter, b []byte) {
_, _ = w.Write(b) _, _ = w.Write(b)
} }
func detectPreviewProvider(job *RecordJob, outPath string) string {
if job != nil {
sourceURL := strings.ToLower(strings.TrimSpace(job.SourceURL))
switch {
case strings.Contains(sourceURL, "chaturbate.com"):
return "chaturbate"
case strings.Contains(sourceURL, "myfreecams.com"):
return "mfc"
}
}
modelKey := strings.TrimSpace(inferModelFromThumbLike(outPath))
if modelKey == "" || coverModelStore == nil {
return ""
}
modelKey = strings.ToLower(modelKey)
models := coverModelStore.List()
for _, m := range models {
if strings.ToLower(strings.TrimSpace(m.ModelKey)) != modelKey {
continue
}
host := strings.ToLower(strings.TrimSpace(m.Host))
input := strings.ToLower(strings.TrimSpace(m.Input))
switch {
case strings.Contains(host, "chaturbate"), strings.Contains(input, "chaturbate.com"):
return "chaturbate"
case strings.Contains(host, "myfreecams"), strings.Contains(input, "myfreecams.com"):
return "mfc"
}
}
return ""
}
func fallbackPreviewImageAssetPathForProvider(provider string) (string, error) {
switch strings.ToLower(strings.TrimSpace(provider)) {
case "chaturbate":
return "assets/img/cb.jpg", nil
case "mfc":
return "assets/img/mfc.jpg", nil
default:
return "", fmt.Errorf("unknown provider")
}
}
func serveFallbackPreviewImage(w http.ResponseWriter, r *http.Request, job *RecordJob, outPath string) bool {
provider := detectPreviewProvider(job, outPath)
if provider == "" {
return false
}
assetPath, err := fallbackPreviewImageAssetPathForProvider(provider)
if err != nil {
return false
}
b, err := embeddedFallbackImages.ReadFile(assetPath)
if err != nil || len(b) == 0 {
return false
}
ext := strings.ToLower(filepath.Ext(assetPath))
switch ext {
case ".png":
w.Header().Set("Content-Type", "image/png")
default:
w.Header().Set("Content-Type", "image/jpeg")
}
w.Header().Set("Cache-Control", "public, max-age=60")
w.Header().Set("X-Content-Type-Options", "nosniff")
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return true
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(b)
return true
}
func serveProviderFallbackOrStatus(w http.ResponseWriter, r *http.Request, job *RecordJob, outPath string, label string, status int) {
if serveFallbackPreviewImage(w, r, job, outPath) {
return
}
servePreviewStatusSVG(w, label, status)
}
func servePreviewJPGAlias(w http.ResponseWriter, r *http.Request, id string) { func servePreviewJPGAlias(w http.ResponseWriter, r *http.Request, id string) {
jobsMu.Lock() jobsMu.Lock()
job := jobs[id] job := jobs[id]
@ -1461,7 +1628,7 @@ func servePreviewJPGAlias(w http.ResponseWriter, r *http.Request, id string) {
} }
} }
servePreviewStatusSVG(w, "Preview", http.StatusOK) serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK)
return return
} }
@ -1552,23 +1719,8 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
previewDir := strings.TrimSpace(j.PreviewDir) previewDir := strings.TrimSpace(j.PreviewDir)
if previewDir != "" { if previewDir != "" {
img, genErr = extractLastFrameFromPreviewDirJPG(previewDir) img, genErr = extractLastFrameFromPreviewDirJPG(previewDir)
} if genErr != nil {
//fmt.Println("preview from previewDir failed:", previewDir, genErr)
if genErr != nil || len(img) == 0 {
outPath := strings.TrimSpace(j.Output)
if outPath != "" {
outPath = filepath.Clean(outPath)
if !filepath.IsAbs(outPath) {
if abs, err := resolvePathRelativeToApp(outPath); err == nil {
outPath = abs
}
}
if fi, err := os.Stat(outPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
img, genErr = extractLastFrameJPG(outPath)
if genErr != nil {
img, _ = extractFirstFrameJPGScaled(outPath, 720, 75)
}
}
} }
} }
@ -1594,16 +1746,15 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
jobsMu.Unlock() jobsMu.Unlock()
if state == "private" { if state == "private" {
servePreviewStatusSVG(w, "Private", http.StatusOK) serveProviderFallbackOrStatus(w, r, job, job.Output, "Private", http.StatusOK)
return return
} }
if state == "offline" { if state == "offline" {
servePreviewStatusSVG(w, "Offline", http.StatusOK) serveProviderFallbackOrStatus(w, r, job, job.Output, "Offline", http.StatusOK)
return return
} }
w.Header().Set("Cache-Control", "no-store") serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK)
w.WriteHeader(http.StatusNoContent)
return return
} }
@ -1738,6 +1889,11 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
return return
} }
// Wenn noch kein generiertes preview.jpg existiert, Provider-Fallbackbild liefern
if serveFallbackPreviewImage(w, r, nil, outPath) {
return
}
genCtx, cancel := context.WithTimeout(r.Context(), 30*time.Second) genCtx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel() defer cancel()

View File

@ -57,17 +57,19 @@ 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"` Error string `json:"error,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"`
} }
type doneMetaResp struct { type doneMetaResp struct {
@ -90,6 +92,10 @@ type undoDeleteToken struct {
File string `json:"file"` // original basename, z.B. "HOT xyz.mp4" File string `json:"file"` // original basename, z.B. "HOT xyz.mp4"
} }
type bulkFilesRequest struct {
Files []string `json:"files"`
}
func encodeUndoDeleteToken(t undoDeleteToken) (string, error) { func encodeUndoDeleteToken(t undoDeleteToken) (string, error) {
b, err := json.Marshal(t) b, err := json.Marshal(t)
if err != nil { if err != nil {
@ -160,6 +166,303 @@ func recordPrepareSplit(w http.ResponseWriter, r *http.Request) {
}) })
} }
func handleDeleteMany(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req bulkFilesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json body", http.StatusBadRequest)
return
}
if len(req.Files) == 0 {
http.Error(w, "missing files", http.StatusBadRequest)
return
}
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if strings.TrimSpace(doneAbs) == "" {
http.Error(w, "doneDir ist leer", http.StatusBadRequest)
return
}
keepRoot := filepath.Join(doneAbs, "keep")
type itemResult struct {
File string `json:"file"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
From string `json:"from,omitempty"`
}
results := make([]itemResult, 0, len(req.Files))
deletedCount := 0
seen := make(map[string]struct{}, len(req.Files))
for _, raw := range req.Files {
file := strings.TrimSpace(raw)
if file == "" {
continue
}
if _, ok := seen[file]; ok {
continue
}
seen[file] = struct{}{}
if !isAllowedVideoExt(file) {
results = append(results, itemResult{
File: file,
OK: false,
Error: "nicht erlaubt",
})
continue
}
// laufende/queued Nacharbeit abbrechen
cancelDeferredEnrichForFile(file)
target, from, fi, err := resolveDoneFileByName(doneAbs, file)
if err != nil {
results = append(results, itemResult{
File: file,
OK: false,
Error: "datei nicht gefunden",
})
continue
}
if fi != nil && fi.IsDir() {
results = append(results, itemResult{
File: file,
OK: false,
Error: "ist ein verzeichnis",
From: from,
})
continue
}
if err := removeWithRetry(target); err != nil {
if runtime.GOOS == "windows" && isSharingViolation(err) {
results = append(results, itemResult{
File: file,
OK: false,
Error: "datei wird gerade verwendet",
From: from,
})
continue
}
results = append(results, itemResult{
File: file,
OK: false,
Error: err.Error(),
From: from,
})
continue
}
purgeDurationCacheForPath(target)
removeJobsByOutputBasename(file)
// generated assets aufräumen
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)))
if strings.TrimSpace(id) != "" {
removeGeneratedForID(id)
}
// optional: leeren Keep-Unterordner nicht hart notwendig, aber sauber
if from == "keep" {
dir := filepath.Dir(target)
if strings.HasPrefix(strings.ToLower(filepath.Clean(dir)), strings.ToLower(filepath.Clean(keepRoot))) {
_ = os.Remove(dir)
}
}
deletedCount++
results = append(results, itemResult{
File: file,
OK: true,
From: from,
})
}
if deletedCount > 0 {
notifyDoneChanged()
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": deletedCount > 0,
"deleted": deletedCount,
"results": results,
})
}
func handleKeepMany(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req bulkFilesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json body", http.StatusBadRequest)
return
}
if len(req.Files) == 0 {
http.Error(w, "missing files", http.StatusBadRequest)
return
}
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if strings.TrimSpace(doneAbs) == "" {
http.Error(w, "doneDir ist leer", http.StatusBadRequest)
return
}
keepRoot := filepath.Join(doneAbs, "keep")
if err := os.MkdirAll(keepRoot, 0o755); err != nil {
http.Error(w, "keep dir erstellen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
type itemResult struct {
File string `json:"file"`
NewFile string `json:"newFile,omitempty"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
results := make([]itemResult, 0, len(req.Files))
keptCount := 0
seen := make(map[string]struct{}, len(req.Files))
for _, raw := range req.Files {
file := strings.TrimSpace(raw)
if file == "" {
continue
}
if _, ok := seen[file]; ok {
continue
}
seen[file] = struct{}{}
if !isAllowedVideoExt(file) {
results = append(results, itemResult{
File: file,
OK: false,
Error: "nicht erlaubt",
})
continue
}
cancelDeferredEnrichForFile(file)
src, from, fi, err := resolveDoneFileByName(doneAbs, file)
if err != nil {
results = append(results, itemResult{
File: file,
OK: false,
Error: "datei nicht gefunden",
})
continue
}
// Bereits in keep -> nichts tun, aber als ok zurückgeben
if from == "keep" {
results = append(results, itemResult{
File: file,
NewFile: file,
OK: true,
})
continue
}
if fi == nil || fi.IsDir() {
results = append(results, itemResult{
File: file,
OK: false,
Error: "ist ein verzeichnis",
})
continue
}
modelKey := modelKeyFromFilenameOrPath(file, src, doneAbs)
dstDir := keepRoot
if modelKey != "" {
dstDir = filepath.Join(keepRoot, modelKey)
}
if err := os.MkdirAll(dstDir, 0o755); err != nil {
results = append(results, itemResult{
File: file,
OK: false,
Error: "keep subdir erstellen fehlgeschlagen: " + err.Error(),
})
continue
}
dst, err := uniqueDestPath(dstDir, file)
if err != nil {
results = append(results, itemResult{
File: file,
OK: false,
Error: "zielname nicht verfügbar: " + err.Error(),
})
continue
}
if err := renameWithRetryAggressive(src, dst); err != nil {
if runtime.GOOS == "windows" && isSharingViolation(err) {
results = append(results, itemResult{
File: file,
OK: false,
Error: "datei wird gerade verwendet",
})
continue
}
results = append(results, itemResult{
File: file,
OK: false,
Error: err.Error(),
})
continue
}
keptCount++
results = append(results, itemResult{
File: file,
NewFile: filepath.Base(dst),
OK: true,
})
}
if keptCount > 0 {
notifyDoneChanged()
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": keptCount > 0,
"kept": keptCount,
"results": results,
})
}
// ---------------- Preview sprite truth (shared) ---------------- // ---------------- Preview sprite truth (shared) ----------------
type previewSpriteMetaFileInfo struct { type previewSpriteMetaFileInfo struct {
@ -185,7 +488,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
} }
@ -314,14 +622,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
@ -348,11 +653,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)
@ -502,9 +950,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
@ -521,8 +966,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)
} }
@ -1261,35 +1779,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 {
resp.MetaExists = true if meta, ok := readVideoMetaIfValid(mp, fi); ok && meta != nil {
resp.MetaExists = true
if dur, w2, h2, fps2, ok := readVideoMeta(mp, fi); ok { resp.Meta = meta
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
} }
} }
@ -1715,6 +2229,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
} }
applyPreviewSpriteTruthToRecordJobMeta(&c) applyPreviewSpriteTruthToRecordJobMeta(&c)
applyFinishedPhaseTruthToRecordJobMeta(&c)
out = append(out, &c) out = append(out, &c)
} }
@ -1763,6 +2278,62 @@ func renameWithRetryAggressive(src, dst string) error {
return lastErr return lastErr
} }
func cancelDeferredEnrichForFile(file string) {
file = strings.TrimSpace(file)
if file == "" {
return
}
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)))
id = strings.TrimSpace(id)
if id == "" {
return
}
enrichKey := "enrich:" + id
st := enrichQ.StatusForKey(enrichKey)
switch st.State {
case "queued":
if enrichQ.RemoveQueued(enrichKey) {
// optional: stale badge sofort entfernen
ev := finishedPostworkEvent{
Type: "finished_postwork",
File: file,
AssetID: id,
Queue: "enrich",
State: "missing",
Phase: "assets",
Label: "",
TS: time.Now().UnixMilli(),
}
if b, err := json.Marshal(ev); err == nil {
publishSSE("finishedPostwork", b)
}
}
case "running":
if enrichQ.Cancel(enrichKey) {
// ffmpeg / reads / handles kurz auslaufen lassen
time.Sleep(350 * time.Millisecond)
ev := finishedPostworkEvent{
Type: "finished_postwork",
File: file,
AssetID: id,
Queue: "enrich",
State: "missing",
Phase: "assets",
Label: "",
TS: time.Now().UnixMilli(),
}
if b, err := json.Marshal(ev); err == nil {
publishSSE("finishedPostwork", b)
}
}
}
}
func recordDeleteVideo(w http.ResponseWriter, r *http.Request) { func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost && r.Method != http.MethodDelete { if r.Method != http.MethodPost && r.Method != http.MethodDelete {
http.Error(w, "Nur POST oder DELETE erlaubt", http.StatusMethodNotAllowed) http.Error(w, "Nur POST oder DELETE erlaubt", http.StatusMethodNotAllowed)
@ -1779,6 +2350,9 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
// ✅ laufende/queued späte Nacharbeiten für dieses Asset vorher stoppen
cancelDeferredEnrichForFile(file)
s := getSettings() s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir) doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil { if err != nil {
@ -2046,6 +2620,9 @@ func recordUnkeepVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
// ✅ späte Nacharbeiten abbrechen, bevor die Datei aus keep zurück verschoben wird
cancelDeferredEnrichForFile(file)
s := getSettings() s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir) doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil { if err != nil {
@ -2116,6 +2693,9 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
// ✅ späte Nacharbeiten abbrechen, bevor die Datei verschoben wird
cancelDeferredEnrichForFile(file)
s := getSettings() s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir) doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil { if err != nil {
@ -2222,6 +2802,9 @@ func recordToggleHot(w http.ResponseWriter, r *http.Request) {
return return
} }
// optional, aber sinnvoll
cancelDeferredEnrichForFile(file)
s := getSettings() s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir) doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil { if err != nil {

View File

@ -9,7 +9,10 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -17,6 +20,25 @@ import (
"github.com/grafov/m3u8" "github.com/grafov/m3u8"
) )
func previewSegmentsDir(previewDir string) string {
previewDir = strings.TrimSpace(previewDir)
if previewDir == "" {
return ""
}
return filepath.Join(previewDir, "segments")
}
func ensurePreviewSegmentsDir(previewDir string) (string, error) {
dir := previewSegmentsDir(previewDir)
if strings.TrimSpace(dir) == "" {
return "", fmt.Errorf("previewDir leer")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}
// --- DVR-ähnlicher Recorder-Ablauf --- // --- DVR-ähnlicher Recorder-Ablauf ---
// Entspricht grob dem RecordStream aus dem Channel-Snippet: // Entspricht grob dem RecordStream aus dem Channel-Snippet:
func RecordStream( func RecordStream(
@ -28,8 +50,6 @@ func RecordStream(
httpCookie string, httpCookie string,
job *RecordJob, job *RecordJob,
) error { ) error {
// 1) Seite laden
// Domain sauber zusammenbauen (mit/ohne Slash)
base := strings.TrimRight(domain, "/") base := strings.TrimRight(domain, "/")
pageURL := base + "/" + username pageURL := base + "/" + username
@ -38,58 +58,155 @@ func RecordStream(
return fmt.Errorf("seite laden: %w", err) return fmt.Errorf("seite laden: %w", err)
} }
// 2) HLS-URL aus roomDossier extrahieren (wie DVR.ParseStream)
hlsURL, err := ParseStream(body) hlsURL, err := ParseStream(body)
if err != nil { if err != nil {
return fmt.Errorf("stream-parsing: %w", err) return fmt.Errorf("stream-parsing: %w", err)
} }
// 3) Playlist holen (wie stream.GetPlaylist im DVR)
playlist, err := FetchPlaylist(ctx, hc, hlsURL, httpCookie) playlist, err := FetchPlaylist(ctx, hc, hlsURL, httpCookie)
if err != nil { if err != nil {
return fmt.Errorf("playlist abrufen: %w", err) return fmt.Errorf("playlist abrufen: %w", err)
} }
// ✅ WICHTIG: fMP4 live preview (/api/preview/live) braucht job.PreviewM3U8 als Input var previewDir string
var segmentsDir string
if job != nil { if job != nil {
assetID := assetIDForJob(job)
if assetID != "" {
if dir, derr := ensureGeneratedDir(assetID); derr == nil {
previewDir = strings.TrimSpace(dir)
if sdir, serr := ensurePreviewSegmentsDir(previewDir); serr == nil {
segmentsDir = strings.TrimSpace(sdir)
}
}
}
jobsMu.Lock() jobsMu.Lock()
job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL) job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL)
job.PreviewCookie = httpCookie job.PreviewCookie = httpCookie
job.PreviewUA = hc.userAgent job.PreviewUA = hc.userAgent
if previewDir != "" {
job.PreviewDir = previewDir
}
jobsMu.Unlock() jobsMu.Unlock()
if previewDir != "" {
startLiveThumbJPGLoop(ctx, job)
}
}
if segmentsDir != "" {
defer func() {
if err := os.RemoveAll(segmentsDir); err != nil {
//fmt.Println("preview segments cleanup failed:", segmentsDir, err)
}
}()
} }
// 4) Datei öffnen
file, err := os.Create(outputPath) file, err := os.Create(outputPath)
if err != nil { if err != nil {
return fmt.Errorf("datei erstellen: %w", err) return fmt.Errorf("datei erstellen: %w", err)
} }
defer func() { defer func() {
_ = file.Close() _ = file.Close()
}() }()
// live size tracking (für UI)
var written int64 var written int64
var lastPush time.Time var lastPush time.Time
var lastBytes int64 var lastBytes int64
published := false published := false
// 5) Segmente „watchen“ analog zu WatchSegments + HandleSegment im DVR trimPreviewSegments := func(dir string, keep int) {
err = playlist.WatchSegments(ctx, hc, httpCookie, func(b []byte, duration float64) error { if strings.TrimSpace(dir) == "" || keep <= 0 {
// Hier wäre im DVR ch.HandleSegment bei dir einfach in eine Datei schreiben return
}
entries, err := os.ReadDir(dir)
if err != nil {
return
}
type item struct {
name string
mod time.Time
}
items := make([]item, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
lower := strings.ToLower(name)
if lower == "init.mp4" {
continue
}
if !strings.HasPrefix(lower, "seg_preview_") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
items = append(items, item{
name: name,
mod: info.ModTime(),
})
}
if len(items) <= keep {
return
}
sort.Slice(items, func(i, j int) bool {
return items[i].mod.Before(items[j].mod)
})
for i := 0; i < len(items)-keep; i++ {
_ = os.Remove(filepath.Join(dir, items[i].name))
}
}
err = playlist.WatchSegments(ctx, hc, httpCookie, previewDir, func(segmentURL string, b []byte, duration float64) error {
if len(b) == 0 {
return nil
}
if _, err := file.Write(b); err != nil { if _, err := file.Write(b); err != nil {
return fmt.Errorf("schreibe segment: %w", err) return fmt.Errorf("schreibe segment: %w", err)
} }
// ✅ erst sichtbar machen, wenn wirklich Bytes geschrieben wurden if err := file.Sync(); err != nil {
//fmt.Println("output sync failed:", outputPath, err)
}
if segmentsDir != "" {
if err := os.MkdirAll(segmentsDir, 0o755); err == nil {
ext := ".m4s"
if len(b) > 0 && b[0] == 0x47 {
ext = ".ts"
}
segName := fmt.Sprintf("seg_preview_%020d%s", time.Now().UnixNano(), ext)
segPath := filepath.Join(segmentsDir, segName)
if err := os.WriteFile(segPath, b, 0o644); err == nil {
trimPreviewSegments(segmentsDir, 12)
}
}
}
if job != nil && !published { if job != nil && !published {
published = true published = true
_ = publishJob(job.ID) _ = publishJob(job.ID)
} }
// ✅ live size (UI) throttled
written += int64(len(b)) written += int64(len(b))
if job != nil { if job != nil {
now := time.Now() now := time.Now()
@ -97,15 +214,14 @@ func RecordStream(
jobsMu.Lock() jobsMu.Lock()
job.SizeBytes = written job.SizeBytes = written
jobsMu.Unlock() jobsMu.Unlock()
publishJobUpsert(job)
lastPush = now lastPush = now
lastBytes = written lastBytes = written
} }
} }
// Könntest hier z.B. auch Dauer/Größe loggen, wenn du möchtest _ = duration
_ = duration // aktuell unbenutzt _ = segmentURL
return nil return nil
}) })
if err != nil { if err != nil {
@ -160,11 +276,68 @@ func (p *Playlist) WatchSegments(
ctx context.Context, ctx context.Context,
hc *HTTPClient, hc *HTTPClient,
httpCookie string, httpCookie string,
handler func([]byte, float64) error, previewDir string,
handler func(segmentURL string, b []byte, duration float64) error,
) error { ) error {
var lastSeq int64 = -1 var lastSeq int64 = -1
emptyRounds := 0 emptyRounds := 0
const maxEmptyRounds = 60 // statt 5 const maxEmptyRounds = 60
playlistBaseURL, err := url.Parse(strings.TrimSpace(p.PlaylistURL))
if err != nil || playlistBaseURL == nil {
return fmt.Errorf("ungültige playlist url: %q", p.PlaylistURL)
}
var lastMapURL string
writeInitIfNeeded := func(mapURL string) {
if strings.TrimSpace(previewDir) == "" || strings.TrimSpace(mapURL) == "" {
return
}
segDir, err := ensurePreviewSegmentsDir(previewDir)
if err != nil {
return
}
initPath := filepath.Join(segDir, "init.mp4")
// schon vorhanden und gleiches URL-Mapping -> nichts tun
if mapURL == lastMapURL {
if st, err := os.Stat(initPath); err == nil && !st.IsDir() && st.Size() > 0 {
return
}
}
req, err := hc.NewRequest(ctx, http.MethodGet, mapURL, httpCookie)
if err != nil {
return
}
resp, err := hc.client.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
_, _ = io.Copy(io.Discard, resp.Body)
return
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return
}
if len(b) == 0 {
return
}
if err := os.WriteFile(initPath, b, 0o644); err != nil {
return
}
lastMapURL = mapURL
}
for { for {
select { select {
@ -173,41 +346,119 @@ func (p *Playlist) WatchSegments(
default: default:
} }
// Playlist holen if strings.TrimSpace(p.PlaylistURL) == "" {
return errors.New("playlist url fehlt")
}
req, err := hc.NewRequest(ctx, http.MethodGet, p.PlaylistURL, httpCookie) req, err := hc.NewRequest(ctx, http.MethodGet, p.PlaylistURL, httpCookie)
if err != nil { if err != nil {
return fmt.Errorf("Fehler beim Erstellen der Playlist-Request: %w", err) return fmt.Errorf("fehler beim erstellen der playlist-request: %w", err)
} }
resp, err := hc.client.Do(req) resp, err := hc.client.Do(req)
if err != nil { if err != nil {
emptyRounds++ emptyRounds++
//fmt.Println("playlist download failed:", p.PlaylistURL, err)
if emptyRounds >= maxEmptyRounds { if emptyRounds >= maxEmptyRounds {
return errors.New("❌ Playlist nicht mehr erreichbar Stream vermutlich offline") return errors.New("❌ Playlist nicht mehr erreichbar Stream vermutlich offline")
} }
time.Sleep(2 * time.Second)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
//fmt.Println("playlist bad status:", resp.StatusCode, p.PlaylistURL)
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
emptyRounds++
if emptyRounds >= maxEmptyRounds {
return fmt.Errorf("❌ playlist liefert status %d stream vermutlich offline", resp.StatusCode)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
continue continue
} }
playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
resp.Body.Close() resp.Body.Close()
if err != nil {
if err != nil || listType != m3u8.MEDIA {
emptyRounds++ emptyRounds++
if emptyRounds >= maxEmptyRounds { if emptyRounds >= maxEmptyRounds {
return errors.New("❌ Fehlerhafte Playlist möglicherweise offline") return errors.New("❌ Fehlerhafte Playlist möglicherweise offline")
} }
time.Sleep(2 * time.Second)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
continue continue
} }
media := playlist.(*m3u8.MediaPlaylist) if listType != m3u8.MEDIA {
emptyRounds++
if emptyRounds >= maxEmptyRounds {
return errors.New("❌ Fehlerhafte Playlist möglicherweise offline")
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
continue
}
media, ok := playlist.(*m3u8.MediaPlaylist)
if !ok || media == nil {
emptyRounds++
if emptyRounds >= maxEmptyRounds {
return errors.New("❌ Fehlerhafte Media-Playlist möglicherweise offline")
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
continue
}
if media.Map != nil {
mapURI := strings.TrimSpace(media.Map.URI)
if mapURI != "" {
mapRef, err := url.Parse(mapURI)
if err != nil {
//fmt.Println("map uri parse failed:", mapURI, err)
} else {
mapURL := playlistBaseURL.ResolveReference(mapRef).String()
writeInitIfNeeded(mapURL)
}
}
}
newSegment := false newSegment := false
successfulSegment := false
for _, segment := range media.Segments { for _, segment := range media.Segments {
if segment == nil { if segment == nil {
continue continue
} }
if int64(segment.SeqId) <= lastSeq { if int64(segment.SeqId) <= lastSeq {
continue continue
} }
@ -215,31 +466,66 @@ func (p *Playlist) WatchSegments(
lastSeq = int64(segment.SeqId) lastSeq = int64(segment.SeqId)
newSegment = true newSegment = true
segmentURL := p.RootURL + segment.URI segURI := strings.TrimSpace(segment.URI)
if segURI == "" {
//fmt.Println("segment uri empty in playlist:", p.PlaylistURL)
continue
}
segRef, err := url.Parse(segURI)
if err != nil {
//fmt.Println("segment uri parse failed:", segURI, err)
continue
}
segmentURL := playlistBaseURL.ResolveReference(segRef).String()
segReq, err := hc.NewRequest(ctx, http.MethodGet, segmentURL, httpCookie) segReq, err := hc.NewRequest(ctx, http.MethodGet, segmentURL, httpCookie)
if err != nil { if err != nil {
//fmt.Println("segment request build failed:", segmentURL, err)
continue continue
} }
segResp, err := hc.client.Do(segReq) segResp, err := hc.client.Do(segReq)
if err != nil { if err != nil {
//fmt.Println("segment download failed:", segmentURL, err)
continue
}
if segResp.StatusCode < 200 || segResp.StatusCode >= 300 {
//fmt.Println("segment bad status:", segResp.StatusCode, segmentURL)
_, _ = io.Copy(io.Discard, segResp.Body)
segResp.Body.Close()
continue continue
} }
data, err := io.ReadAll(segResp.Body) data, err := io.ReadAll(segResp.Body)
segResp.Body.Close() segResp.Body.Close()
if err != nil || len(data) == 0 { if err != nil {
//fmt.Println("segment read failed:", segmentURL, err)
continue
}
if len(data) == 0 {
//fmt.Println("segment empty:", segmentURL)
continue continue
} }
if err := handler(data, segment.Duration); err != nil { if err := handler(segmentURL, data, segment.Duration); err != nil {
return err return err
} }
successfulSegment = true
} }
if newSegment { if successfulSegment {
emptyRounds = 0 emptyRounds = 0
} else if newSegment {
emptyRounds++
//fmt.Println("playlist had new segment ids but none could be downloaded:", p.PlaylistURL)
if emptyRounds >= maxEmptyRounds {
return errors.New("🛑 Neue HLS-Segmente gefunden, aber keines konnte geladen werden Stream vermutlich geschützt, fehlerhaft oder offline.")
}
} else { } else {
emptyRounds++ emptyRounds++
if emptyRounds >= maxEmptyRounds { if emptyRounds >= maxEmptyRounds {
@ -247,7 +533,11 @@ func (p *Playlist) WatchSegments(
} }
} }
time.Sleep(1 * time.Second) select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(1 * time.Second):
}
} }
} }

View File

@ -234,7 +234,7 @@ func runMFC(ctx context.Context, username string, outArg string) error {
return err return err
} }
if st != StatusPublic { if st != StatusPublic {
return fmt.Errorf("Stream ist nicht öffentlich (Status: %s)", st) return fmt.Errorf("Stream ist nicht live (Status: %s)", st)
} }
m3u8URL, err := mfc.GetVideoURL(false) m3u8URL, err := mfc.GetVideoURL(false)
@ -379,7 +379,6 @@ func handleM3U8Mode(ctx context.Context, m3u8URL, outFile string, job *RecordJob
jobsMu.Lock() jobsMu.Lock()
job.SizeBytes = sz job.SizeBytes = sz
jobsMu.Unlock() jobsMu.Unlock()
publishJobUpsert(job)
last = sz last = sz
} }
} }

View File

@ -266,8 +266,6 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
if j.Hidden && !req.Hidden { if j.Hidden && !req.Hidden {
j.Hidden = false j.Hidden = false
jobsMu.Unlock() jobsMu.Unlock()
publishJobUpsert(j)
return j, nil return j, nil
} }
@ -317,10 +315,6 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
jobs[jobID] = job jobs[jobID] = job
jobsMu.Unlock() jobsMu.Unlock()
if !job.Hidden {
publishJobUpsert(job)
}
go runJob(ctx, job, req) go runJob(ctx, job, req)
return job, nil return job, nil
} }
@ -350,7 +344,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
} }
setJobProgress(job, "recording", 0) setJobProgress(job, "recording", 0)
publishJobUpsert(job)
switch provider { switch provider {
case "chaturbate": case "chaturbate":
@ -383,7 +376,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Lock() jobsMu.Lock()
job.Output = outPath job.Output = outPath
jobsMu.Unlock() jobsMu.Unlock()
publishJobUpsert(job)
} }
err = RecordStream(ctx, hc, "https://chaturbate.com/", username, outPath, req.Cookie, job) err = RecordStream(ctx, hc, "https://chaturbate.com/", username, outPath, req.Cookie, job)
@ -404,7 +396,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Lock() jobsMu.Lock()
job.Output = outPath job.Output = outPath
jobsMu.Unlock() jobsMu.Unlock()
publishJobUpsert(job)
err = RecordStreamMFC(ctx, hc, username, outPath, job) err = RecordStreamMFC(ctx, hc, username, outPath, job)
@ -412,10 +403,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
@ -440,7 +427,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.Phase = "postwork" job.Phase = "postwork"
out := strings.TrimSpace(job.Output) out := strings.TrimSpace(job.Output)
jobsMu.Unlock() jobsMu.Unlock()
publishJobUpsert(job)
if out == "" { if out == "" {
jobsMu.Lock() jobsMu.Lock()
@ -452,7 +438,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Unlock() jobsMu.Unlock()
publishJobRemove(job) publishJobRemove(job)
notifyDoneChanged()
return return
} }
@ -468,7 +453,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Unlock() jobsMu.Unlock()
publishJobRemove(job) publishJobRemove(job)
notifyDoneChanged()
if shouldLogRecordInfo(req) { if shouldLogRecordInfo(req) {
if serr != nil { if serr != nil {
@ -490,7 +474,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Lock() jobsMu.Lock()
job.SizeBytes = fi.Size() job.SizeBytes = fi.Size()
jobsMu.Unlock() jobsMu.Unlock()
publishJobUpsert(job)
s := getSettings() s := getSettings()
minMB := s.AutoDeleteSmallDownloadsBelowMB minMB := s.AutoDeleteSmallDownloadsBelowMB
@ -512,7 +495,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Unlock() jobsMu.Unlock()
publishJobRemove(job) publishJobRemove(job)
notifyDoneChanged()
if shouldLogRecordInfo(req) { if shouldLogRecordInfo(req) {
fmt.Println("🧹 auto-deleted before enqueue:", base, "(size: "+formatBytesSI(fi.Size())+", threshold: "+formatBytesSI(threshold)+")") fmt.Println("🧹 auto-deleted before enqueue:", base, "(size: "+formatBytesSI(fi.Size())+", threshold: "+formatBytesSI(threshold)+")")
@ -541,7 +523,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.PostWork = &s job.PostWork = &s
} }
jobsMu.Unlock() jobsMu.Unlock()
publishJobUpsert(job)
okQueued := postWorkQ.Enqueue(PostWorkTask{ okQueued := postWorkQ.Enqueue(PostWorkTask{
Key: postKey, Key: postKey,
@ -554,7 +535,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Unlock() jobsMu.Unlock()
setJobProgress(job, "postwork", 0) setJobProgress(job, "postwork", 0)
publishJobUpsert(job)
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: postFile, File: postFile,
@ -581,7 +561,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Unlock() jobsMu.Unlock()
publishJobRemove(job) publishJobRemove(job)
notifyDoneChanged()
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: postFile, File: postFile,
@ -600,7 +579,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Lock() jobsMu.Lock()
job.PostWork = &st job.PostWork = &st
jobsMu.Unlock() jobsMu.Unlock()
publishJobUpsert(job)
label := "Nachbearbeitung läuft…" label := "Nachbearbeitung läuft…"
switch strings.ToLower(strings.TrimSpace(phase)) { switch strings.ToLower(strings.TrimSpace(phase)) {
@ -638,7 +616,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Lock() jobsMu.Lock()
job.Output = out job.Output = out
jobsMu.Unlock() jobsMu.Unlock()
publishJobUpsert(job)
} }
} }
@ -657,7 +634,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Unlock() jobsMu.Unlock()
publishJobRemove(job) publishJobRemove(job)
notifyDoneChanged()
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: filepath.Base(out), File: filepath.Base(out),
@ -686,7 +662,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Lock() jobsMu.Lock()
job.Output = out job.Output = out
jobsMu.Unlock() jobsMu.Unlock()
publishJobUpsert(job)
notifyDoneChanged() notifyDoneChanged()
} }
@ -698,7 +673,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Lock() jobsMu.Lock()
job.DurationSeconds = sec job.DurationSeconds = sec
jobsMu.Unlock() jobsMu.Unlock()
publishJobUpsert(job)
} }
cancel() cancel()
} }
@ -715,7 +689,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.VideoHeight = h job.VideoHeight = h
job.FPS = fps job.FPS = fps
jobsMu.Unlock() jobsMu.Unlock()
publishJobUpsert(job)
} }
} }
@ -771,7 +744,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Unlock() jobsMu.Unlock()
publishJobRemove(job) publishJobRemove(job)
notifyDoneChanged()
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: finalFile, File: finalFile,
@ -803,7 +775,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Lock() jobsMu.Lock()
job.PostWork = &st job.PostWork = &st
jobsMu.Unlock() jobsMu.Unlock()
publishJobUpsert(job)
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: postFile, File: postFile,
@ -827,7 +798,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Unlock() jobsMu.Unlock()
publishJobRemove(job) publishJobRemove(job)
notifyDoneChanged()
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: postFile, File: postFile,

View File

@ -0,0 +1,19 @@
{
"databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable",
"encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=",
"recordDir": "C:\\Users\\Rother\\Desktop\\Privat\\records",
"doneDir": "C:\\Users\\Rother\\Desktop\\Privat\\records\\done",
"ffmpegPath": "",
"autoAddToDownloadList": true,
"autoStartAddedDownloads": true,
"useChaturbateApi": true,
"useMyFreeCamsWatcher": true,
"autoDeleteSmallDownloads": false,
"autoDeleteSmallDownloadsBelowMB": 50,
"lowDiskPauseBelowGB": 5,
"blurPreviews": false,
"teaserPlayback": "all",
"teaserAudio": true,
"enableNotifications": true,
"encryptedCookies": "tZzqhwM+6rrdV36edfxpkhfikSfzfs4VZESLJIpYw1U4Bwn7vo+P8Sq86M7nvXpvbQVg00QLl5Wo2dD2agojU58GYY5P5P6Wpc1ZPr2T409BaQXRA/WhHiZv7z/b4JoVYX7fScZ4FDtscQYuTdMpnU6Wr725j66Qg2x8VgXqUHb1qceUes9P3L2kc8MAvZHWnZHIEjbgDW9NAZOADlbBxOonmc5hMGpz5xzmpWWV7Gqg6n1i75U/XBDV6Ks1Jn5Ic9dwi0f1BH6zo56v5oDdPAxKPlw01TUsV9ZAA+u+TjWUiq52AJP/C20bujUEsmQupqKxMs3E4rs6DEb7SYfbQ6SSryKyYYLCv0T3+CbeUgDmgYd2n+yjatE8udHvmkCtZCWvBIK4lSglsVeFq1prgpiGBL2ItyLcR13musHGbZ7Whq/p1btJsFDTTyfGnILW+sRUcd1Y/17YM5zKAWYyi81qKop6DYqh9kBoM2y3DYVxsLkb9+lUmwN6N7oopqh/bJ8vxJKyC74="
}

View File

@ -63,6 +63,9 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
api.HandleFunc("/api/record/unkeep", recordUnkeepVideo) api.HandleFunc("/api/record/unkeep", recordUnkeepVideo)
api.HandleFunc("/api/record/restore", recordRestoreVideo) api.HandleFunc("/api/record/restore", recordRestoreVideo)
api.HandleFunc("/api/record/prepare-split", recordPrepareSplit) api.HandleFunc("/api/record/prepare-split", recordPrepareSplit)
api.HandleFunc("/api/record/regenerate-assets", handleRegenerateAssets)
api.HandleFunc("/api/record/delete-many", handleDeleteMany)
api.HandleFunc("/api/record/keep-many", handleKeepMany)
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler) api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler) api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)

View File

@ -70,6 +70,15 @@ type finishedPostworkEvent struct {
TS int64 `json:"ts"` TS int64 `json:"ts"`
} }
type taskStateEvent struct {
Type string `json:"type"` // "task_state"
GenerateAssetsRunning bool `json:"generateAssetsRunning"`
RegenerateAssetsRunning bool `json:"regenerateAssetsRunning"`
CleanupRunning bool `json:"cleanupRunning"`
AnyRunning bool `json:"anyRunning"`
TS int64 `json:"ts"`
}
func publishFinishedPostworkStateForJob( func publishFinishedPostworkStateForJob(
j *RecordJob, j *RecordJob,
queue string, queue string,
@ -106,6 +115,24 @@ func publishFinishedPostworkStateForJob(
}) })
} }
func publishTaskState() {
assets := getGenerateAssetsTaskStatus()
regenerate := snapshotRegenerateAssetsTaskState()
cleanup := snapshotCleanupTaskState()
ev := taskStateEvent{
Type: "task_state",
GenerateAssetsRunning: assets.Running,
RegenerateAssetsRunning: regenerate.Running,
CleanupRunning: cleanup.Running,
AnyRunning: assets.Running || regenerate.Running || cleanup.Running,
TS: time.Now().UnixMilli(),
}
b, _ := json.Marshal(ev)
publishSSE("taskState", b)
}
func publishFinishedPostworkStatusForJob(j *RecordJob, ev finishedPostworkEvent) { func publishFinishedPostworkStatusForJob(j *RecordJob, ev finishedPostworkEvent) {
if j == nil { if j == nil {
return return
@ -125,10 +152,7 @@ func publishFinishedPostworkStatusForJob(j *RecordJob, ev finishedPostworkEvent)
b, _ := json.Marshal(ev) b, _ := json.Marshal(ev)
// 1) modellbezogen für Running/Downloads // nur global für FinishedDownloads
publishSSE(eventName, b)
// 2) global für FinishedDownloads
publishSSE("finishedPostwork", b) publishSSE("finishedPostwork", b)
} }
@ -269,54 +293,6 @@ func visibleJobEventsJSON() []ssePublishItem {
return out return out
} }
func publishJobUpsert(j *RecordJob) {
if j == nil || j.Hidden {
return
}
if !shouldPublishModelEventForJob(j) {
return
}
eventName := sseModelEventNameForJob(j)
if eventName == "" {
return
}
payload := jobEvent{
Type: "job_upsert",
Model: eventName,
JobID: j.ID,
Status: j.Status,
Phase: j.Phase,
Progress: j.Progress,
SourceURL: j.SourceURL,
Output: j.Output,
StartedAt: j.StartedAt.Format(time.RFC3339Nano),
StartedAtMs: j.StartedAtMs,
EndedAtMs: j.EndedAtMs,
SizeBytes: j.SizeBytes,
DurationSeconds: j.DurationSeconds,
PreviewState: j.PreviewState,
PostWorkKey: strings.TrimSpace(j.PostWorkKey),
PostWork: j.PostWork,
TS: time.Now().UnixMilli(),
}
if sm := sseStoredModelForJob(j); sm != nil {
payload.RoomStatus = strings.ToLower(strings.TrimSpace(sm.RoomStatus))
payload.IsOnline = sm.IsOnline
payload.ModelImageURL = strings.TrimSpace(sm.ImageURL)
payload.ModelChatRoomURL = strings.TrimSpace(sm.ChatRoomURL)
}
if j.EndedAt != nil {
payload.EndedAt = j.EndedAt.Format(time.RFC3339Nano)
}
b, _ := json.Marshal(payload)
publishSSE(eventName, b)
}
func publishJobRemove(j *RecordJob) { func publishJobRemove(j *RecordJob) {
if j == nil || j.Hidden { if j == nil || j.Hidden {
return return

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
@ -44,6 +46,7 @@ func updateAssetsState(fn func(st *AssetsTaskState)) AssetsTaskState {
assetsTaskMu.Unlock() assetsTaskMu.Unlock()
notifyAssetsChanged() notifyAssetsChanged()
publishTaskState()
return st return st
} }
@ -91,12 +94,15 @@ 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
notifyAssetsChanged() notifyAssetsChanged()
publishTaskState()
go runGenerateMissingAssets(ctx) go runGenerateMissingAssets(ctx)
@ -121,7 +127,7 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
// das macht der Worker zuverlässig im finishWithErr(context.Canceled)) // das macht der Worker zuverlässig im finishWithErr(context.Canceled))
st := updateAssetsState(func(st *AssetsTaskState) { st := updateAssetsState(func(st *AssetsTaskState) {
if st.Running { if st.Running {
st.Error = "abgebrochen" st.Error = "Abgebrochen"
} }
}) })
@ -134,7 +140,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 +149,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,16 +191,17 @@ 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 {
st.Error = err.Error() st.Error = err.Error()
} }
@ -234,77 +220,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 +271,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 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 +291,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 +299,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 +352,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
} else { // ----------------
publishAssetsTaskFinishedPostworkState( if needThumb {
it.name, updateAssetsState(func(st *AssetsTaskState) {
"done", st.CurrentFile = it.name
"Vorschau / Thumbnails fertig", st.CurrentQueue = "postwork"
) st.CurrentPhase = "thumb"
st.CurrentLabel = "Vorschaubild"
})
publishAssetsTaskPhase(it.name, "postwork", "thumb", "running", "Vorschaubild")
resThumb, terr := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, it.path, sourceURL, nil)
if terr != nil {
publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen")
} else if finishedPhaseTruthForID(id).ThumbReady {
if resThumb.ThumbGenerated {
thumbGenerated = true
}
publishAssetsTaskPhase(it.name, "postwork", "thumb", "done", "Vorschaubild")
} else {
publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen")
}
}
// ----------------
// TEASER
// ----------------
if needTeaser {
updateAssetsState(func(st *AssetsTaskState) {
st.CurrentFile = it.name
st.CurrentQueue = "postwork"
st.CurrentPhase = "teaser"
st.CurrentLabel = "Teaser"
})
publishAssetsTaskPhase(it.name, "postwork", "teaser", "running", "Teaser")
genTeaser, terr := ensureTeaserForVideoCtx(ctx, it.path, sourceURL)
if terr != nil {
publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen")
} else if finishedPhaseTruthForID(id).TeaserReady {
if genTeaser {
previewGenerated = true
}
publishAssetsTaskPhase(it.name, "postwork", "teaser", "done", "Teaser")
} else {
publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen")
}
}
// ----------------
// SPRITES
// ----------------
if needSprites {
updateAssetsState(func(st *AssetsTaskState) {
st.CurrentFile = it.name
st.CurrentQueue = "postwork"
st.CurrentPhase = "sprites"
st.CurrentLabel = "Sprites"
})
publishAssetsTaskPhase(it.name, "postwork", "sprites", "running", "Sprites")
_, serr := ensureSpritesForVideoCtx(ctx, it.path, sourceURL)
if serr != nil {
publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen")
} else if finishedPhaseTruthForID(id).SpritesReady {
publishAssetsTaskPhase(it.name, "postwork", "sprites", "done", "Sprites")
} else {
publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen")
}
}
// ----------------
// ANALYZE
// ----------------
if needAnalyze {
updateAssetsState(func(st *AssetsTaskState) {
st.CurrentFile = it.name
st.CurrentQueue = "enrich"
st.CurrentPhase = "analyze"
st.CurrentLabel = "KI-Analyse"
})
publishAssetsTaskPhase(it.name, "enrich", "analyze", "running", "KI-Analyse")
_, aerr := ensureAnalyzeForVideoCtx(ctx, it.path, sourceURL, "nsfw")
if aerr != nil {
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen")
} else if finishedPhaseTruthForID(id).AnalyzeReady {
publishAssetsTaskPhase(it.name, "enrich", "analyze", "done", "KI-Analyse")
} else {
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen")
}
} }
updateAssetsState(func(st *AssetsTaskState) { 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"`
@ -34,6 +40,14 @@ type cleanupReq struct {
BelowMB *int `json:"belowMB,omitempty"` BelowMB *int `json:"belowMB,omitempty"`
} }
func clearCleanupTaskState() {
cleanupTaskMu.Lock()
cleanupTaskState = CleanupTaskState{}
cleanupTaskMu.Unlock()
publishTaskState()
}
// /api/settings/cleanup (POST) // /api/settings/cleanup (POST)
// - löscht kleine Dateien < threshold MB (mp4/ts; skip .part/.tmp; skip keep-Ordner) // - löscht kleine Dateien < threshold MB (mp4/ts; skip .part/.tmp; skip keep-Ordner)
// - räumt Orphans (preview/thumbs + generated) auf // - räumt Orphans (preview/thumbs + generated) auf
@ -49,6 +63,11 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
doneAbs, err := resolvePathRelativeToApp(s.DoneDir) doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil || strings.TrimSpace(doneAbs) == "" { if err != nil || strings.TrimSpace(doneAbs) == "" {
setCleanupTaskError("doneDir auflösung fehlgeschlagen") setCleanupTaskError("doneDir auflösung fehlgeschlagen")
time.AfterFunc(7*time.Second, func() {
clearCleanupTaskState()
})
http.Error(w, "doneDir auflösung fehlgeschlagen", http.StatusBadRequest) http.Error(w, "doneDir auflösung fehlgeschlagen", http.StatusBadRequest)
return return
} }
@ -90,6 +109,10 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
orphansTotalRemoved := resp.GeneratedOrphansRemoved orphansTotalRemoved := resp.GeneratedOrphansRemoved
setCleanupTaskDone(fmt.Sprintf("geprüft: %d · Orphans: %d", resp.ScannedFiles, orphansTotalRemoved)) setCleanupTaskDone(fmt.Sprintf("geprüft: %d · Orphans: %d", resp.ScannedFiles, orphansTotalRemoved))
time.AfterFunc(7*time.Second, func() {
clearCleanupTaskState()
})
writeJSON(w, http.StatusOK, resp) writeJSON(w, http.StatusOK, resp)
} }
@ -103,8 +126,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 +149,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 +168,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 +189,43 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
continue continue
} }
resp.ScannedFiles++ candidates = append(candidates, cleanupCandidate{
path: full,
if fi.Size() < threshold { name: name,
base := strings.TrimSuffix(filepath.Base(full), filepath.Ext(full)) size: fi.Size(),
id := stripHotPrefix(base) })
if derr := removeWithRetry(full); derr == nil || os.IsNotExist(derr) {
resp.DeletedFiles++
resp.DeletedBytes += fi.Size()
if strings.TrimSpace(id) != "" {
removeGeneratedForID(id)
}
purgeDurationCacheForPath(full)
} else {
resp.ErrorCount++
}
}
} }
} }
scanDir(doneAbs, true) collectCandidates(doneAbs, true)
resp.ScannedFiles = len(candidates)
setCleanupTaskProgress(0, resp.ScannedFiles, "", "Prüfe Dateien…")
for i, c := range candidates {
setCleanupTaskProgress(i+1, resp.ScannedFiles, c.name, fmt.Sprintf("Prüfe %s", c.name))
if c.size >= threshold {
continue
}
base := strings.TrimSuffix(filepath.Base(c.path), filepath.Ext(c.path))
id := stripHotPrefix(base)
if derr := removeWithRetry(c.path); derr == nil || os.IsNotExist(derr) {
resp.DeletedFiles++
resp.DeletedBytes += c.size
if strings.TrimSpace(id) != "" {
removeGeneratedForID(id)
}
purgeDurationCacheForPath(c.path)
} else {
resp.ErrorCount++
}
}
} }
var generatedGCRunning int32 var generatedGCRunning int32
@ -286,6 +310,23 @@ func runGeneratedGarbageCollector() generatedGCStats {
return nil return nil
}) })
// 1b) Auch aktuell laufende / nachbearbeitete Jobs als "live" behandeln.
// Sonst räumt der GC generated/meta/<id> weg, obwohl RecordStream dort noch schreibt.
jobsMu.Lock()
for _, j := range jobs {
if j == nil {
continue
}
id := strings.TrimSpace(assetIDForJob(j))
if id == "" {
continue
}
live[id] = struct{}{}
}
jobsMu.Unlock()
// 2) /generated/meta/<id> prüfen // 2) /generated/meta/<id> prüfen
metaRoot, err := generatedMetaRoot() metaRoot, err := generatedMetaRoot()
if err == nil { if err == nil {

View File

@ -0,0 +1,605 @@
// backend\tasks_regenerate_assets.go
package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const regenerateAssetsMaxParallel = 3
type regenerateAssetsRequest struct {
File string `json:"file"`
}
type regenerateAssetsPhase struct {
Queue string `json:"queue"`
Phase string `json:"phase"`
State string `json:"state"`
Label string `json:"label,omitempty"`
TS int64 `json:"ts"`
}
type regenerateAssetsJob struct {
File string
VideoPath string
AssetID string
Ctx context.Context
Cancel context.CancelFunc
State string
Error string
EnqueuedAt time.Time
StartedAt time.Time
EndedAt time.Time
Phases map[string]regenerateAssetsPhase
}
var (
regenerateAssetsMu sync.Mutex
regenerateAssetsJobs = map[string]*regenerateAssetsJob{} // key = file
regenerateAssetsQueue []string // file names
regenerateAssetsWorkersStarted bool
)
func publishFinishedPostworkPhase(
file string,
assetID string,
queue string,
phase string,
state string,
label string,
) {
setRegenerateAssetsJobPhase(file, queue, phase, state, label)
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)
}
}
type regeneratePhaseTruth struct {
MetaReady bool
ThumbReady bool
TeaserReady bool
SpritesReady bool
AnalyzeReady bool
}
func regeneratePhaseTruthForVideo(videoPath string, assetID string) regeneratePhaseTruth {
videoPath = strings.TrimSpace(videoPath)
assetID = strings.TrimSpace(assetID)
if assetID == "" && videoPath != "" {
assetID = assetIDFromVideoPath(videoPath)
}
var out regeneratePhaseTruth
if assetID == "" {
return out
}
_, thumbPath, previewPath, spritePath, metaPath, err := assetPathsForID(assetID)
if err != nil {
return out
}
fileExists := func(p string) bool {
fi, err := os.Stat(strings.TrimSpace(p))
return err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0
}
out.ThumbReady = fileExists(thumbPath)
out.TeaserReady = fileExists(previewPath)
out.SpritesReady = fileExists(spritePath)
// Meta ist nur "ready", wenn die meta.json gültig zur aktuellen Videodatei passt
if videoPath != "" {
if vfi, err := os.Stat(videoPath); err == nil && vfi != nil && !vfi.IsDir() && vfi.Size() > 0 {
if meta, ok := readVideoMetaIfValid(metaPath, vfi); ok && meta != nil {
out.MetaReady = true
}
}
// KI nur dann fertig, wenn Sprite existiert UND AI in meta.json vorhanden ist
out.AnalyzeReady = out.SpritesReady && hasAIResultsForOutput(videoPath)
}
return out
}
func firstMissingPrimaryRegeneratePhaseError(videoPath string, assetID string) error {
truth := regeneratePhaseTruthForVideo(videoPath, assetID)
if !truth.MetaReady {
return errors.New("meta.json fehlt oder ist noch unvollständig")
}
if !truth.ThumbReady {
return errors.New("preview.jpg fehlt oder ist leer")
}
if !truth.TeaserReady {
return errors.New("preview.mp4 fehlt oder ist leer")
}
return nil
}
func firstMissingDeferredRegeneratePhaseError(videoPath string, assetID string) error {
truth := regeneratePhaseTruthForVideo(videoPath, assetID)
if !truth.SpritesReady {
return errors.New("preview-sprite.jpg fehlt oder ist leer")
}
if !truth.AnalyzeReady {
return errors.New("KI-Analyse fehlt oder wurde nicht in meta.json gespeichert")
}
return nil
}
func ensureRegenerateAssetsWorkersStarted() {
regenerateAssetsMu.Lock()
defer regenerateAssetsMu.Unlock()
if regenerateAssetsWorkersStarted {
return
}
regenerateAssetsWorkersStarted = true
for i := 0; i < regenerateAssetsMaxParallel; i++ {
go regenerateAssetsWorker()
}
}
func enqueueRegenerateAssetsJob(job *regenerateAssetsJob) {
regenerateAssetsMu.Lock()
defer regenerateAssetsMu.Unlock()
regenerateAssetsJobs[job.File] = job
regenerateAssetsQueue = append(regenerateAssetsQueue, job.File)
}
func nextQueuedRegenerateAssetsJob() *regenerateAssetsJob {
regenerateAssetsMu.Lock()
defer regenerateAssetsMu.Unlock()
for len(regenerateAssetsQueue) > 0 {
file := regenerateAssetsQueue[0]
regenerateAssetsQueue = regenerateAssetsQueue[1:]
job := regenerateAssetsJobs[file]
if job == nil {
continue
}
if job.State != "queued" {
continue
}
job.State = "running"
job.StartedAt = time.Now()
return job
}
return nil
}
func finishRegenerateAssetsJob(file string, state string, errMsg string) {
regenerateAssetsMu.Lock()
defer regenerateAssetsMu.Unlock()
job := regenerateAssetsJobs[file]
if job == nil {
return
}
job.State = state
job.Error = strings.TrimSpace(errMsg)
job.EndedAt = time.Now()
}
func setRegenerateAssetsJobPhase(
file string,
queue string,
phase string,
state string,
label string,
) {
regenerateAssetsMu.Lock()
defer regenerateAssetsMu.Unlock()
job := regenerateAssetsJobs[file]
if job == nil {
return
}
if job.Phases == nil {
job.Phases = make(map[string]regenerateAssetsPhase)
}
key := strings.ToLower(strings.TrimSpace(queue)) + ":" + strings.ToLower(strings.TrimSpace(phase))
job.Phases[key] = regenerateAssetsPhase{
Queue: strings.TrimSpace(queue),
Phase: strings.TrimSpace(phase),
State: strings.TrimSpace(state),
Label: strings.TrimSpace(label),
TS: time.Now().UnixMilli(),
}
}
func removeRegenerateAssetsJobLater(file string, after time.Duration) {
time.AfterFunc(after, func() {
regenerateAssetsMu.Lock()
defer regenerateAssetsMu.Unlock()
delete(regenerateAssetsJobs, file)
})
}
func getRegenerateAssetsJobsSnapshot() []map[string]any {
regenerateAssetsMu.Lock()
defer regenerateAssetsMu.Unlock()
out := make([]map[string]any, 0, len(regenerateAssetsJobs))
for _, job := range regenerateAssetsJobs {
phases := make([]map[string]any, 0, len(job.Phases))
for _, p := range job.Phases {
phases = append(phases, map[string]any{
"queue": p.Queue,
"phase": p.Phase,
"state": p.State,
"label": p.Label,
"ts": p.TS,
})
}
out = append(out, map[string]any{
"file": job.File,
"assetId": job.AssetID,
"state": job.State,
"error": job.Error,
"phases": phases,
"enqueuedAt": job.EnqueuedAt.UnixMilli(),
"startedAt": func() int64 {
if job.StartedAt.IsZero() {
return 0
}
return job.StartedAt.UnixMilli()
}(),
"endedAt": func() int64 {
if job.EndedAt.IsZero() {
return 0
}
return job.EndedAt.UnixMilli()
}(),
})
}
return out
}
func regenerateAssetsWorker() {
for {
job := nextQueuedRegenerateAssetsJob()
if job == nil {
time.Sleep(250 * time.Millisecond)
continue
}
runRegenerateAssetsJob(job)
}
}
func runRegenerateAssetsJob(job *regenerateAssetsJob) {
file := job.File
videoPath := job.VideoPath
id := job.AssetID
ctx := job.Ctx
setRegenerateAssetsTaskRunning(file, file)
cancelDeferredEnrichForFile(file)
cancelJob := func() {
finishRegenerateAssetsJob(file, "cancelled", "Abgebrochen")
setRegenerateAssetsTaskError(file, "Abgebrochen")
publishFinishedPostworkPhase(file, id, "postwork", "meta", "missing", "")
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "missing", "")
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "missing", "")
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "missing", "")
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "")
removeRegenerateAssetsJobLater(file, 2*time.Second)
}
failJob := func(queue, phase, msg string) {
if queue != "" && phase != "" {
publishFinishedPostworkPhase(file, id, queue, phase, "error", "Fehler")
}
finishRegenerateAssetsJob(file, "error", msg)
setRegenerateAssetsTaskError(file, msg)
removeRegenerateAssetsJobLater(file, 5*time.Second)
}
if ctx.Err() == context.Canceled {
cancelJob()
return
}
// Initialstatus
publishFinishedPostworkPhase(file, id, "postwork", "meta", "running", "Meta")
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild")
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser")
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites")
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse")
// Alte Assets löschen
if id != "" {
if assetDir, _, _, _, metaPath, err := assetPathsForID(id); err == nil {
_ = os.Remove(filepath.Join(assetDir, "preview.jpg"))
_ = os.Remove(filepath.Join(assetDir, "preview.mp4"))
_ = os.Remove(filepath.Join(assetDir, "preview-sprite.jpg"))
_ = os.Remove(metaPath)
}
}
// 1) Meta
if _, err := ensureMetaForVideoCtx(ctx, videoPath, ""); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
cancelJob()
return
}
failJob("postwork", "meta", err.Error())
return
}
if !regeneratePhaseTruthForVideo(videoPath, id).MetaReady {
failJob("postwork", "meta", "meta.json fehlt oder ist noch unvollständig")
return
}
publishFinishedPostworkPhase(file, id, "postwork", "meta", "done", "Meta")
// 2) Thumb
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "running", "Vorschaubild")
if _, err := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, videoPath, "", nil); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
cancelJob()
return
}
failJob("postwork", "thumb", err.Error())
return
}
if !regeneratePhaseTruthForVideo(videoPath, id).ThumbReady {
failJob("postwork", "thumb", "preview.jpg fehlt oder ist leer")
return
}
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "done", "Vorschaubild")
// 3) Teaser
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "running", "Teaser")
if _, err := ensureTeaserForVideoCtx(ctx, videoPath, ""); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
cancelJob()
return
}
failJob("postwork", "teaser", err.Error())
return
}
if !regeneratePhaseTruthForVideo(videoPath, id).TeaserReady {
failJob("postwork", "teaser", "preview.mp4 fehlt oder ist leer")
return
}
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "done", "Teaser")
// 4) Sprites
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "running", "Sprites")
if _, err := ensureSpritesForVideoCtx(ctx, videoPath, ""); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
cancelJob()
return
}
failJob("postwork", "sprites", err.Error())
return
}
if !regeneratePhaseTruthForVideo(videoPath, id).SpritesReady {
failJob("postwork", "sprites", "preview-sprite.jpg fehlt oder ist leer")
return
}
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites")
// 5) KI-Analyse
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "KI-Analyse")
if _, err := ensureAnalyzeForVideoCtx(ctx, videoPath, "", "nsfw"); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
cancelJob()
return
}
failJob("enrich", "analyze", err.Error())
return
}
if !regeneratePhaseTruthForVideo(videoPath, id).AnalyzeReady {
failJob("enrich", "analyze", "KI-Analyse fehlt oder wurde nicht in meta.json gespeichert")
return
}
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "KI-Analyse")
finishRegenerateAssetsJob(file, "done", "")
setRegenerateAssetsTaskDone(file, "Fertig")
removeRegenerateAssetsJobLater(file, 3*time.Second)
}
func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if r.Method == http.MethodDelete {
var req regenerateAssetsRequest
_ = json.NewDecoder(r.Body).Decode(&req)
file := strings.TrimSpace(req.File)
if file == "" {
regenerateAssetsMu.Lock()
for _, j := range regenerateAssetsJobs {
if j != nil && (j.State == "running" || j.State == "queued") {
file = j.File
break
}
}
regenerateAssetsMu.Unlock()
}
if file == "" {
http.Error(w, "missing file", http.StatusBadRequest)
return
}
regenerateAssetsMu.Lock()
job := regenerateAssetsJobs[file]
regenerateAssetsMu.Unlock()
if job != nil && job.Cancel != nil {
job.Cancel()
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"cancelled": true,
"file": file,
})
return
}
var req regenerateAssetsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json body", http.StatusBadRequest)
return
}
file := strings.TrimSpace(req.File)
if file == "" {
http.Error(w, "missing file", http.StatusBadRequest)
return
}
if !isAllowedVideoExt(file) {
http.Error(w, "nicht erlaubt", http.StatusForbidden)
return
}
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if strings.TrimSpace(doneAbs) == "" {
http.Error(w, "doneDir ist leer", http.StatusBadRequest)
return
}
videoPath, _, fi, err := resolveDoneFileByName(doneAbs, file)
if err != nil {
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
return
}
if fi == nil || fi.IsDir() || fi.Size() <= 0 {
http.Error(w, "invalid output path", http.StatusBadRequest)
return
}
ensureRegenerateAssetsWorkersStarted()
regenerateAssetsMu.Lock()
if existing := regenerateAssetsJobs[file]; existing != nil {
regenerateAssetsMu.Unlock()
w.Header().Set("Content-Type", "application/json")
phases := make([]map[string]any, 0, len(existing.Phases))
for _, p := range existing.Phases {
phases = append(phases, map[string]any{
"queue": p.Queue,
"phase": p.Phase,
"state": p.State,
"label": p.Label,
"ts": p.TS,
})
}
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"file": file,
"state": existing.State,
"error": existing.Error,
"phases": phases,
})
return
}
regenerateAssetsMu.Unlock()
runCtx, runCancel := context.WithCancel(context.Background())
id := assetIDFromVideoPath(videoPath)
if strings.TrimSpace(id) == "" {
id = stripHotPrefix(strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)))
}
job := &regenerateAssetsJob{
File: file,
VideoPath: videoPath,
AssetID: id,
Ctx: runCtx,
Cancel: runCancel,
State: "queued",
EnqueuedAt: time.Now(),
Phases: make(map[string]regenerateAssetsPhase),
}
enqueueRegenerateAssetsJob(job)
publishFinishedPostworkPhase(file, id, "postwork", "meta", "queued", "Meta")
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild")
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser")
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites")
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"file": file,
"state": "queued",
})
}

View File

@ -7,25 +7,112 @@ import (
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
"time"
) )
type CleanupTaskState struct { type CleanupTaskState struct {
Running bool `json:"running"`
Done int `json:"done"`
Total int `json:"total"`
Text string `json:"text,omitempty"`
CurrentFile string `json:"currentFile,omitempty"`
Error string `json:"error,omitempty"`
FinishedAt *string `json:"finishedAt,omitempty"`
}
type RegenerateAssetsTaskState struct {
Running bool `json:"running"` Running bool `json:"running"`
File string `json:"file,omitempty"`
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
type TasksStatusResponse struct { type TasksStatusResponse struct {
GenerateAssets AssetsTaskState `json:"generateAssets"` GenerateAssets AssetsTaskState `json:"generateAssets"`
Cleanup CleanupTaskState `json:"cleanup"` RegenerateAssets RegenerateAssetsTaskState `json:"regenerateAssets"`
AnyRunning bool `json:"anyRunning"` RegenerateAssetsJobs []map[string]any `json:"regenerateAssetsJobs"`
Cleanup CleanupTaskState `json:"cleanup"`
AnyRunning bool `json:"anyRunning"`
} }
var ( var (
cleanupTaskMu sync.Mutex cleanupTaskMu sync.Mutex
cleanupTaskState CleanupTaskState cleanupTaskState CleanupTaskState
regenerateAssetsTaskMu sync.Mutex
regenerateAssetsTaskState RegenerateAssetsTaskState
) )
func cleanupNowISO() *string {
s := time.Now().Format(time.RFC3339)
return &s
}
func anyToString(v any) string {
switch x := v.(type) {
case string:
return x
case []byte:
return string(x)
default:
return ""
}
}
func snapshotRegenerateAssetsTaskState() RegenerateAssetsTaskState {
regenerateAssetsTaskMu.Lock()
st := regenerateAssetsTaskState
regenerateAssetsTaskMu.Unlock()
return st
}
func setRegenerateAssetsTaskRunning(file, text string) {
regenerateAssetsTaskMu.Lock()
regenerateAssetsTaskState = RegenerateAssetsTaskState{
Running: true,
File: strings.TrimSpace(file),
Text: strings.TrimSpace(text),
Error: "",
}
regenerateAssetsTaskMu.Unlock()
publishTaskState()
}
func setRegenerateAssetsTaskDone(file, text string) {
regenerateAssetsTaskMu.Lock()
regenerateAssetsTaskState = RegenerateAssetsTaskState{
Running: false,
File: strings.TrimSpace(file),
Text: strings.TrimSpace(text),
Error: "",
}
regenerateAssetsTaskMu.Unlock()
publishTaskState()
}
func setRegenerateAssetsTaskError(file, errText string) {
regenerateAssetsTaskMu.Lock()
regenerateAssetsTaskState = RegenerateAssetsTaskState{
Running: false,
File: strings.TrimSpace(file),
Text: "",
Error: strings.TrimSpace(errText),
}
regenerateAssetsTaskMu.Unlock()
publishTaskState()
}
func clearRegenerateAssetsTaskState() {
regenerateAssetsTaskMu.Lock()
regenerateAssetsTaskState = RegenerateAssetsTaskState{}
regenerateAssetsTaskMu.Unlock()
publishTaskState()
}
func snapshotCleanupTaskState() CleanupTaskState { func snapshotCleanupTaskState() CleanupTaskState {
cleanupTaskMu.Lock() cleanupTaskMu.Lock()
st := cleanupTaskState st := cleanupTaskState
@ -36,31 +123,60 @@ func snapshotCleanupTaskState() CleanupTaskState {
func setCleanupTaskRunning(text string) { func setCleanupTaskRunning(text string) {
cleanupTaskMu.Lock() cleanupTaskMu.Lock()
cleanupTaskState = CleanupTaskState{ cleanupTaskState = CleanupTaskState{
Running: true, Running: true,
Text: strings.TrimSpace(text), Done: 0,
Error: "", Total: 0,
Text: strings.TrimSpace(text),
CurrentFile: "",
Error: "",
FinishedAt: nil,
} }
cleanupTaskMu.Unlock() cleanupTaskMu.Unlock()
publishTaskState()
}
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()
publishTaskState()
} }
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()
publishTaskState()
} }
func setCleanupTaskError(errText string) { func setCleanupTaskError(errText string) {
cleanupTaskMu.Lock() cleanupTaskMu.Lock()
cleanupTaskState = CleanupTaskState{ cleanupTaskState.Running = false
Running: false, cleanupTaskState.Text = ""
Text: "", cleanupTaskState.Error = strings.TrimSpace(errText)
Error: strings.TrimSpace(errText), cleanupTaskState.CurrentFile = ""
} cleanupTaskState.FinishedAt = cleanupNowISO()
cleanupTaskMu.Unlock() cleanupTaskMu.Unlock()
publishTaskState()
} }
func tasksStatusHandler(w http.ResponseWriter, r *http.Request) { func tasksStatusHandler(w http.ResponseWriter, r *http.Request) {
@ -70,12 +186,27 @@ func tasksStatusHandler(w http.ResponseWriter, r *http.Request) {
} }
assets := getGenerateAssetsTaskStatus() assets := getGenerateAssetsTaskStatus()
regenerate := snapshotRegenerateAssetsTaskState()
regenerateJobs := getRegenerateAssetsJobsSnapshot()
cleanup := snapshotCleanupTaskState() cleanup := snapshotCleanupTaskState()
anyRegenerateRunning := regenerate.Running
if !anyRegenerateRunning {
for _, job := range regenerateJobs {
state := strings.TrimSpace(strings.ToLower(anyToString(job["state"])))
if state == "queued" || state == "running" {
anyRegenerateRunning = true
break
}
}
}
resp := TasksStatusResponse{ resp := TasksStatusResponse{
GenerateAssets: assets, GenerateAssets: assets,
Cleanup: cleanup, RegenerateAssets: regenerate,
AnyRunning: assets.Running || cleanup.Running, RegenerateAssetsJobs: regenerateJobs,
Cleanup: cleanup,
AnyRunning: assets.Running || anyRegenerateRunning || cleanup.Running,
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")

View File

@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@ -65,10 +65,11 @@ type RecorderSettingsState = {
teaserPlayback?: 'still' | 'hover' | 'all' teaserPlayback?: 'still' | 'hover' | 'all'
teaserAudio?: boolean teaserAudio?: boolean
lowDiskPauseBelowGB?: number lowDiskPauseBelowGB?: number
enableNotifications?: boolean
} }
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
@ -110,6 +111,15 @@ type JobEvent = {
maxParallel?: number maxParallel?: number
} }
type TaskStateEvent = {
type?: 'task_state'
generateAssetsRunning?: boolean
regenerateAssetsRunning?: boolean
cleanupRunning?: boolean
anyRunning?: boolean
ts?: number
}
const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = { const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
recordDir: 'records', recordDir: 'records',
doneDir: 'records/done', doneDir: 'records/done',
@ -124,6 +134,7 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
teaserPlayback: 'hover', teaserPlayback: 'hover',
teaserAudio: false, teaserAudio: false,
lowDiskPauseBelowGB: 5, lowDiskPauseBelowGB: 5,
enableNotifications: true,
} }
type StoredModel = { type StoredModel = {
@ -203,6 +214,27 @@ function normalizePendingShow(v: unknown): 'public' | 'private' | 'hidden' | 'aw
} }
} }
function normalizeRoomStatusText(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' {
return normalizePendingShow(v)
}
function roomStatusLabelDE(v: unknown): string {
switch (normalizeRoomStatusText(v)) {
case 'public':
return 'live'
case 'private':
return 'privat'
case 'hidden':
return 'hidden'
case 'away':
return 'away'
case 'offline':
return 'offline'
default:
return 'unbekannt'
}
}
function normalizeHttpUrl(raw: string): string | null { function normalizeHttpUrl(raw: string): string | null {
let v = (raw ?? '').trim() let v = (raw ?? '').trim()
if (!v) return null if (!v) return null
@ -450,6 +482,9 @@ export default function App() {
const notifyRef = useRef(notify) const notifyRef = useRef(notify)
const lastKnownRoomStatusByKeyRef = useRef<Record<string, string>>({})
const roomStatusBaselineReadyRef = useRef(false)
// ✅ Dedupe für "Cookies fehlen" Meldung (damit silent/autostarts nicht spammen) // ✅ Dedupe für "Cookies fehlen" Meldung (damit silent/autostarts nicht spammen)
const cookieProblemLastAtRef = useRef(0) const cookieProblemLastAtRef = useRef(0)
@ -508,6 +543,22 @@ export default function App() {
const modelEventNamesRef = useRef<Set<string>>(new Set()) const modelEventNamesRef = useRef<Set<string>>(new Set())
const onModelJobEventRef = useRef<((ev: MessageEvent) => void) | null>(null) const onModelJobEventRef = useRef<((ev: MessageEvent) => void) | null>(null)
const [playerModelKey, setPlayerModelKey] = useState<string | null>(null)
const [sourceUrl, setSourceUrl] = useState('')
const [jobs, setJobs] = useState<RecordJob[]>([])
const [doneJobs, setDoneJobs] = useState<RecordJob[]>([])
const [donePage, setDonePage] = useState(1)
const [doneCount, setDoneCount] = useState<number>(0)
const [modelsCount, setModelsCount] = useState(0)
const [roomStatusByModelKey, setRoomStatusByModelKey] = useState<Record<string, string>>({})
const [lastHeaderUpdateAtMs, setLastHeaderUpdateAtMs] = useState<number>(() => Date.now())
const [nowMs, setNowMs] = useState<number>(() => Date.now())
const [modelsByKey, setModelsByKey] = useState<Record<string, StoredModel>>({})
const selectSourceUrl = useCallback(() => { const selectSourceUrl = useCallback(() => {
const el = sourceUrlInputRef.current const el = sourceUrlInputRef.current
if (!el) return if (!el) return
@ -570,56 +621,6 @@ export default function App() {
} }
}, []) }, [])
useEffect(() => {
if (!authed) return
let cancelled = false
let timer: number | null = null
const loadSettingsTaskState = async () => {
try {
const res = await fetch('/api/tasks/generate-assets', { cache: 'no-store' as any })
if (!res.ok) {
if (!cancelled) setSettingsTaskRunning(false)
return
}
const data = await res.json().catch(() => null)
if (cancelled) return
const running = Boolean(data?.running)
setSettingsTaskRunning(running)
} catch {
if (!cancelled) setSettingsTaskRunning(false)
}
}
const schedule = (ms: number) => {
if (cancelled) return
timer = window.setTimeout(async () => {
await loadSettingsTaskState()
schedule(document.hidden ? 8000 : 3000)
}, ms)
}
void loadSettingsTaskState()
schedule(3000)
const onVis = () => {
if (!document.hidden) {
void loadSettingsTaskState()
}
}
document.addEventListener('visibilitychange', onVis)
return () => {
cancelled = true
if (timer != null) window.clearTimeout(timer)
document.removeEventListener('visibilitychange', onVis)
}
}, [authed])
useEffect(() => { useEffect(() => {
void checkAuth() void checkAuth()
}, [checkAuth]) }, [checkAuth])
@ -768,6 +769,43 @@ export default function App() {
} }
}, []) }, [])
const loadDonePage = useCallback(async (pageToLoad = donePage, sortToLoad = doneSort) => {
try {
const key = makePrefetchKey(pageToLoad, sortToLoad)
const prefetched = donePrefetchRef.current
if (prefetched?.key === key && Array.isArray(prefetched.items)) {
setDoneJobs(prefetched.items)
setLastHeaderUpdateAtMs(Date.now())
donePrefetchRef.current = null
void prefetchDonePage(pageToLoad + 1)
return
}
const res = await fetch(
`/api/record/done?page=${pageToLoad}&pageSize=${DONE_PAGE_SIZE}&sort=${encodeURIComponent(sortToLoad)}`,
{ cache: 'no-store' as any }
)
if (!res.ok) return
const data = await res.json().catch(() => null)
const items = Array.isArray(data?.items)
? (data.items as RecordJob[])
: Array.isArray(data)
? (data as RecordJob[])
: []
setDoneJobs(items)
setLastHeaderUpdateAtMs(Date.now())
void prefetchDonePage(pageToLoad + 1)
} catch {
// ignore
}
}, [donePage, doneSort, prefetchDonePage])
const requestFinishedReload = useCallback((reason = 'unknown') => { const requestFinishedReload = useCallback((reason = 'unknown') => {
const now = Date.now() const now = Date.now()
const cooldownMs = 700 // 👈 wichtig gegen Event-Stürme const cooldownMs = 700 // 👈 wichtig gegen Event-Stürme
@ -853,20 +891,14 @@ export default function App() {
void loadDoneCount() void loadDoneCount()
}, [authed, loadDoneCount]) }, [authed, loadDoneCount])
const [playerModelKey, setPlayerModelKey] = useState<string | null>(null) useEffect(() => {
const [sourceUrl, setSourceUrl] = useState('') if (!authed) return
const [jobs, setJobs] = useState<RecordJob[]>([]) void loadDonePage(donePage, doneSort)
const [doneJobs, setDoneJobs] = useState<RecordJob[]>([]) }, [authed, donePage, doneSort, loadDonePage])
const [donePage, setDonePage] = useState(1)
const [doneCount, setDoneCount] = useState<number>(0)
const [modelsCount, setModelsCount] = useState(0)
const [roomStatusByModelKey, setRoomStatusByModelKey] = useState<Record<string, string>>({}) useEffect(() => {
setModelsCount(Object.keys(modelsByKey).length)
const [lastHeaderUpdateAtMs, setLastHeaderUpdateAtMs] = useState<number>(() => Date.now()) }, [modelsByKey])
const [nowMs, setNowMs] = useState<number>(() => Date.now())
const [modelsByKey, setModelsByKey] = useState<Record<string, StoredModel>>({})
const isChaturbateStoreModel = useCallback((m?: StoredModel | null) => { const isChaturbateStoreModel = useCallback((m?: StoredModel | null) => {
const h = String(m?.host ?? '').toLowerCase() const h = String(m?.host ?? '').toLowerCase()
@ -1124,13 +1156,76 @@ export default function App() {
const refreshModelsByKey = useCallback(async () => { const refreshModelsByKey = useCallback(async () => {
try { try {
const list = await apiJSON<StoredModel[]>('/api/models', { cache: 'no-store' as any }) const list = await apiJSON<StoredModel[]>('/api/models', { cache: 'no-store' as any })
setModelsByKey(buildModelsByKey(Array.isArray(list) ? list : [])) const safeList = Array.isArray(list) ? list : []
setModelsByKey(buildModelsByKey(safeList))
setLastHeaderUpdateAtMs(Date.now()) setLastHeaderUpdateAtMs(Date.now())
} catch { } catch {
// ignore // ignore
} }
}, [buildModelsByKey]) }, [buildModelsByKey])
const maybeNotifyWatchedModelStatusChange = useCallback((
keyLower: string,
nextStatusRaw: unknown,
patch?: {
imageUrl?: string
}
) => {
if (!keyLower) return
if (!recSettingsRef.current.enableNotifications) return
if (!roomStatusBaselineReadyRef.current) return
const model = modelsByKeyRef.current[keyLower]
if (!model?.watching) {
lastKnownRoomStatusByKeyRef.current[keyLower] = normalizeRoomStatusText(nextStatusRaw)
return
}
const nextStatus = normalizeRoomStatusText(nextStatusRaw)
const prevStatus = normalizeRoomStatusText(lastKnownRoomStatusByKeyRef.current[keyLower])
// keine Änderung
if (prevStatus === nextStatus) return
// keine "erste Sichtung" als Wechsel melden
const hadPreviousRealStatus =
Boolean(lastKnownRoomStatusByKeyRef.current[keyLower]) &&
prevStatus !== 'unknown'
lastKnownRoomStatusByKeyRef.current[keyLower] = nextStatus
if (!hadPreviousRealStatus) return
const displayName =
String(model?.modelKey ?? keyLower).trim() || keyLower
const imageUrl =
String(patch?.imageUrl ?? '').trim() ||
String(model?.imageUrl ?? '').trim() ||
undefined
const title =
nextStatus === 'public'
? `${displayName} ist live`
: `${displayName} Status geändert`
const message = `Status: ${roomStatusLabelDE(prevStatus)}${roomStatusLabelDE(nextStatus)}`
notifyRef.current?.info(title, message, {
imageUrl,
imageAlt: displayName,
durationMs: 5000,
onClick: () => {
window.dispatchEvent(
new CustomEvent('open-model-details', {
detail: { modelKey: keyLower },
})
)
},
})
}, [])
const enqueueStart = useCallback( const enqueueStart = useCallback(
(item: StartQueueItem) => { (item: StartQueueItem) => {
const norm0 = normalizeHttpUrl(item.url) const norm0 = normalizeHttpUrl(item.url)
@ -1163,56 +1258,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()
@ -1223,6 +1268,10 @@ export default function App() {
? Boolean(msg.isOnline) ? Boolean(msg.isOnline)
: roomStatus !== 'offline' : roomStatus !== 'offline'
maybeNotifyWatchedModelStatusChange(modelKey, roomStatus, {
imageUrl: String(msg?.modelImageUrl ?? '').trim(),
})
applyRoomStateToModel(modelKey, { applyRoomStateToModel(modelKey, {
roomStatus, roomStatus,
isOnline, isOnline,
@ -1247,7 +1296,7 @@ export default function App() {
// ignore // ignore
} }
} }
}, [applyJobDelta, applyRoomStateToModel, enqueueStart]) }, [applyJobDelta, applyRoomStateToModel, enqueueStart, maybeNotifyWatchedModelStatusChange])
useEffect(() => { useEffect(() => {
const t = window.setInterval(() => setNowMs(Date.now()), 1000) const t = window.setInterval(() => setNowMs(Date.now()), 1000)
@ -1354,7 +1403,9 @@ export default function App() {
// ✅ 1) Update-Event mit Model: direkt in State übernehmen (KEIN /api/models) // ✅ 1) Update-Event mit Model: direkt in State übernehmen (KEIN /api/models)
if (updated && typeof updated === 'object') { if (updated && typeof updated === 'object') {
const k = String(updated.modelKey ?? '').toLowerCase().trim() const k = String(updated.modelKey ?? '').toLowerCase().trim()
if (k) setModelsByKey((prev) => ({ ...prev, [k]: updated })) if (k) {
setModelsByKey((prev) => ({ ...prev, [k]: updated }))
}
try { try {
upsertModelCache(updated) upsertModelCache(updated)
@ -1614,6 +1665,24 @@ export default function App() {
modelsByKeyRef.current = modelsByKey modelsByKeyRef.current = modelsByKey
}, [modelsByKey]) }, [modelsByKey])
useEffect(() => {
const next: Record<string, string> = {}
for (const [rawKey, model] of Object.entries(modelsByKey || {})) {
const key = String(rawKey ?? '').trim().toLowerCase()
if (!key) continue
const status = normalizeRoomStatusText((model as any)?.roomStatus)
next[key] = status
}
lastKnownRoomStatusByKeyRef.current = next
if (!roomStatusBaselineReadyRef.current && Object.keys(next).length > 0) {
roomStatusBaselineReadyRef.current = true
}
}, [modelsByKey])
const pendingAutoStartByKeyRef = useRef(pendingAutoStartByKey) const pendingAutoStartByKeyRef = useRef(pendingAutoStartByKey)
useEffect(() => { useEffect(() => {
pendingAutoStartByKeyRef.current = pendingAutoStartByKey pendingAutoStartByKeyRef.current = pendingAutoStartByKey
@ -1707,6 +1776,10 @@ export default function App() {
for (const key of keys) { for (const key of keys) {
const snap = byKey[key] ?? { show: 'offline' as const } const snap = byKey[key] ?? { show: 'offline' as const }
maybeNotifyWatchedModelStatusChange(key, snap.show, {
imageUrl: snap.imageUrl,
})
applyPendingRoomSnapshot(key, snap) applyPendingRoomSnapshot(key, snap)
if (snap.show === 'public') { if (snap.show === 'public') {
@ -2094,31 +2167,6 @@ export default function App() {
} }
}, []) }, [])
// ✅ Models-Count (leicht)
useEffect(() => {
let cancelled = false
const load = async () => {
try {
const meta = await apiJSON<{ count?: number }>('/api/models/meta', { cache: 'no-store' })
const c = Number(meta?.count ?? 0)
if (!cancelled && Number.isFinite(c)) {
setModelsCount(c)
setLastHeaderUpdateAtMs(Date.now())
}
} catch {
// ignore
}
}
load()
const t = window.setInterval(load, document.hidden ? 60000 : 30000)
return () => {
cancelled = true
window.clearInterval(t)
}
}, [])
const initialCookies = useMemo(() => Object.entries(cookies).map(([name, value]) => ({ name, value })), [cookies]) const initialCookies = useMemo(() => Object.entries(cookies).map(([name, value]) => ({ name, value })), [cookies])
const openPlayer = useCallback((job: RecordJob, startAtSec?: number) => { const openPlayer = useCallback((job: RecordJob, startAtSec?: number) => {
@ -2333,6 +2381,7 @@ export default function App() {
void loadDoneCount() void loadDoneCount()
void loadJobs() void loadJobs()
void loadTaskStateOnce()
if (selectedTabRef.current === 'finished') { if (selectedTabRef.current === 'finished') {
requestFinishedReload('sse fallback poll') requestFinishedReload('sse fallback poll')
@ -2340,31 +2389,23 @@ export default function App() {
}, document.hidden ? 60000 : 5000) }, document.hidden ? 60000 : 5000)
} }
const lastDoneFireRef = { t: 0 } const loadTaskStateOnce = async () => {
let doneCoalesceTimer: number | null = null try {
const res = await fetch('/api/tasks/status', { cache: 'no-store' as any })
if (!res.ok) return
const requestDoneRefresh = () => { const data = await res.json().catch(() => null)
const now = Date.now()
const since = now - lastDoneFireRef.t
if (since < 800) { setSettingsTaskRunning(
if (doneCoalesceTimer != null) return Boolean(
doneCoalesceTimer = window.setTimeout(() => { data?.anyRunning ||
doneCoalesceTimer = null data?.generateAssets?.running ||
lastDoneFireRef.t = Date.now() data?.regenerateAssets?.running ||
data?.cleanup?.running
void loadDoneCount() )
if (selectedTabRef.current === 'finished') { )
requestFinishedReload('doneChanged coalesced') } catch {
} // ignore
}, 900)
return
}
lastDoneFireRef.t = now
void loadDoneCount()
if (selectedTabRef.current === 'finished') {
requestFinishedReload('doneChanged')
} }
} }
@ -2377,11 +2418,79 @@ 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 {}
}
const onTaskState = (ev: MessageEvent) => {
try {
const msg = JSON.parse(String(ev.data ?? 'null')) as TaskStateEvent
setSettingsTaskRunning(
Boolean(
msg?.anyRunning ||
msg?.generateAssetsRunning ||
msg?.regenerateAssetsRunning ||
msg?.cleanupRunning
)
)
} catch {}
}
void loadJobs() void loadJobs()
void loadDoneCount() void loadDoneCount()
void loadTaskStateOnce()
// 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({
@ -2398,112 +2507,24 @@ export default function App() {
es.onopen = () => { es.onopen = () => {
stopFallbackPoll() stopFallbackPoll()
void loadTaskStateOnce()
} }
es.onerror = () => { es.onerror = () => {
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)
es.addEventListener('taskState', onTaskState 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()
void loadDoneCount() void loadDoneCount()
void loadTaskStateOnce()
if (selectedTabRef.current === 'finished') { if (selectedTabRef.current === 'finished') {
requestFinishedReload('visibilitychange') requestFinishedReload('visibilitychange')
} }
@ -2513,17 +2534,13 @@ 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) {
es.removeEventListener('doneChanged', onDoneChanged as any) es.removeEventListener('doneChanged', onDoneChanged as any)
es.removeEventListener('autostart', onAutostart as any) es.removeEventListener('autostart', onAutostart as any)
es.removeEventListener('finishedPostwork', onFinishedPostwork as any) es.removeEventListener('finishedPostwork', onFinishedPostwork as any)
es.removeEventListener('taskState', onTaskState as any)
const handler = onModelJobEventRef.current const handler = onModelJobEventRef.current
if (handler) { if (handler) {
@ -2537,18 +2554,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>()
@ -2853,6 +2860,10 @@ export default function App() {
// ✅ Prefetch wieder nachfüllen // ✅ Prefetch wieder nachfüllen
void prefetchDonePage(donePage + 1) void prefetchDonePage(donePage + 1)
// ✅ wichtig
requestFinishedReload('delete success')
void loadDoneCount()
}, 320) }, 320)
}, },
onError: async () => { onError: async () => {
@ -2881,16 +2892,48 @@ export default function App() {
run: () => apiJSON(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' }), run: () => apiJSON(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' }),
onSuccess: async () => { onSuccess: async () => {
window.setTimeout(() => { window.setTimeout(() => {
// ✅ Entfernt aus Finished + Running + Player // ✅ Done-Liste lokal bereinigen + Seite direkt wieder auffüllen (aus Prefetch)
setDoneJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) setDoneJobs((prev) => {
const filtered = prev.filter((j) => baseName(j.output || '') !== file)
const need = DONE_PAGE_SIZE - filtered.length
if (need <= 0) return filtered
const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
const buf = donePrefetchRef.current
if (!buf || buf.key !== prefetchKey || !Array.isArray(buf.items) || buf.items.length === 0) {
return filtered
}
const next: RecordJob[] = [...filtered]
const used = new Set(next.map((x) => String(x.id || baseName(x.output || '')).trim()))
while (next.length < DONE_PAGE_SIZE && buf.items.length > 0) {
const cand = buf.items.shift()!
const id = String(cand.id || baseName(cand.output || '')).trim()
if (!id || used.has(id)) continue
used.add(id)
next.push(cand)
}
donePrefetchRef.current = { ...buf, items: buf.items, ts: buf.ts }
return next
})
// ✅ Entfernt aus Running + Player
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev)) setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
// ✅ Count ebenfalls runter (Keep entfernt den Eintrag aus "finished") // ✅ Count runter
setDoneCount((c) => Math.max(0, c - 1)) setDoneCount((c) => Math.max(0, c - 1))
// Optional: nächste Seite vorladen (schadet nicht) // ✅ Prefetch wieder nachfüllen
void prefetchDonePage(donePage + 1) void prefetchDonePage(donePage + 1)
// ✅ Finished-Liste aktiv neu laden
requestFinishedReload('keep success')
void loadDoneCount()
}, 320) }, 320)
}, },
onError: async () => { onError: async () => {
@ -2901,7 +2944,15 @@ export default function App() {
return return
} }
}, },
[runFinishedFileAction, notify, donePage, prefetchDonePage] [
runFinishedFileAction,
notify,
donePage,
doneSort,
prefetchDonePage,
requestFinishedReload,
loadDoneCount,
]
) )
const handleToggleHot = useCallback( const handleToggleHot = useCallback(
@ -2957,7 +3008,7 @@ export default function App() {
return res return res
} catch (e: any) { } catch (e: any) {
notify.error('Umbenennen fehlgeschlagen', e?.message ?? String(e)) notify.error('Umbenennen fehlgeschlagen: ', baseName(job.output))
return return
} }
}, },
@ -3322,7 +3373,7 @@ export default function App() {
</div> </div>
<div className="relative"> <div className="relative">
<header className="z-30 bg-white/70 backdrop-blur dark:bg-gray-950/60 sm:sticky sm:top-0 sm:border-b sm:border-gray-200/70 sm:dark:border-white/10"> <header className="z-[70] bg-white/70 backdrop-blur dark:bg-gray-950/60 sm:sticky sm:top-0 sm:border-b sm:border-gray-200/70 sm:dark:border-white/10">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pt-3 sm:py-4 space-y-2 sm:space-y-3"> <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pt-3 sm:py-4 space-y-2 sm:space-y-3">
<div className="flex items-center sm:items-start justify-between gap-3 sm:gap-4"> <div className="flex items-center sm:items-start justify-between gap-3 sm:gap-4">
<div className="min-w-0"> <div className="min-w-0">
@ -3476,7 +3527,7 @@ export default function App() {
</div> </div>
</header> </header>
<div className="sm:hidden sticky top-0 z-20 border-b border-gray-200/70 bg-white/70 backdrop-blur dark:border-white/10 dark:bg-gray-950/60"> <div className="sm:hidden sticky top-0 z-[65] border-b border-gray-200/70 bg-white/70 backdrop-blur dark:border-white/10 dark:bg-gray-950/60">
<div className="mx-auto max-w-7xl px-4 pt-0 pb-2"> <div className="mx-auto max-w-7xl px-4 pt-0 pb-2">
<Tabs tabs={tabs} value={selectedTab} onChange={setSelectedTab} ariaLabel="Tabs" variant="barUnderline" /> <Tabs tabs={tabs} value={selectedTab} onChange={setSelectedTab} ariaLabel="Tabs" variant="barUnderline" />
</div> </div>
@ -3535,6 +3586,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) => {
@ -3552,7 +3604,6 @@ export default function App() {
{selectedTab === 'settings' ? ( {selectedTab === 'settings' ? (
<RecorderSettings <RecorderSettings
onAssetsGenerated={bumpAssets} onAssetsGenerated={bumpAssets}
onTaskRunningChange={setSettingsTaskRunning}
/> />
) : null} ) : null}
</main> </main>

View File

@ -0,0 +1,71 @@
// frontend\src\components\ui\Checkbox.tsx
'use client'
import * as React from 'react'
type Props = {
checked: boolean
indeterminate?: boolean
disabled?: boolean
onChange: (checked: boolean) => void
ariaLabel?: string
id?: string
name?: string
className?: string
}
export default function Checkbox({
checked,
indeterminate = false,
disabled = false,
onChange,
ariaLabel,
id,
name,
className = '',
}: Props) {
const ref = React.useRef<HTMLInputElement | null>(null)
React.useEffect(() => {
if (ref.current) {
ref.current.indeterminate = indeterminate && !checked
}
}, [indeterminate, checked])
return (
<div className={['relative z-0 group grid size-4 grid-cols-1', className].filter(Boolean).join(' ')}>
<input
ref={ref}
id={id}
name={name}
type="checkbox"
checked={checked}
disabled={disabled}
aria-label={ariaLabel}
onChange={(e) => onChange(e.target.checked)}
className="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-indigo-600 checked:bg-indigo-600 indeterminate:border-indigo-600 indeterminate:bg-indigo-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 dark:border-white/20 dark:bg-gray-900 dark:checked:border-indigo-500 dark:checked:bg-indigo-500 dark:indeterminate:border-indigo-500 dark:indeterminate:bg-indigo-500 dark:focus-visible:outline-indigo-500 dark:disabled:border-white/10 dark:disabled:bg-white/10 dark:disabled:checked:bg-white/10 forced-colors:appearance-auto"
/>
<svg
fill="none"
viewBox="0 0 14 14"
className="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white group-has-disabled:stroke-gray-950/25 dark:group-has-disabled:stroke-white/25"
>
<path
d="M3 8L6 11L11 3.5"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-0 group-has-checked:opacity-100"
/>
<path
d="M3 7H11"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-0 group-has-indeterminate:opacity-100"
/>
</svg>
</div>
)
}

View File

@ -59,7 +59,7 @@ export default function CookieModal({
} }
return ( return (
<Dialog open={open} onClose={onClose} className="relative z-50"> <Dialog open={open} onClose={onClose} className="relative z-[100]">
<div className="fixed inset-0 bg-black/40 backdrop-blur-[2px]" aria-hidden="true" /> <div className="fixed inset-0 bg-black/40 backdrop-blur-[2px]" aria-hidden="true" />
<div className="fixed inset-0 flex items-center justify-center p-4"> <div className="fixed inset-0 flex items-center justify-center p-4">
@ -147,11 +147,11 @@ export default function CookieModal({
</div> </div>
) : ( ) : (
<div className="overflow-hidden"> <div className="overflow-hidden">
<table className="min-w-full table-fixed text-sm"> <table className="w-full table-fixed text-sm">
<thead className="bg-gray-50 text-gray-700 dark:bg-white/5 dark:text-gray-200"> <thead className="bg-gray-50 text-gray-700 dark:bg-white/5 dark:text-gray-200">
<tr className="border-b border-gray-200/70 dark:border-white/10"> <tr className="border-b border-gray-200/70 dark:border-white/10">
<th className="w-[180px] px-4 py-2.5 text-left font-medium">Name</th> <th className="w-[180px] px-4 py-2.5 text-left font-medium">Name</th>
<th className="px-4 py-2.5 text-left font-medium">Wert</th> <th className="w-[calc(100%-290px)] px-4 py-2.5 text-left font-medium">Wert</th>
<th className="w-[110px] px-4 py-2.5 text-right font-medium">Aktion</th> <th className="w-[110px] px-4 py-2.5 text-right font-medium">Aktion</th>
</tr> </tr>
</thead> </thead>
@ -166,7 +166,7 @@ export default function CookieModal({
<div <div
className=" className="
inline-flex max-w-full items-center rounded-md inline-flex max-w-full items-center rounded-md
border border-gray-200 bg-gray-50 px-2 py-1 border border-gray-200 bg-gray-50 px-3 py-2
font-mono text-xs text-gray-800 font-mono text-xs text-gray-800
dark:border-white/10 dark:bg-white/5 dark:text-gray-100 dark:border-white/10 dark:bg-white/5 dark:text-gray-100
" "
@ -189,18 +189,16 @@ export default function CookieModal({
</div> </div>
</td> </td>
<td className="px-4 py-3 text-right align-top"> <td className="w-[110px] px-4 py-3 text-right align-top whitespace-nowrap">
<button <Button
type="button" type="button"
size="sm"
variant="secondary"
onClick={() => removeCookie(c.name)} onClick={() => removeCookie(c.name)}
className=" className="text-red-700 dark:text-red-300"
inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
text-red-700 hover:bg-red-50
dark:text-red-300 dark:hover:bg-red-500/10
"
> >
Entfernen Entfernen
</button> </Button>
</td> </td>
</tr> </tr>
))} ))}

View File

@ -642,6 +642,9 @@ export default function Downloads({
const [stopRequestedIds, setStopRequestedIds] = useState<Record<string, true>>({}) const [stopRequestedIds, setStopRequestedIds] = useState<Record<string, true>>({})
const [stopInitiatedIds, setStopInitiatedIds] = useState<Record<string, true>>({}) const [stopInitiatedIds, setStopInitiatedIds] = useState<Record<string, true>>({})
const [removePendingRequestedKeys, setRemovePendingRequestedKeys] = useState<Record<string, true>>({})
const [removeQueuedPostworkRequestedIds, setRemoveQueuedPostworkRequestedIds] = useState<Record<string, true>>({})
const markStopRequested = useCallback((ids: string | string[]) => { const markStopRequested = useCallback((ids: string | string[]) => {
const arr = Array.isArray(ids) ? ids : [ids] const arr = Array.isArray(ids) ? ids : [ids]
@ -662,6 +665,36 @@ export default function Downloads({
}) })
}, []) }, [])
const markRemovePendingRequested = useCallback((key: string) => {
if (!key) return
setRemovePendingRequestedKeys((prev) => ({ ...prev, [key]: true }))
}, [])
const clearRemovePendingRequested = useCallback((key: string) => {
if (!key) return
setRemovePendingRequestedKeys((prev) => {
if (!prev[key]) return prev
const next = { ...prev }
delete next[key]
return next
})
}, [])
const markRemoveQueuedPostworkRequested = useCallback((id: string) => {
if (!id) return
setRemoveQueuedPostworkRequestedIds((prev) => ({ ...prev, [id]: true }))
}, [])
const clearRemoveQueuedPostworkRequested = useCallback((id: string) => {
if (!id) return
setRemoveQueuedPostworkRequestedIds((prev) => {
if (!prev[id]) return prev
const next = { ...prev }
delete next[id]
return next
})
}, [])
useEffect(() => { useEffect(() => {
setStopRequestedIds((prev) => { setStopRequestedIds((prev) => {
const keys = Object.keys(prev) const keys = Object.keys(prev)
@ -690,6 +723,38 @@ export default function Downloads({
}) })
}, [jobs]) }, [jobs])
useEffect(() => {
setRemoveQueuedPostworkRequestedIds((prev) => {
const keys = Object.keys(prev)
if (keys.length === 0) return prev
const liveIds = new Set(jobs.map((j) => String(j.id ?? '').trim()))
const next: Record<string, true> = {}
for (const id of keys) {
if (liveIds.has(id)) next[id] = true
}
return Object.keys(next).length === keys.length ? prev : next
})
}, [jobs])
useEffect(() => {
setRemovePendingRequestedKeys((prev) => {
const keys = Object.keys(prev)
if (keys.length === 0) return prev
const liveKeys = new Set(pending.map((p) => pendingRowKey(p)))
const next: Record<string, true> = {}
for (const key of keys) {
if (liveKeys.has(key)) next[key] = true
}
return Object.keys(next).length === keys.length ? prev : next
})
}, [pending])
const [nowMs, setNowMs] = useState(() => Date.now()) const [nowMs, setNowMs] = useState(() => Date.now())
const prevSizeBytesRef = useRef<Record<string, number>>({}) const prevSizeBytesRef = useRef<Record<string, number>>({})
@ -1058,19 +1123,30 @@ export default function Downloads({
widthClassName: 'w-[320px] min-w-[300px]', widthClassName: 'w-[320px] min-w-[300px]',
cell: (r) => { cell: (r) => {
if (r.kind !== 'job') { if (r.kind !== 'job') {
const pendingKey = pendingRowKey(r.pending)
const isRemovingPending = Boolean(removePendingRequestedKeys[pendingKey])
return ( return (
<div className="flex justify-end pr-2"> <div className="flex justify-end pr-2">
<Button <Button
size="sm" size="sm"
variant="primary" variant="primary"
color="red" color="red"
disabled={isRemovingPending || !onRemovePending}
className="shrink-0" className="shrink-0"
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation() e.stopPropagation()
await onRemovePending?.(r.pending) if (isRemovingPending) return
markRemovePendingRequested(pendingKey)
try {
await onRemovePending?.(r.pending)
} catch {
clearRemovePendingRequested(pendingKey)
}
}} }}
> >
Entfernen {isRemovingPending ? 'Entferne…' : 'Entfernen'}
</Button> </Button>
</div> </div>
) )
@ -1083,10 +1159,13 @@ export default function Downloads({
const postworkState = getEffectivePostworkState(j) const postworkState = getEffectivePostworkState(j)
const isQueuedPostwork = postworkState === 'queued' const isQueuedPostwork = postworkState === 'queued'
const isRemovingQueuedPostwork = Boolean(removeQueuedPostworkRequestedIds[j.id])
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording' const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
const disableStopButton = isQueuedPostwork ? false : isStopping
const buttonBusy = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping
const disableStopButton = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping
const key = modelKeyFromJob(j) const key = modelKeyFromJob(j)
const flags = key ? modelsByKey[key] : undefined const flags = key ? modelsByKey[key] : undefined
@ -1103,7 +1182,7 @@ export default function Downloads({
<RecordJobActions <RecordJobActions
job={j} job={j}
variant="table" variant="table"
busy={isQueuedPostwork ? false : isStopping} busy={buttonBusy}
isFavorite={isFav} isFavorite={isFav}
isLiked={isLiked} isLiked={isLiked}
isWatching={isWatching} isWatching={isWatching}
@ -1124,7 +1203,13 @@ export default function Downloads({
e.stopPropagation() e.stopPropagation()
if (isQueuedPostwork) { if (isQueuedPostwork) {
await onRemoveQueuedPostworkJob?.(j.id) if (isRemovingQueuedPostwork) return
markRemoveQueuedPostworkRequested(j.id)
try {
await onRemoveQueuedPostworkJob?.(j.id)
} catch {
clearRemoveQueuedPostworkRequested(j.id)
}
return return
} }
@ -1133,7 +1218,9 @@ export default function Downloads({
await onStopJob(j.id) await onStopJob(j.id)
}} }}
> >
{isQueuedPostwork ? 'Entfernen' : (isStopping ? 'Stoppe…' : 'Stop')} {isQueuedPostwork
? (isRemovingQueuedPostwork ? 'Entferne…' : 'Entfernen')
: (isStopping ? 'Stoppe…' : 'Stoppen')}
</Button> </Button>
</div> </div>
) )
@ -1247,6 +1334,33 @@ export default function Downloads({
} }
}, [stopAllBusy, stoppableIds, markStopRequested, onStopJob]) }, [stopAllBusy, stoppableIds, markStopRequested, onStopJob])
const rowClassName = useCallback((r: DownloadRow) => {
if (r.kind === 'pending') {
const key = pendingRowKey(r.pending)
const removing = Boolean(removePendingRequestedKeys[key])
return [
'transition-all duration-300',
removing && 'bg-red-50/70 dark:bg-red-500/10 pointer-events-none animate-pulse',
]
.filter(Boolean)
.join(' ')
}
const j = r.job
const stopRequested = Boolean(stopRequestedIds[j.id])
const removingQueued = Boolean(removeQueuedPostworkRequestedIds[j.id])
const isQueuedPostwork = getEffectivePostworkState(j) === 'queued'
return [
'transition-all duration-300',
stopRequested && !isQueuedPostwork && 'bg-indigo-50/70 dark:bg-indigo-500/10 pointer-events-none animate-pulse',
removingQueued && isQueuedPostwork && 'bg-red-50/70 dark:bg-red-500/10 pointer-events-none animate-pulse',
]
.filter(Boolean)
.join(' ')
}, [removePendingRequestedKeys, stopRequestedIds, removeQueuedPostworkRequestedIds])
return ( return (
<div className="grid gap-3"> <div className="grid gap-3">
<div className="sticky top-[56px] z-20"> <div className="sticky top-[56px] z-20">
@ -1338,7 +1452,7 @@ export default function Downloads({
return ( return (
<SwipeActionRow <SwipeActionRow
key={`dl:${j.id}`} key={`dl:${j.id}`}
actionLabel="Stoppen" actionLabel={isStopping ? 'Stoppe…' : 'Stoppen'}
actionColor="indigo" actionColor="indigo"
disabled={isStopping || isQueuedPostwork} disabled={isStopping || isQueuedPostwork}
onAction={async () => { onAction={async () => {
@ -1347,23 +1461,30 @@ export default function Downloads({
await onStopJob(j.id) await onStopJob(j.id)
}} }}
> >
<DownloadsCardRow <div
r={r} className={[
nowMs={nowMs} 'transition-all duration-300',
blurPreviews={blurPreviews} isStopping && 'pointer-events-none opacity-70 ring-1 ring-indigo-300 rounded-2xl animate-pulse bg-indigo-50/60 dark:bg-indigo-500/10 dark:ring-indigo-500/30',
modelsByKey={modelsByKey} ].filter(Boolean).join(' ')}
roomStatusByModelKey={roomStatusByModelKey} >
postworkInfoOf={postworkInfoOf} <DownloadsCardRow
stopRequestedIds={stopRequestedIds} r={r}
growingByJobId={growingByJobId} nowMs={nowMs}
markStopRequested={markStopRequested} blurPreviews={blurPreviews}
onOpenPlayer={onOpenPlayer} modelsByKey={modelsByKey}
onStopJob={onStopJob} roomStatusByModelKey={roomStatusByModelKey}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob} postworkInfoOf={postworkInfoOf}
onToggleFavorite={onToggleFavorite} stopRequestedIds={stopRequestedIds}
onToggleLike={onToggleLike} growingByJobId={growingByJobId}
onToggleWatch={onToggleWatch} markStopRequested={markStopRequested}
/> onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
</div>
</SwipeActionRow> </SwipeActionRow>
) )
})} })}
@ -1378,33 +1499,49 @@ export default function Downloads({
{pendingRows.map((r) => { {pendingRows.map((r) => {
if (r.kind !== 'pending') return null if (r.kind !== 'pending') return null
const pKey = pendingRowKey(r.pending)
const isRemovingPending = Boolean(removePendingRequestedKeys[pKey])
return ( return (
<SwipeActionRow <SwipeActionRow
key={`pending:${pendingRowKey(r.pending)}`} key={`pending:${pKey}`}
actionLabel="Entfernen" actionLabel={isRemovingPending ? 'Entferne…' : 'Entfernen'}
actionColor="red" actionColor="red"
disabled={!onRemovePending} disabled={!onRemovePending || isRemovingPending}
onAction={async () => { onAction={async () => {
await onRemovePending?.(r.pending) if (isRemovingPending) return
markRemovePendingRequested(pKey)
try {
await onRemovePending?.(r.pending)
} catch {
clearRemovePendingRequested(pKey)
}
}} }}
> >
<DownloadsCardRow <div
r={r} className={[
nowMs={nowMs} 'transition-all duration-300',
blurPreviews={blurPreviews} isRemovingPending && 'pointer-events-none opacity-70 ring-1 ring-red-300 rounded-2xl animate-pulse bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
modelsByKey={modelsByKey} ].filter(Boolean).join(' ')}
roomStatusByModelKey={roomStatusByModelKey} >
postworkInfoOf={postworkInfoOf} <DownloadsCardRow
stopRequestedIds={stopRequestedIds} r={r}
growingByJobId={growingByJobId} nowMs={nowMs}
markStopRequested={markStopRequested} blurPreviews={blurPreviews}
onOpenPlayer={onOpenPlayer} modelsByKey={modelsByKey}
onStopJob={onStopJob} roomStatusByModelKey={roomStatusByModelKey}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob} postworkInfoOf={postworkInfoOf}
onToggleFavorite={onToggleFavorite} stopRequestedIds={stopRequestedIds}
onToggleLike={onToggleLike} growingByJobId={growingByJobId}
onToggleWatch={onToggleWatch} markStopRequested={markStopRequested}
/> onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
</div>
</SwipeActionRow> </SwipeActionRow>
) )
})} })}
@ -1429,9 +1566,14 @@ export default function Downloads({
const isBusyPhase = phase !== '' && phase !== 'recording' const isBusyPhase = phase !== '' && phase !== 'recording'
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
const label = isQueuedPostwork ? 'Entfernen' : 'Stoppen' const isRemovingQueuedPostwork = Boolean(removeQueuedPostworkRequestedIds[j.id])
const label = isQueuedPostwork
? (isRemovingQueuedPostwork ? 'Entferne…' : 'Entfernen')
: (isStopping ? 'Stoppe…' : 'Stoppen')
const color = isQueuedPostwork ? 'red' : 'indigo' const color = isQueuedPostwork ? 'red' : 'indigo'
const disabled = isQueuedPostwork ? false : isStopping const disabled = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping
return ( return (
<SwipeActionRow <SwipeActionRow
@ -1441,7 +1583,13 @@ export default function Downloads({
disabled={disabled} disabled={disabled}
onAction={async () => { onAction={async () => {
if (isQueuedPostwork) { if (isQueuedPostwork) {
await onRemoveQueuedPostworkJob?.(j.id) if (isRemovingQueuedPostwork) return
markRemoveQueuedPostworkRequested(j.id)
try {
await onRemoveQueuedPostworkJob?.(j.id)
} catch {
clearRemoveQueuedPostworkRequested(j.id)
}
return return
} }
@ -1450,23 +1598,31 @@ export default function Downloads({
await onStopJob(j.id) await onStopJob(j.id)
}} }}
> >
<DownloadsCardRow <div
r={r} className={[
nowMs={nowMs} 'transition-all duration-300',
blurPreviews={blurPreviews} isQueuedPostwork && isRemovingQueuedPostwork && 'pointer-events-none opacity-70 ring-1 ring-red-300 rounded-2xl animate-pulse bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
modelsByKey={modelsByKey} !isQueuedPostwork && isStopping && 'pointer-events-none opacity-70 ring-1 ring-indigo-300 rounded-2xl animate-pulse bg-indigo-50/60 dark:bg-indigo-500/10 dark:ring-indigo-500/30',
roomStatusByModelKey={roomStatusByModelKey} ].filter(Boolean).join(' ')}
postworkInfoOf={postworkInfoOf} >
stopRequestedIds={stopRequestedIds} <DownloadsCardRow
growingByJobId={growingByJobId} r={r}
markStopRequested={markStopRequested} nowMs={nowMs}
onOpenPlayer={onOpenPlayer} blurPreviews={blurPreviews}
onStopJob={onStopJob} modelsByKey={modelsByKey}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob} roomStatusByModelKey={roomStatusByModelKey}
onToggleFavorite={onToggleFavorite} postworkInfoOf={postworkInfoOf}
onToggleLike={onToggleLike} stopRequestedIds={stopRequestedIds}
onToggleWatch={onToggleWatch} growingByJobId={growingByJobId}
/> markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
</div>
</SwipeActionRow> </SwipeActionRow>
) )
})} })}
@ -1483,6 +1639,7 @@ export default function Downloads({
</div> </div>
<Table <Table
rows={downloadJobRows} rows={downloadJobRows}
rowClassName={rowClassName}
columns={columns} columns={columns}
getRowKey={(r) => (r.kind === 'job' ? `dl:job:${r.job.id}` : `dl:pending:${pendingRowKey(r.pending)}`)} getRowKey={(r) => (r.kind === 'job' ? `dl:job:${r.job.id}` : `dl:pending:${pendingRowKey(r.pending)}`)}
striped striped

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -2,13 +2,12 @@
'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 {
StarIcon as StarSolidIcon,
HeartIcon as HeartSolidIcon,
EyeIcon as EyeSolidIcon,
} from '@heroicons/react/24/solid'
import RecordJobActions from './RecordJobActions' import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow' import TagOverflowRow from './TagOverflowRow'
import { isHotName, stripHotPrefix } from './hotName' import { isHotName, stripHotPrefix } from './hotName'
@ -20,43 +19,30 @@ import {
shouldEnableTeaserAudio, shouldEnableTeaserAudio,
shouldObserveTeasers, shouldObserveTeasers,
} from './teaserPlayback' } from './teaserPlayback'
import Checkbox from './Checkbox'
type ModelFlags = { import {
favorite?: boolean buildSpriteFrameStyle,
liked?: boolean | null parseJobMeta,
watching?: boolean | null firstNonEmptyString,
tags?: string readPreviewSpriteInfo,
scrubProgressRatioFromIndex,
// ✅ Optional für Model-Hover-Preview } from './previewSprite'
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[]
selectedKeys: Set<string>
onToggleSelected: (job: RecordJob) => void
onToggleSelectAllPage: (checked: boolean) => void
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?: 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
@ -94,33 +80,7 @@ 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>
// optional queued actions (bevorzugt verwenden, falls vorhanden) forcePreviewMuted?: boolean
enqueueDeleteVideo?: (job: RecordJob) => boolean
enqueueKeepVideo?: (job: RecordJob) => boolean
enqueueToggleHot?: (job: RecordJob) => 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 {
@ -129,49 +89,17 @@ function normalizeDurationSeconds(value: unknown): number | undefined {
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,
selectedKeys,
onToggleSelected,
handleDuration, handleDuration,
handleScrubberClickIndex, handleScrubberClickIndex,
@ -186,7 +114,6 @@ export default function FinishedDownloadsGalleryView({
deletingKeys, deletingKeys,
keepingKeys, keepingKeys,
removingKeys, removingKeys,
deletedKeys,
registerTeaserHost, registerTeaserHost,
@ -204,12 +131,11 @@ export default function FinishedDownloadsGalleryView({
onToggleWatch, onToggleWatch,
onSplit, onSplit,
onAddToDownloads, onAddToDownloads,
enqueueDeleteVideo, forcePreviewMuted,
enqueueKeepVideo,
enqueueToggleHot,
}: Props) { }: Props) {
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll // ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
const observeTeasers = shouldObserveTeasers(teaserState.mode) const observeTeasers = shouldObserveTeasers(teaserState.mode)
const hasAnySelection = selectedKeys.size > 0
// ✅ Wrapper: bei still unregistrieren / nicht registrieren // ✅ Wrapper: bei still unregistrieren / nicht registrieren
const registerTeaserHostIfNeeded = React.useCallback( const registerTeaserHostIfNeeded = React.useCallback(
@ -244,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
}, []) }, [])
@ -294,13 +225,17 @@ export default function FinishedDownloadsGalleryView({
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{rows.map((j) => { {rows.map((j) => {
const k = keyFor(j) const k = keyFor(j)
const checked = selectedKeys.has(k)
const isPreviewActive =
teaserState.activeKey === k || teaserState.hoverKey === k
// Sound nur bei Hover auf genau diesem Teaser // Sound nur bei Hover auf genau diesem Teaser
const allowSound = shouldEnableTeaserAudio({ const allowSound = shouldEnableTeaserAudio({
state: teaserState, state: teaserState,
itemKey: k, itemKey: k,
}) })
const previewMuted = !allowSound const previewMuted = forcePreviewMuted ? true : !allowSound
const model = modelNameFromOutput(j.output) const model = modelNameFromOutput(j.output)
const modelKey = lower(model) const modelKey = lower(model)
@ -315,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))
@ -331,8 +259,10 @@ export default function FinishedDownloadsGalleryView({
const resObj = resolutionObjOf(j) const resObj = resolutionObjOf(j)
const resLabel = formatResolution(resObj) const resLabel = formatResolution(resObj)
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k) const isRemoving = removingKeys.has(k)
const deleted = deletedKeys.has(k) const isDeleting = deletingKeys.has(k)
const isKeeping = keepingKeys.has(k)
const busy = isDeleting || isKeeping || isRemoving
// ✅ Modelbild-Mapping (anpassen, falls deine API andere Feldnamen hat) // ✅ Modelbild-Mapping (anpassen, falls deine API andere Feldnamen hat)
const modelImageSrc = firstNonEmptyString( const modelImageSrc = firstNonEmptyString(
@ -342,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)
@ -353,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)
@ -473,300 +310,283 @@ 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 <div key={k} className="relative">
key={k}
role="button"
tabIndex={0}
className={[
'group 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',
'transition-all duration-200',
'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
busy && 'pointer-events-none opacity-70',
deletingKeys.has(k) &&
'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
deleted && 'hidden',
]
.filter(Boolean)
.join(' ')}
onClick={() => onOpenPlayer(j)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
>
{/* Thumb */}
<div <div
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5" role="button"
ref={registerTeaserHostIfNeeded(k)} tabIndex={0}
data-finished-hover-key={k}
className="group relative focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500"
onClick={(e) => {
if (hasAnySelection) {
e.preventDefault()
e.stopPropagation()
onToggleSelected(j)
return
}
onOpenPlayer(j)
}}
onMouseEnter={() => { onMouseEnter={() => {
setHoveredThumbKey(k)
onHoverPreviewKeyChange?.(k) onHoverPreviewKeyChange?.(k)
}} }}
onMouseLeave={() => { onMouseLeave={() => {
setHoveredThumbKey((prev) => (prev === k ? null : prev))
onHoverPreviewKeyChange?.(null) onHoverPreviewKeyChange?.(null)
setHoveredThumbKey((prev) => (prev === k ? null : prev))
clearScrubIndex(k) clearScrubIndex(k)
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev)) setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
}} }}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
> >
{/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */} <div
<div className="absolute inset-0 overflow-hidden rounded-t-lg"> className={[
<div className="absolute inset-0"> 'relative rounded-lg overflow-visible outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
<FinishedVideoPreview 'bg-white dark:bg-gray-900/40',
job={j} 'transition-[transform,opacity,box-shadow,background-color] duration-220 ease-out',
getFileName={(p) => stripHotPrefix(baseName(p))} 'will-change-[transform,opacity]',
durationSeconds={durations[k] ?? (j as any)?.durationSeconds} !busy && 'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
onDuration={handleDuration} busy && !isRemoving && 'pointer-events-none opacity-70',
variant="fill" isDeleting &&
showPopover={false} 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
blur={blurPreviews} isKeeping &&
animated={shouldAnimateTeaser({ 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
state: teaserState, isRemoving
itemKey: k, ? 'pointer-events-none opacity-0 translate-y-2 scale-[0.98]'
disabled: hideTeaserUnderOverlay, : 'opacity-100 translate-y-0 scale-100',
})} ]
animatedMode="teaser" .filter(Boolean)
animatedTrigger="always" .join(' ')}
muted={previewMuted} >
popoverMuted={previewMuted} <div
scrubProgressRatio={scrubProgressRatio} className={[
preferScrubProgress={typeof activeScrubIndex === 'number'} '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> </div>
{/* ✅ Sprite vorladen (einmal), damit erster Scrub-Move sofort sichtbar ist */} {/* Thumb */}
{hasSpriteScrubber && spriteUrl ? ( <div
<img className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
src={spriteUrl} ref={registerTeaserHostIfNeeded(k)}
alt="" onMouseEnter={() => {
className="hidden" setHoveredThumbKey(k)
loading="lazy" }}
decoding="async" onMouseLeave={() => {
aria-hidden="true" setHoveredThumbKey((prev) => (prev === k ? null : prev))
/> clearScrubIndex(k)
) : null} setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
}}
>
{/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */}
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
<div className="absolute inset-0">
<FinishedVideoPreview
key={`gallery-preview-${k}-${isPreviewActive ? 'active' : 'idle'}`}
job={j}
getFileName={(p) => stripHotPrefix(baseName(p))}
durationSeconds={previewDurationSeconds}
onDuration={handleDuration}
variant="fill"
showPopover={false}
blur={blurPreviews}
animated={shouldAnimateTeaser({
state: teaserState,
itemKey: k,
disabled: hideTeaserUnderOverlay,
})}
animatedMode="teaser"
animatedTrigger="always"
muted={previewMuted}
popoverMuted={previewMuted}
scrubProgressRatio={scrubProgressRatio}
preferScrubProgress={typeof activeScrubIndex === 'number'}
forceActive={isPreviewActive}
teaserPreloadEnabled
/>
</div>
{/* ✅ Scrubber-Frame Overlay (Sprite-first = stashapp-like, kein Request pro Move) */} {/* ✅ Sprite vorladen (einmal), damit erster Scrub-Move sofort sichtbar ist */}
{showScrubberSpriteInThumb && spriteFrameStyle ? ( {hasSpriteScrubber && spriteUrl ? (
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true"> <img
<div src={spriteUrl}
className="h-full w-full" alt=""
style={spriteFrameStyle} className="hidden"
/> loading="lazy"
</div> decoding="async"
) : null} aria-hidden="true"
/>
) : null}
{/* ✅ Modelbild-Preview Overlay (hover auf Modelname) */} {/* ✅ Scrubber-Frame Overlay (Sprite-first = stashapp-like, kein Request pro Move) */}
{showModelPreviewInThumb && modelImageSrc ? ( {showScrubberSpriteInThumb && spriteFrameStyle ? (
<div className="absolute inset-0 z-[6]"> <div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
<img <div
src={modelImageSrc} className="h-full w-full"
alt={model ? `${model} preview` : 'Model preview'} style={spriteFrameStyle}
className="h-full w-full object-cover" />
draggable={false} </div>
/> ) : null}
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/65 to-transparent px-2 py-1.5">
<div className="text-[10px] font-semibold tracking-wide text-white/95"> {/* ✅ Modelbild-Preview Overlay (hover auf Modelname) */}
MODEL PREVIEW {showModelPreviewInThumb && modelImageSrc ? (
<div className="absolute inset-0 z-[6]">
<img
src={modelImageSrc}
alt={model ? `${model} preview` : 'Model preview'}
className="h-full w-full object-cover"
draggable={false}
/>
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/65 to-transparent px-2 py-1.5">
<div className="text-[10px] font-semibold tracking-wide text-white/95">
MODEL PREVIEW
</div>
</div>
</div>
) : null}
{/* ✅ stashapp-artiger Hover-Scrubber (UI-only) */}
{hasScrubber && hoveredThumbKey === k ? (
<div
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<PreviewScrubber
className="pointer-events-auto px-1"
imageCount={scrubberCount}
activeIndex={activeScrubIndex}
onActiveIndexChange={(idx) => setScrubIndexForKey(k, idx)}
onIndexClick={(index) => {
setScrubIndexForKey(k, index)
handleScrubberClickIndex(j, index, scrubberCount)
}}
stepSeconds={scrubberStepSeconds}
/>
</div>
) : null}
{/* Meta-Overlay im Video: unten rechts */}
<div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
<div
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size]
.filter(Boolean)
.join(' • ')}
>
<span>{dur}</span>
{resLabel ? <span aria-hidden="true"></span> : null}
{resLabel ? <span>{resLabel}</span> : null}
<span aria-hidden="true"></span>
<span>{size}</span>
</div> </div>
</div> </div>
</div> </div>
) : null}
{/* ✅ stashapp-artiger Hover-Scrubber (UI-only) */}
{hasScrubber && hoveredThumbKey === k ? (
<div
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<PreviewScrubber
className="pointer-events-auto px-1"
imageCount={scrubberCount}
activeIndex={activeScrubIndex}
onActiveIndexChange={(idx) => setScrubIndexForKey(k, idx)}
onIndexClick={(index) => {
setScrubIndexForKey(k, index)
handleScrubberClickIndex(j, index, scrubberCount)
}}
stepSeconds={scrubberStepSeconds}
/>
</div>
) : null}
{/* Meta-Overlay im Video: unten rechts */}
<div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
<div
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size]
.filter(Boolean)
.join(' • ')}
>
<span>{dur}</span>
{resLabel ? <span aria-hidden="true"></span> : null}
{resLabel ? <span>{resLabel}</span> : null}
<span aria-hidden="true"></span>
<span>{size}</span>
</div>
</div> </div>
</div>
</div>
{/* Footer / Meta */} {/* Footer / Meta */}
<div className="relative flex min-h-[118px] flex-col px-4 py-3 rounded-b-lg border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900"> <div
{/* ✅ stashapp-like: Dateiname zuerst */} className="relative flex min-h-[118px] flex-col px-4 py-3 rounded-b-lg border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900"
<div className="min-w-0"> onClick={(e) => {
<div className="mt-0.5 flex items-start gap-2 min-w-0"> e.preventDefault()
{isHot ? ( e.stopPropagation()
<span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold text-amber-800 dark:text-amber-300"> onOpenPlayer(j)
HOT }}
</span> >
) : null} {/* Model oben + Badge direkt dahinter */}
<div className="min-w-0">
<div className="mt-0.5 flex items-start justify-between gap-2 min-w-0">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
{model}
</div>
<span {showPostworkBadge && renderPostworkBadge ? (
className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white" <div className="relative z-20 shrink-0">
title={stripHotPrefix(file) || '—'} {renderPostworkBadge(postworkBadge)}
> </div>
{stripHotPrefix(file) || '—'} ) : null}
</span> </div>
</div>
</div>
{/* ✅ Model darunter + Postwork-Badge inline + rechts die Flags */} <div className="mt-0.5 flex items-start gap-2 min-w-0">
<div className="mt-1 flex items-center justify-between gap-2"> {isHot ? (
<div className="min-w-0 flex items-center gap-2"> <span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold text-amber-800 dark:text-amber-300">
<span HOT
className={[ </span>
'block min-w-0 truncate text-xs text-gray-500 dark:text-gray-400', ) : null}
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 ? ( <span
<span className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400"
className={[ title={stripHotPrefix(file) || '—'}
'shrink-0 inline-flex max-w-[180px] items-center rounded-md px-2 py-0.5 text-[11px] font-semibold ring-1', >
postworkBadge.state === 'running' {stripHotPrefix(file) || '—'}
? 'bg-indigo-500/10 text-indigo-700 ring-indigo-500/20 dark:bg-indigo-400/10 dark:text-indigo-200 dark:ring-indigo-400/20'
: postworkBadge.state === 'queued'
? 'bg-amber-500/10 text-amber-700 ring-amber-500/20 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/20'
: postworkBadge.state === 'done'
? 'bg-emerald-500/10 text-emerald-700 ring-emerald-500/20 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/20'
: 'bg-red-500/10 text-red-700 ring-red-500/20 dark:bg-red-400/10 dark:text-red-200 dark:ring-red-400/20',
].join(' ')}
title={
postworkBadge.label ||
(postworkBadge.state === 'running'
? 'Nacharbeiten laufen'
: postworkBadge.state === 'queued'
? 'Warte auf Nacharbeiten'
: postworkBadge.state === 'done'
? 'Nacharbeiten abgeschlossen'
: 'Nacharbeiten fehlgeschlagen')
}
>
<span className="truncate">
{postworkBadge.label ||
(postworkBadge.state === 'running'
? 'Nacharbeiten laufen…'
: postworkBadge.state === 'queued'
? 'Warte auf Nacharbeiten…'
: postworkBadge.state === 'done'
? 'Nacharbeiten abgeschlossen'
: 'Nacharbeiten fehlgeschlagen')}
</span> </span>
</span> </div>
) : null} </div>
</div>
<div className="shrink-0 flex items-center gap-1.5"> {/* Actions (wie CardView: im Footer statt im Video) */}
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null} <div
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null} className="mt-2 shrink-0"
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null} onClick={(e) => e.stopPropagation()}
</div> onMouseDown={(e) => e.stopPropagation()}
</div> >
<div className="w-full">
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<RecordJobActions
job={jobForDetails(j)}
variant="table"
busy={busy}
collapseToMenu
compact={true}
isHot={isHot}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
onSplit={onSplit}
onAddToDownloads={onAddToDownloads}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
className="w-full gap-1.5"
/>
</div>
</div>
</div>
{/* Actions (wie CardView: im Footer statt im Video) */} {/* Tags */}
<div <div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
className="mt-2 shrink-0" <TagOverflowRow
onClick={(e) => e.stopPropagation()} rowKey={k}
onMouseDown={(e) => e.stopPropagation()} tags={tags}
> activeTagSet={activeTagSet}
<div className="w-full"> lower={lower}
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10"> onToggleTagFilter={onToggleTagFilter}
<RecordJobActions
job={jobForDetails(j)}
variant="table"
busy={busy}
collapseToMenu
compact={true}
isHot={isHot}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={async (job) => {
if (enqueueToggleHot) {
const accepted = enqueueToggleHot(job)
if (accepted) return
}
return onToggleHot(job)
}}
onKeep={async (job) => {
if (enqueueKeepVideo) {
const accepted = enqueueKeepVideo(job)
if (accepted) return true
}
return keepVideo(job)
}}
onDelete={async (job) => {
if (enqueueDeleteVideo) {
const accepted = enqueueDeleteVideo(job)
if (accepted) return true
}
return deleteVideo(job)
}}
onSplit={onSplit}
onAddToDownloads={onAddToDownloads}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
className="w-full gap-1.5"
/> />
</div> </div>
</div> </div>
</div> </div>
{/* Tags */}
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<TagOverflowRow
rowKey={k}
tags={tags}
activeTagSet={activeTagSet}
lower={lower}
onToggleTagFilter={onToggleTagFilter}
/>
</div>
</div>
</div> </div>
</div>
) )
})} })}
</div> </div>

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'
@ -15,34 +20,15 @@ import {
shouldAnimateTeaser, shouldAnimateTeaser,
shouldEnableTeaserAudio, shouldEnableTeaserAudio,
} from './teaserPlayback' } from './teaserPlayback'
import Checkbox from './Checkbox'
type SortMode = import { parseJobMeta } from './previewSprite'
| '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[]
isLoading?: boolean isLoading?: boolean
selectedKeys: Set<string>
onToggleSelected: (job: RecordJob) => void
onToggleSelectAllPage: (checked: boolean) => void
// helpers // helpers
keyFor: (j: RecordJob) => string keyFor: (j: RecordJob) => string
@ -54,8 +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?: FinishedPostworkSummary) => React.ReactNode
// teaser/preview // teaser/preview
canHover: boolean canHover: boolean
@ -72,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
@ -92,16 +83,15 @@ type Props = {
deleteVideo: (job: RecordJob) => Promise<boolean> deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean> keepVideo: (job: RecordJob) => Promise<boolean>
forcePreviewMuted?: boolean
// optional queued actions (bevorzugt verwenden, falls vorhanden)
enqueueDeleteVideo?: (job: RecordJob) => boolean
enqueueKeepVideo?: (job: RecordJob) => boolean
enqueueToggleHot?: (job: RecordJob) => boolean
} }
export default function FinishedDownloadsTableView({ export default function FinishedDownloadsTableView({
rows, rows,
isLoading, isLoading,
selectedKeys,
onToggleSelected,
onToggleSelectAllPage,
keyFor, keyFor,
baseName, baseName,
lower, lower,
@ -112,7 +102,9 @@ export default function FinishedDownloadsTableView({
resolutions, resolutions,
durations, durations,
postworkByFile, postworkByFile,
getPostworkSummaryForOutput,
jobForDetails, jobForDetails,
renderPostworkBadge,
canHover, canHover,
teaserState, teaserState,
@ -145,15 +137,13 @@ export default function FinishedDownloadsTableView({
deleteVideo, deleteVideo,
keepVideo, keepVideo,
enqueueDeleteVideo, forcePreviewMuted,
enqueueKeepVideo,
enqueueToggleHot,
}: Props) { }: Props) {
const [sort, setSort] = React.useState<SortState>(null) const [sort, setSort] = React.useState<SortState>(null)
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)
@ -188,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(
@ -235,19 +216,67 @@ export default function FinishedDownloadsTableView({
}) })
}, []) }, [])
const allSelectedOnPage =
rows.length > 0 && rows.every((j) => selectedKeys.has(keyFor(j)))
const someSelectedOnPage =
rows.some((j) => selectedKeys.has(keyFor(j))) && !allSelectedOnPage
const columns = React.useMemo<Column<RecordJob>[]>(() => { const columns = React.useMemo<Column<RecordJob>[]>(() => {
return [ return [
{
key: 'select',
header: (
<div
className="flex items-center justify-center"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={allSelectedOnPage}
indeterminate={someSelectedOnPage}
ariaLabel={
allSelectedOnPage
? 'Auswahl aufheben'
: 'Alle Downloads auf dieser Seite auswählen'
}
onChange={(checked) => onToggleSelectAllPage(checked)}
/>
</div>
),
widthClassName: 'w-[44px]',
align: 'center',
cell: (j) => {
const checked = selectedKeys.has(keyFor(j))
return (
<div
className="flex items-center justify-center"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={checked}
ariaLabel={`${checked ? 'Abwählen' : 'Auswählen'}: ${baseName(j.output || '') || 'Download'}`}
onChange={() => onToggleSelected(j)}
/>
</div>
)
},
},
{ {
key: 'preview', key: 'preview',
header: 'Vorschau', header: 'Vorschau',
widthClassName: 'w-[140px]', widthClassName: 'w-[140px]',
cell: (j) => { cell: (j) => {
const k = keyFor(j) const k = keyFor(j)
const isPreviewActive =
teaserState.activeKey === k || teaserState.hoverKey === k
const allowSound = shouldEnableTeaserAudio({ const allowSound = shouldEnableTeaserAudio({
state: teaserState, state: teaserState,
itemKey: k, itemKey: k,
}) })
const previewMuted = !allowSound const previewMuted = forcePreviewMuted ? true : !allowSound
const spriteInfo = previewSpriteInfoOf(j) const spriteInfo = previewSpriteInfoOf(j)
const scrubActiveIndex = scrubActiveByKey[k] const scrubActiveIndex = scrubActiveByKey[k]
@ -270,18 +299,15 @@ export default function FinishedDownloadsTableView({
className="group relative py-1" className="group relative py-1"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onMouseEnter={() => {
if (canHover) setHoverTeaserKey(k)
}}
onMouseLeave={() => { onMouseLeave={() => {
if (canHover) setHoverTeaserKey(null)
setScrubActiveIndex(k, undefined) setScrubActiveIndex(k, undefined)
}} }}
> >
<FinishedVideoPreview <FinishedVideoPreview
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}
@ -296,6 +322,8 @@ export default function FinishedDownloadsTableView({
animatedMode="teaser" animatedMode="teaser"
animatedTrigger="always" animatedTrigger="always"
assetNonce={assetNonce} assetNonce={assetNonce}
forceActive={isPreviewActive}
teaserPreloadEnabled
/> />
{/* Scrub-Frame Overlay */} {/* Scrub-Frame Overlay */}
@ -332,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">
@ -349,32 +371,9 @@ export default function FinishedDownloadsTableView({
{model} {model}
</div> </div>
{showPostworkBadge ? ( {showPostworkBadge && renderPostworkBadge
<span ? renderPostworkBadge(postworkBadge)
className={[ : null}
'shrink-0 inline-flex max-w-[220px] items-center rounded-md px-2 py-0.5 text-[11px] font-semibold ring-1',
postworkBadge.state === 'running'
? 'bg-indigo-500/10 text-indigo-700 ring-indigo-500/20 dark:bg-indigo-400/10 dark:text-indigo-200 dark:ring-indigo-400/20'
: postworkBadge.state === 'queued'
? 'bg-amber-500/10 text-amber-700 ring-amber-500/20 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/20'
: postworkBadge.state === 'done'
? 'bg-emerald-500/10 text-emerald-700 ring-emerald-500/20 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/20'
: 'bg-red-500/10 text-red-700 ring-red-500/20 dark:bg-red-400/10 dark:text-red-200 dark:ring-red-400/20',
].join(' ')}
title={postworkBadge.label || 'Nacharbeiten laufen'}
>
<span className="truncate">
{postworkBadge.label ||
(postworkBadge.state === 'running'
? 'Nacharbeiten laufen…'
: postworkBadge.state === 'queued'
? 'Warte auf Nacharbeiten…'
: postworkBadge.state === 'done'
? 'Nacharbeiten abgeschlossen'
: 'Nacharbeiten fehlgeschlagen')}
</span>
</span>
) : null}
</div> </div>
<div className="mt-0.5 min-w-0 text-xs text-gray-500 dark:text-gray-400"> <div className="mt-0.5 min-w-0 text-xs text-gray-500 dark:text-gray-400">
@ -502,31 +501,9 @@ export default function FinishedDownloadsTableView({
onToggleWatch={onToggleWatch} onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite} onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike} onToggleLike={onToggleLike}
onToggleHot={ onToggleHot={onToggleHot}
onToggleHot onKeep={keepVideo}
? async (job) => { onDelete={deleteVideo}
if (enqueueToggleHot) {
const accepted = enqueueToggleHot(job)
if (accepted) return
}
return onToggleHot(job)
}
: undefined
}
onKeep={async (job) => {
if (enqueueKeepVideo) {
const accepted = enqueueKeepVideo(job)
if (accepted) return true
}
return keepVideo(job)
}}
onDelete={async (job) => {
if (enqueueDeleteVideo) {
const accepted = enqueueDeleteVideo(job)
if (accepted) return true
}
return deleteVideo(job)
}}
onSplit={onSplit} onSplit={onSplit}
onAddToDownloads={onAddToDownloads} onAddToDownloads={onAddToDownloads}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']} order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
@ -572,9 +549,17 @@ export default function FinishedDownloadsTableView({
runtimeOf, runtimeOf,
parseTags, parseTags,
runtimeSecondsForSort, runtimeSecondsForSort,
forcePreviewMuted,
renderPostworkBadge,
rows,
selectedKeys,
onToggleSelected,
onToggleSelectAllPage,
allSelectedOnPage,
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 ?? '')
@ -629,6 +614,18 @@ export default function FinishedDownloadsTableView({
sort={sort} sort={sort}
onSortChange={handleSortChange} onSortChange={handleSortChange}
onRowClick={onOpenPlayer} onRowClick={onOpenPlayer}
onRowMouseEnter={(j) => {
if (canHover) setHoverTeaserKey(keyFor(j))
}}
onRowMouseLeave={(j) => {
if (canHover) {
setHoverTeaserKey((prev) => (prev === keyFor(j) ? null : prev))
}
setScrubActiveIndex(keyFor(j), undefined)
}}
rowAttrs={(j) => ({
'data-finished-hover-key': keyFor(j),
})}
rowClassName={rowClassName} rowClassName={rowClassName}
/> />

View File

@ -1,7 +1,7 @@
// frontend\src\components\ui\FinishedVideoPreview.tsx // frontend\src\components\ui\FinishedVideoPreview.tsx
'use client' 'use client'
import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react' import { useCallback, useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react'
import type { RecordJob } from '../../types' import type { RecordJob } from '../../types'
import HoverPopover from './HoverPopover' import HoverPopover from './HoverPopover'
import { DEFAULT_INLINE_MUTED, applyInlineVideoPolicy } from './videoPolicy' import { DEFAULT_INLINE_MUTED, applyInlineVideoPolicy } from './videoPolicy'
@ -79,6 +79,11 @@ export type FinishedVideoPreviewProps = {
scrubProgressRatio?: number scrubProgressRatio?: number
preferScrubProgress?: boolean preferScrubProgress?: boolean
forceActive?: boolean
activationNonce?: number
registerTeaserPlay?: (fn: (() => void) | null) => void
holdSpeedActive?: boolean
} }
function baseName(path: string) { function baseName(path: string) {
@ -124,6 +129,10 @@ export default function FinishedVideoPreview({
teaserPreloadRootMargin = '700px 0px', teaserPreloadRootMargin = '700px 0px',
scrubProgressRatio, scrubProgressRatio,
preferScrubProgress = false, preferScrubProgress = false,
forceActive= false,
activationNonce = 0,
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' : ''
@ -142,46 +151,9 @@ export default function FinishedVideoPreview({
return m return m
}, [job]) }, [job])
// ✅ falls job.meta keine previewClips enthält: meta.json nachladen
const [fetchedMeta, setFetchedMeta] = useState<any | null>(null)
// ✅ verhindert mehrfachen fallback-fetch pro Datei in derselben Komponenteninstanz
const fetchedMetaFilesRef = useRef<Set<string>>(new Set())
// ✅ merge statt "meta ?? fetchedMeta" // ✅ merge statt "meta ?? fetchedMeta"
// job.meta bleibt Basis, fetchedMeta ergänzt fehlende Felder (z.B. previewClips) // job.meta bleibt Basis, fetchedMeta ergänzt fehlende Felder (z.B. previewClips)
const metaForPreview = useMemo(() => { const metaForPreview = meta
if (!meta && !fetchedMeta) return null
if (!meta) return fetchedMeta
if (!fetchedMeta) return meta
return { ...meta, ...fetchedMeta }
}, [meta, fetchedMeta])
// ✅ stabiler Guard für den fallback-fetch (berücksichtigt auch bereits geholtes fetchedMeta)
const hasPreviewClipsInAnyMeta = useMemo(() => {
let pcs: any = (metaForPreview as any)?.previewClips
if (typeof pcs === 'string') {
const s = pcs.trim()
if (s.length > 0) {
try {
const parsed = JSON.parse(s)
if (Array.isArray(parsed) && parsed.length > 0) return true
// falls string vorhanden aber nicht parsebar, behandeln wir ihn trotzdem als "vorhanden"
return true
} catch {
return true
}
}
}
if (Array.isArray(pcs) && pcs.length > 0) return true
const nested = (metaForPreview as any)?.preview?.clips
if (Array.isArray(nested) && nested.length > 0) return true
return false
}, [metaForPreview])
const [progressMountTick, setProgressMountTick] = useState(0) const [progressMountTick, setProgressMountTick] = useState(0)
@ -341,7 +313,7 @@ export default function FinishedVideoPreview({
const [inView, setInView] = useState(false) const [inView, setInView] = useState(false)
const [nearView, setNearView] = useState(false) const [nearView, setNearView] = useState(false)
// ✅ sobald einmal im Viewport gewesen -> true (damit wir danach nicht wieder entladen) // ✅ sobald einmal im Viewport gewesen -> true (damit wir danach nicht wieder entladen)
const [everInView, setEverInView] = useState(false) const [everInView, setEverInView] = useState(false)
// Tick nur für frames-Mode // Tick nur für frames-Mode
@ -350,6 +322,15 @@ export default function FinishedVideoPreview({
// Hover-State (für inline hover ODER teaser hover) // Hover-State (für inline hover ODER teaser hover)
const [hovered, setHovered] = useState(false) const [hovered, setHovered] = useState(false)
const [pageVisible, setPageVisible] = useState(() => {
if (typeof document === 'undefined') return true
return !document.hidden
})
const effectiveInView = forceActive || inView
const effectiveNearView = forceActive || nearView
const effectiveEverInView = forceActive || everInView
const inlineMode: 'never' | 'always' | 'hover' = const inlineMode: 'never' | 'always' | 'hover' =
inlineVideo === true || inlineVideo === 'always' ? 'always' : inlineVideo === 'hover' ? 'hover' : 'never' inlineVideo === true || inlineVideo === 'always' ? 'always' : inlineVideo === 'hover' ? 'hover' : 'never'
@ -374,6 +355,68 @@ 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 setTeaserPlaybackRate = useCallback((rate: number) => {
const el = teaserMp4Ref.current
if (!el) return
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 {
el.pause()
} catch {}
try {
el.muted = nextMuted
el.defaultMuted = nextMuted
el.playsInline = true
el.loop = true
el.autoplay = true
el.currentTime = 0
} catch {}
const p = el.play?.()
if (p && typeof (p as any).catch === 'function') {
;(p as Promise<void>).catch(() => {})
}
return true
}, [])
useEffect(() => {
return () => {
setTeaserPlaybackRate(1)
}
}, [setTeaserPlaybackRate])
useEffect(() => {
if (!registerTeaserPlay) return
registerTeaserPlay(() => {
teaserSoundForcedRef.current = true
return playTeaserFromGesture(true)
})
return () => registerTeaserPlay(null)
}, [registerTeaserPlay, playTeaserFromGesture])
const teaserPlayRetryTimerRef = useRef<number | null>(null)
const teaserLastProgressTimeRef = useRef<number>(0)
const lastMountedRef = useRef<{ const lastMountedRef = useRef<{
inline: HTMLVideoElement | null inline: HTMLVideoElement | null
@ -477,14 +520,41 @@ export default function FinishedVideoPreview({
} catch {} } catch {}
} }
useEffect(() => {
return () => {
if (teaserPlayRetryTimerRef.current != null) {
window.clearTimeout(teaserPlayRetryTimerRef.current)
teaserPlayRetryTimerRef.current = null
}
}
}, [])
useEffect(() => {
if (typeof document === 'undefined') return
const onVisibilityChange = () => {
setPageVisible(!document.hidden)
}
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('pageshow', onVisibilityChange)
window.addEventListener('focus', onVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('pageshow', onVisibilityChange)
window.removeEventListener('focus', onVisibilityChange)
}
}, [])
useEffect(() => { useEffect(() => {
setTeaserReady(false) setTeaserReady(false)
setTeaserOk(true) setTeaserOk(true)
setFetchedMeta(null) // ✅ neue Datei/Asset -> alten fallback-meta cache in state verwerfen
// ✅ 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])
@ -560,14 +630,14 @@ export default function FinishedVideoPreview({
}, [teaserPreloadEnabled, teaserPreloadRootMargin, inView]) }, [teaserPreloadEnabled, teaserPreloadRootMargin, inView])
// --- Tick für "frames" // --- Tick für "frames"
useEffect(() => { useEffect(() => {
if (!animated) return if (!animated) return
if (animatedMode !== 'frames') return if (animatedMode !== 'frames') return
if (!inView || document.hidden) return if (!effectiveInView || !pageVisible) return
const id = window.setInterval(() => setLocalTick((t) => t + 1), autoTickMs) const id = window.setInterval(() => setLocalTick((t) => t + 1), autoTickMs)
return () => window.clearInterval(id) return () => window.clearInterval(id)
}, [animated, animatedMode, inView, autoTickMs]) }, [animated, animatedMode, effectiveInView, pageVisible, autoTickMs])
// --- Thumbnail time (nur frames!) // --- Thumbnail time (nur frames!)
const thumbTimeSec = useMemo(() => { const thumbTimeSec = useMemo(() => {
@ -606,30 +676,43 @@ export default function FinishedVideoPreview({
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}${noGen}&v=${v}` return `/api/generated/teaser?id=${encodeURIComponent(previewId)}${noGen}&v=${v}`
}, [previewId, v, noGenerateTeaser]) }, [previewId, v, noGenerateTeaser])
// --- Inline Video sichtbar? // --- Inline Video sichtbar?
const showingInlineVideo = const showingInlineVideo =
inlineMode !== 'never' && inView && videoOk && (inlineMode === 'always' || (inlineMode === 'hover' && hovered)) inlineMode !== 'never' &&
effectiveInView &&
videoOk &&
(inlineMode === 'always' || (inlineMode === 'hover' && hovered))
// --- Teaser/Clips aktiv? (inView, nicht inline, optional nur hover) // --- Teaser/Clips aktiv? (sichtbar/erzwungen aktiv, nicht inline, optional nur hover)
const teaserActive = const teaserActive =
animated && animated &&
inView && effectiveInView &&
!document.hidden && pageVisible &&
videoOk && videoOk &&
!showingInlineVideo && !showingInlineVideo &&
(animatedTrigger === 'always' || hovered) && (animatedTrigger === 'always' || hovered) &&
((animatedMode === 'teaser' && teaserOk && Boolean(teaserSrc)) || (animatedMode === 'clips' && hasDuration)) (
(animatedMode === 'teaser' && teaserOk && Boolean(teaserSrc)) ||
(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
// ✅ Still-Bild: optional immer laden (entkoppelt vom inView-Gating) // ✅ Still-Bild: optional immer laden (entkoppelt vom inView-Gating)
const shouldLoadStill = alwaysLoadStill || inView || everInView || (wantsHover && hovered) const shouldLoadStill =
forceActive || alwaysLoadStill || effectiveInView || effectiveEverInView || (wantsHover && hovered)
// ✅ Teaser/Clips "vorwärmen": schon in nearView erlauben // ✅ Teaser/Clips "vorwärmen": schon in nearView erlauben
const shouldPreloadAnimatedAssets = nearView || inView || everInView || (wantsHover && hovered) const shouldPreloadAnimatedAssets =
forceActive || effectiveNearView || effectiveInView || effectiveEverInView || (wantsHover && hovered)
// ✅ Wenn meta.json schon alles hat: sofort Callbacks auslösen (kein hidden <video> nötig) // ✅ Wenn meta.json schon alles hat: sofort Callbacks auslösen (kein hidden <video> nötig)
// aber pro Datei/Wert nur 1x (verhindert doppelte Parent-Requests) // aber pro Datei/Wert nur 1x (verhindert doppelte Parent-Requests)
@ -662,63 +745,6 @@ export default function FinishedVideoPreview({
if (did) setMetaLoaded(true) if (did) setMetaLoaded(true)
}, [file, job, onDuration, onResolution, hasDuration, hasResolution, effectiveDurationSec, effectiveW, effectiveH]) }, [file, job, onDuration, onResolution, hasDuration, hasResolution, effectiveDurationSec, effectiveW, effectiveH])
// ✅ meta fallback fetch: nur wenn previewClips fehlen (für teaser-sprünge)
// und pro Datei in dieser Komponenteninstanz nur 1x
useEffect(() => {
if (!previewId) return
if (!file) return
if (!animated || animatedMode !== 'teaser') return
if (!shouldPreloadAnimatedAssets) return
// ✅ wenn wir schon previewClips aus job.meta ODER fetchedMeta haben -> kein Request
if (hasPreviewClipsInAnyMeta) return
// ✅ gleicher file-Request in dieser Instanz schon gemacht -> nicht nochmal feuern
if (fetchedMetaFilesRef.current.has(file)) return
let aborted = false
const ctrl = new AbortController()
const tryFetch = async (url: string) => {
try {
const res = await fetch(url, {
signal: ctrl.signal,
cache: 'no-store',
credentials: 'include',
})
if (!res.ok) return null
return await res.json()
} catch {
return null
}
}
;(async () => {
const byFile = await tryFetch(`/api/record/done/meta?file=${encodeURIComponent(file)}`)
if (aborted) return
// ✅ als "versucht" markieren (auch wenn null), damit nearView/inView/hover nicht spammt
fetchedMetaFilesRef.current.add(file)
if (byFile) {
setFetchedMeta(byFile)
}
})()
return () => {
aborted = true
ctrl.abort()
}
}, [
previewId,
file,
animated,
animatedMode,
shouldPreloadAnimatedAssets,
hasPreviewClipsInAnyMeta,
])
// ✅ Fallback: nur wenn wir wirklich noch nichts haben, über loadedmetadata nachladen // ✅ Fallback: nur wenn wir wirklich noch nichts haben, über loadedmetadata nachladen
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => { const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
setMetaLoaded(true) setMetaLoaded(true)
@ -784,9 +810,9 @@ export default function FinishedVideoPreview({
? clipsRef ? clipsRef
: null : null
const showProgressBar = const showProgressBar =
Boolean(progressVideoRef) && Boolean(progressVideoRef) &&
inView effectiveInView
const progressKind: ProgressKind = const progressKind: ProgressKind =
showingInlineVideo ? 'inline' : teaserActive && animatedMode === 'teaser' ? 'teaser' : 'clips' showingInlineVideo ? 'inline' : teaserActive && animatedMode === 'teaser' ? 'teaser' : 'clips'
@ -888,18 +914,226 @@ export default function FinishedVideoPreview({
if (!vv) return if (!vv) return
const active = teaserActive && animatedMode === 'teaser' const active = teaserActive && animatedMode === 'teaser'
if (!active) { if (!active) {
setTeaserPlaybackRate(1)
}
const clearRetryTimer = () => {
if (teaserPlayRetryTimerRef.current != null) {
window.clearTimeout(teaserPlayRetryTimerRef.current)
teaserPlayRetryTimerRef.current = null
}
}
if (!active) {
clearRetryTimer()
try { try {
vv.pause() vv.pause()
} catch {} } catch {}
return return
} }
applyInlineVideoPolicy(vv, { muted }) let cancelled = false
const p = vv.play?.() const tryPlay = (opts?: { resetToStart?: boolean }) => {
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {}) if (cancelled) return
}, [teaserActive, animatedMode, teaserSrc, muted])
const el = teaserMp4Ref.current
if (!el) {
return
}
const effectiveMuted = teaserSoundForcedRef.current ? false : muted
applyInlineVideoPolicy(el, { muted: effectiveMuted })
try {
el.muted = effectiveMuted
el.defaultMuted = effectiveMuted
el.playsInline = true
el.loop = true
el.autoplay = true
el.playbackRate = teaserSpeedHold ? 2 : 1
el.defaultPlaybackRate = teaserSpeedHold ? 2 : 1
} catch {}
if (opts?.resetToStart) {
try {
el.currentTime = 0
} catch {}
}
const p = el.play?.()
if (p && typeof (p as any).catch === 'function') {
;(p as Promise<void>).catch(() => {})
}
}
const scheduleRetry = (delayMs: number, resetToStart = false) => {
clearRetryTimer()
teaserPlayRetryTimerRef.current = window.setTimeout(() => {
teaserPlayRetryTimerRef.current = null
tryPlay({ resetToStart })
}, delayMs)
}
const onPlaying = () => {
teaserLastProgressTimeRef.current = performance.now()
setTeaserReady(true)
clearRetryTimer()
}
const onTimeUpdate = () => {
teaserLastProgressTimeRef.current = performance.now()
setTeaserReady(true)
}
const onLoadedData = () => {
setTeaserReady(true)
tryPlay()
scheduleRetry(120)
}
const onCanPlay = () => {
tryPlay()
scheduleRetry(120)
}
const onPause = () => {
if (cancelled) return
const el = teaserMp4Ref.current
if (!el) return
if (!active) return
if (el.ended) return
// iOS-Fall: erster Frame sichtbar, aber Playback stoppt direkt wieder
scheduleRetry(120)
}
const onWaiting = () => {
if (cancelled) return
scheduleRetry(120)
}
vv.addEventListener('playing', onPlaying)
vv.addEventListener('timeupdate', onTimeUpdate)
vv.addEventListener('loadeddata', onLoadedData)
vv.addEventListener('canplay', onCanPlay)
vv.addEventListener('pause', onPause)
vv.addEventListener('waiting', onWaiting)
// frischer Start für neue TopCard
tryPlay({ resetToStart: true })
const raf1 = requestAnimationFrame(() => {
tryPlay()
const raf2 = requestAnimationFrame(() => {
tryPlay()
})
;(tryPlay as any).__raf2 = raf2
})
// Watchdog: falls currentTime nicht vorläuft, nochmal anstoßen
const watchdog = window.setInterval(() => {
if (cancelled) return
const el = teaserMp4Ref.current
if (!el) return
if (!active) return
const now = performance.now()
const stalledFor = now - teaserLastProgressTimeRef.current
const ct = Number(el.currentTime)
if ((!Number.isFinite(ct) || ct <= 0.05) && stalledFor > 180) {
tryPlay({ resetToStart: true })
return
}
if (el.paused && !el.ended && stalledFor > 180) {
tryPlay()
}
}, 180)
return () => {
cancelled = true
clearRetryTimer()
vv.removeEventListener('playing', onPlaying)
vv.removeEventListener('timeupdate', onTimeUpdate)
vv.removeEventListener('loadeddata', onLoadedData)
vv.removeEventListener('canplay', onCanPlay)
vv.removeEventListener('pause', onPause)
vv.removeEventListener('waiting', onWaiting)
cancelAnimationFrame(raf1)
const raf2 = (tryPlay as any).__raf2
if (typeof raf2 === 'number') cancelAnimationFrame(raf2)
window.clearInterval(watchdog)
}
}, [teaserActive, animatedMode, teaserSrc, muted, pageVisible])
useEffect(() => {
if (!activationNonce) return
if (!teaserActive) return
if (animatedMode !== 'teaser') return
if (showingInlineVideo) return
const vv = teaserMp4Ref.current
if (!vv) return
let cancelled = false
let retryTimer: number | null = null
const tryRestart = (resetToStart = false) => {
if (cancelled) return
const el = teaserMp4Ref.current
if (!el) {
return
}
const effectiveMuted = teaserSoundForcedRef.current ? false : muted
applyInlineVideoPolicy(el, { muted: effectiveMuted })
try {
el.muted = effectiveMuted
el.defaultMuted = effectiveMuted
el.playsInline = true
el.loop = true
el.autoplay = true
} catch {}
if (resetToStart) {
try {
el.pause()
} catch {}
try {
el.currentTime = 0
} catch {}
}
const p = el.play?.()
if (p && typeof (p as any).catch === 'function') {
;(p as Promise<void>).catch(() => {})
}
}
retryTimer = window.setTimeout(() => {
tryRestart(true)
requestAnimationFrame(() => {
tryRestart()
requestAnimationFrame(() => {
tryRestart()
})
})
}, 40)
return () => {
cancelled = true
if (retryTimer != null) {
window.clearTimeout(retryTimer)
}
}
}, [activationNonce, teaserActive, animatedMode, showingInlineVideo, muted])
// ▶️ Progressbar: global relativ zur Vollvideo-Dauer (mit mapping => Sprünge) // ▶️ Progressbar: global relativ zur Vollvideo-Dauer (mit mapping => Sprünge)
useEffect(() => { useEffect(() => {
@ -1008,7 +1242,7 @@ export default function FinishedVideoPreview({
// ✅ brauchen wir noch hidden-metadata-load? // ✅ brauchen wir noch hidden-metadata-load?
const needHiddenMeta = const needHiddenMeta =
(nearView || inView) && (forceActive || effectiveNearView || effectiveInView) &&
(onDuration || onResolution) && (onDuration || onResolution) &&
!metaLoaded && !metaLoaded &&
!showingInlineVideo && !showingInlineVideo &&
@ -1024,9 +1258,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}
@ -1041,7 +1288,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)}
/> />
) : ( ) : (
@ -1100,7 +1356,7 @@ export default function FinishedVideoPreview({
teaserMp4Ref.current = el teaserMp4Ref.current = el
bumpMountTickIfNew('teaser', el) bumpMountTickIfNew('teaser', el)
}} }}
key={`teaser-mp4-${previewId}`} key={`teaser-mp4-${previewId}-${forceActive ? 'force' : 'normal'}-${activationNonce}`}
src={teaserSrc} src={teaserSrc}
className={[ className={[
'absolute inset-0 w-full h-full object-cover pointer-events-none', 'absolute inset-0 w-full h-full object-cover pointer-events-none',
@ -1110,14 +1366,12 @@ export default function FinishedVideoPreview({
] ]
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
muted={muted} muted={teaserSoundForcedRef.current ? false : muted}
playsInline playsInline
autoPlay autoPlay
loop loop
preload={teaserReady ? 'auto' : 'metadata'} preload={teaserReady ? 'auto' : 'metadata'}
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined} poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
onLoadedData={() => setTeaserReady(true)}
onPlaying={() => setTeaserReady(true)}
onError={() => { onError={() => {
setTeaserOk(false) setTeaserOk(false)
setTeaserReady(false) setTeaserReady(false)
@ -1143,6 +1397,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

@ -142,7 +142,7 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
createPortal( createPortal(
<div <div
ref={popoverRef} ref={popoverRef}
className="fixed z-50" className="fixed z-[120]"
style={{ style={{
left: pos?.left ?? -9999, left: pos?.left ?? -9999,
top: pos?.top ?? -9999, top: pos?.top ?? -9999,

View File

@ -56,7 +56,7 @@ export default function LabeledSwitch({
// With left label and description Beispiel // With left label and description Beispiel
return ( return (
<div className={clsx('flex items-center justify-between', className)}> <div className={clsx('flex items-center justify-between', className)}>
<span className="flex grow flex-col"> <span className="flex grow flex-col pr-2">
<label id={labelId} htmlFor={switchId} className="text-sm/6 font-medium text-gray-900 dark:text-white"> <label id={labelId} htmlFor={switchId} className="text-sm/6 font-medium text-gray-900 dark:text-white">
{label} {label}
</label> </label>

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,
@ -112,30 +112,6 @@ export default function Modal({
useEffect(() => { useEffect(() => {
if (!open) return 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(() => {
if (!open) return
// reset when opening // reset when opening
setMobileCollapsed(false) setMobileCollapsed(false)
}, [open]) }, [open])
@ -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}
@ -2000,8 +1936,6 @@ export default function ModelDetails({
pageSize={DONE_PAGE_SIZE} pageSize={DONE_PAGE_SIZE}
totalItems={doneTotalCount} totalItems={doneTotalCount}
onPageChange={(p) => setDonePage(p)} onPageChange={(p) => setDonePage(p)}
siblingCount={1}
boundaryCount={1}
showSummary={true} showSummary={true}
prevLabel="Zurück" prevLabel="Zurück"
nextLabel="Weiter" nextLabel="Weiter"
@ -2062,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)
@ -2214,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>
@ -2403,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)
@ -2576,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,57 +366,58 @@ export default function ModelPreview({
e.stopPropagation() e.stopPropagation()
}} }}
> >
{!apiImgError ? ( {shouldRenderPreview ? (
<img !apiImgError ? (
src={currentImgSrc} <img
loading={inView ? 'eager' : 'lazy'} src={currentImgSrc}
fetchPriority={inView ? 'high' : 'auto'} loading="eager"
decoding="async" fetchPriority="high"
alt="" decoding="async"
className={['block w-full h-full object-cover object-center', blurCls].filter(Boolean).join(' ')} alt=""
onLoad={() => { className={['block w-full h-full object-cover object-center', blurCls].filter(Boolean).join(' ')}
hadSuccess.current = true onLoad={() => {
fastTries.current = 0 hadSuccess.current = true
if (retryT.current) window.clearTimeout(retryT.current) fastTries.current = 0
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={() => { if (useDirectThumb) {
// 1) Wenn direkte preview.jpg fehlschlägt -> auf API-Fallback umschalten setDirectImgError(true)
if (useDirectThumb) { return
setDirectImgError(true) }
return
}
// 2) API-Fallback fehlschlägt -> bisherige Retry-Logik setApiImgError(true)
setApiImgError(true)
if (!fastRetryMs) return if (!fastRetryMs) return
if (!inViewRef.current || !pageVisibleRef.current) return if (!inViewRef.current || !pageVisibleRef.current) return
if (hadSuccess.current) return if (hadSuccess.current) return
const startMs = alignStartAt ? toMs(alignStartAt) : NaN const startMs = alignStartAt ? toMs(alignStartAt) : NaN
const windowMs = Number(fastRetryWindowMs ?? 60_000) const windowMs = Number(fastRetryWindowMs ?? 60_000)
const withinWindow = !Number.isFinite(startMs) || Date.now() - startMs < windowMs const withinWindow = !Number.isFinite(startMs) || Date.now() - startMs < windowMs
if (!withinWindow) return if (!withinWindow) return
const max = Number(fastRetryMax ?? 25) const max = Number(fastRetryMax ?? 25)
if (fastTries.current >= max) return if (fastTries.current >= max) return
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)
}} }}
/> />
) : (
<div className="absolute inset-0 grid place-items-center px-1 text-center text-[10px] text-gray-500 dark:text-gray-400">
keine Vorschau
</div>
)
) : ( ) : (
<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 bg-gray-100 dark:bg-white/5" />
keine Vorschau
</div>
)} )}
</div> </div>
</HoverPopover> </HoverPopover>

View File

@ -1,10 +1,16 @@
// frontend\src\components\ui\Pagination.tsx
'use client' 'use client'
import * as React from 'react' import * as React from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/20/solid' import {
ChevronLeftIcon,
type PageItem = number | 'ellipsis' ChevronRightIcon,
ChevronDoubleLeftIcon,
ChevronDoubleRightIcon,
ChevronDownIcon,
} from '@heroicons/react/20/solid'
export type PaginationProps = { export type PaginationProps = {
page: number // 1-based page: number // 1-based
@ -12,106 +18,27 @@ export type PaginationProps = {
totalItems: number totalItems: number
onPageChange: (page: number) => void onPageChange: (page: number) => void
/** wie viele Seiten links/rechts neben aktueller Seite */
siblingCount?: number
/** wie viele Seiten am Anfang/Ende immer gezeigt werden */
boundaryCount?: number
/** Summary "Showing x to y of z" anzeigen */
showSummary?: boolean showSummary?: boolean
/** Wrapper Klassen */
className?: string className?: string
/** Labels (optional) */
ariaLabel?: string ariaLabel?: string
prevLabel?: string prevLabel?: string
nextLabel?: string nextLabel?: string
firstLabel?: string
lastLabel?: string
} }
function clamp(n: number, min: number, max: number) { function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n)) return Math.max(min, Math.min(max, n))
} }
function range(start: number, end: number): number[] {
const out: number[] = []
for (let i = start; i <= end; i++) out.push(i)
return out
}
function getPageItems(
totalPages: number,
current: number,
boundaryCount: number,
siblingCount: number
): PageItem[] {
if (totalPages <= 1) return [1]
const first = 1
const last = totalPages
const startPages = range(first, Math.min(boundaryCount, last))
const endPages = range(Math.max(last - boundaryCount + 1, boundaryCount + 1), last)
const siblingsStart = Math.max(
Math.min(
current - siblingCount,
last - boundaryCount - siblingCount * 2 - 1
),
boundaryCount + 1
)
const siblingsEnd = Math.min(
Math.max(
current + siblingCount,
boundaryCount + siblingCount * 2 + 2
),
last - boundaryCount
)
const items: PageItem[] = []
// start
items.push(...startPages)
// left gap
if (siblingsStart > boundaryCount + 1) {
items.push('ellipsis')
} else if (boundaryCount + 1 < last - boundaryCount) {
items.push(boundaryCount + 1)
}
// siblings
items.push(...range(siblingsStart, siblingsEnd))
// right gap
if (siblingsEnd < last - boundaryCount) {
items.push('ellipsis')
} else if (last - boundaryCount > boundaryCount) {
items.push(last - boundaryCount)
}
// end
items.push(...endPages)
// dedupe + keep order
const seen = new Set<string>()
return items.filter((x) => {
const k = String(x)
if (seen.has(k)) return false
seen.add(k)
return true
})
}
function PageButton({ function PageButton({
active,
disabled, disabled,
rounded, rounded,
onClick, onClick,
children, children,
title, title,
}: { }: {
active?: boolean
disabled?: boolean disabled?: boolean
rounded?: 'l' | 'r' | 'none' rounded?: 'l' | 'r' | 'none'
onClick?: () => void onClick?: () => void
@ -128,14 +55,12 @@ function PageButton({
onClick={disabled ? undefined : onClick} onClick={disabled ? undefined : onClick}
title={title} title={title}
className={clsx( className={clsx(
'relative inline-flex items-center px-4 py-2 text-sm font-semibold focus:z-20 focus:outline-offset-0', 'relative inline-flex h-10 min-w-10 items-center justify-center px-3 text-sm font-semibold focus:z-20 focus:outline-offset-0',
roundedCls, roundedCls,
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer', disabled ? 'opacity-100 cursor-not-allowed' : 'cursor-pointer',
active 'bg-white text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50',
? 'z-10 !bg-indigo-600 !text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:focus-visible:outline-indigo-500' 'dark:bg-slate-800 dark:text-gray-200 dark:inset-ring-slate-700 dark:hover:bg-slate-700'
: 'text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50 dark:text-gray-200 dark:inset-ring-gray-700 dark:hover:bg-white/5'
)} )}
aria-current={active ? 'page' : undefined}
> >
{children} {children}
</button> </button>
@ -147,15 +72,18 @@ export default function Pagination({
pageSize, pageSize,
totalItems, totalItems,
onPageChange, onPageChange,
siblingCount = 1, showSummary = false,
boundaryCount = 1,
showSummary = true,
className, className,
ariaLabel = 'Pagination', ariaLabel = 'Pagination',
prevLabel = 'Previous', prevLabel = 'Vorherige Seite',
nextLabel = 'Next', nextLabel = 'Nächste Seite',
firstLabel = 'Erste Seite',
lastLabel = 'Letzte Seite',
}: PaginationProps) { }: PaginationProps) {
const totalPages = Math.max(1, Math.ceil((totalItems || 0) / Math.max(1, pageSize || 1))) const totalPages = Math.max(
1,
Math.ceil((totalItems || 0) / Math.max(1, pageSize || 1))
)
const current = clamp(page || 1, 1, totalPages) const current = clamp(page || 1, 1, totalPages)
if (totalPages <= 1) return null if (totalPages <= 1) return null
@ -163,99 +91,96 @@ export default function Pagination({
const from = totalItems === 0 ? 0 : (current - 1) * pageSize + 1 const from = totalItems === 0 ? 0 : (current - 1) * pageSize + 1
const to = Math.min(current * pageSize, totalItems) const to = Math.min(current * pageSize, totalItems)
const items = getPageItems(totalPages, current, boundaryCount, siblingCount)
const go = (p: number) => onPageChange(clamp(p, 1, totalPages)) const go = (p: number) => onPageChange(clamp(p, 1, totalPages))
return ( return (
<div <div
className={clsx( className={clsx(
'flex items-center justify-between bg-transparent dark:border-white/10', 'flex items-center justify-center bg-transparent dark:border-white/10',
className className
)} )}
> >
{/* Mobile: nur Previous/Next */} <div className="flex flex-col items-center gap-2">
<div className="flex flex-1 justify-between sm:hidden"> {showSummary ? (
<button <p className="text-sm text-gray-700 dark:text-gray-300 px-2">
type="button" Showing <span className="font-medium">{from}</span> to{' '}
onClick={() => go(current - 1)} <span className="font-medium">{to}</span> of{' '}
disabled={current <= 1} <span className="font-medium">{totalItems}</span> results
className="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:border-white/10 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10" </p>
> ) : null}
{prevLabel}
</button>
<button
type="button"
onClick={() => go(current + 1)}
disabled={current >= totalPages}
className="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:border-white/10 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10"
>
{nextLabel}
</button>
</div>
{/* Desktop: Summary + Zahlen */} <nav
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between"> aria-label={ariaLabel}
<div> className="isolate inline-flex -space-x-px rounded-md shadow-xs dark:shadow-none"
{showSummary ? ( >
<p className="text-sm text-gray-700 dark:text-gray-300 px-2"> <PageButton
Showing <span className="font-medium">{from}</span> to{' '} rounded="l"
<span className="font-medium">{to}</span> of{' '} onClick={() => go(1)}
<span className="font-medium">{totalItems}</span> results disabled={current <= 1}
</p> title={firstLabel}
) : null}
</div>
<div>
<nav
aria-label={ariaLabel}
className="isolate inline-flex -space-x-px rounded-md shadow-xs dark:shadow-none"
> >
<button <span className="sr-only">{firstLabel}</span>
type="button" <ChevronDoubleLeftIcon aria-hidden="true" className="size-5" />
onClick={() => go(current - 1)} </PageButton>
disabled={current <= 1}
className="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 inset-ring inset-ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed dark:inset-ring-gray-700 dark:hover:bg-white/5"
>
<span className="sr-only">{prevLabel}</span>
<ChevronLeftIcon aria-hidden="true" className="size-5" />
</button>
{items.map((it, idx) => { <PageButton
if (it === 'ellipsis') { rounded="none"
onClick={() => go(current - 1)}
disabled={current <= 1}
title={prevLabel}
>
<span className="sr-only">{prevLabel}</span>
<ChevronLeftIcon aria-hidden="true" className="size-5" />
</PageButton>
<div className="relative">
<select
value={current}
onChange={(e) => go(Number(e.target.value))}
aria-label="Seite auswählen"
className={clsx(
'relative h-10 appearance-none bg-white pl-4 pr-10 text-sm font-semibold',
'text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0',
'dark:bg-slate-800 dark:text-gray-200 dark:inset-ring-slate-700 dark:hover:bg-slate-700',
'min-w-[112px] cursor-pointer'
)}
>
{Array.from({ length: totalPages }, (_, i) => {
const p = i + 1
return ( return (
<span <option key={p} value={p} className="text-black dark:text-white">
key={`e-${idx}`} {p} von {totalPages}
className="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 inset-ring inset-ring-gray-300 dark:text-gray-400 dark:inset-ring-gray-700" </option>
>
</span>
) )
} })}
</select>
return ( <ChevronDownIcon
<PageButton aria-hidden="true"
key={it} className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-gray-500 dark:text-gray-400"
active={it === current} />
onClick={() => go(it)} </div>
rounded="none"
>
{it}
</PageButton>
)
})}
<button <PageButton
type="button" rounded="none"
onClick={() => go(current + 1)} onClick={() => go(current + 1)}
disabled={current >= totalPages} disabled={current >= totalPages}
className="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 inset-ring inset-ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed dark:inset-ring-gray-700 dark:hover:bg-white/5" title={nextLabel}
> >
<span className="sr-only">{nextLabel}</span> <span className="sr-only">{nextLabel}</span>
<ChevronRightIcon aria-hidden="true" className="size-5" /> <ChevronRightIcon aria-hidden="true" className="size-5" />
</button> </PageButton>
</nav>
</div> <PageButton
rounded="r"
onClick={() => go(totalPages)}
disabled={current >= totalPages}
title={lastLabel}
>
<span className="sr-only">{lastLabel}</span>
<ChevronDoubleRightIcon aria-hidden="true" className="size-5" />
</PageButton>
</nav>
</div> </div>
</div> </div>
) )

View File

@ -20,6 +20,7 @@ import RecordJobActions from './RecordJobActions'
import Button from './Button' import Button from './Button'
import { apiUrl, apiFetch } from '../../lib/api' import { apiUrl, apiFetch } from '../../lib/api'
import LiveVideo from './LiveVideo' import LiveVideo from './LiveVideo'
import { formatResolution, formatFps } from './formatters'
const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || '' const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || ''
const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s) const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
@ -103,17 +104,6 @@ const pickNum = (...vals: any[]): number | null => {
return null return null
} }
function formatFps(n?: number | null): string {
if (!n || !Number.isFinite(n)) return '—'
const digits = n >= 10 ? 0 : 2
return `${n.toFixed(digits)} fps`
}
function formatResolution(h?: number | null): string {
if (!h || !Number.isFinite(h)) return '—'
return `${Math.round(h)}p`
}
function parseDateFromOutput(output?: string): Date | null { function parseDateFromOutput(output?: string): Date | null {
const fileRaw = baseName(output || '') const fileRaw = baseName(output || '')
const file = stripHotPrefix(fileRaw) const file = stripHotPrefix(fileRaw)
@ -432,19 +422,31 @@ export default function Player({
setPreviewSrc(previewA) setPreviewSrc(previewA)
}, [previewA]) }, [previewA])
const videoH = React.useMemo(
() => pickNum(metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height),
[metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height]
)
const fps = React.useMemo( const fps = React.useMemo(
() => pickNum(metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate), () => pickNum(metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate),
[metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate] [metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate]
) )
const [intrH, setIntrH] = React.useState<number | null>(null) const [intrSize, setIntrSize] = React.useState<{ w: number; h: number } | null>(null)
const resolutionLabel = React.useMemo(() => formatResolution(intrH ?? videoH), [intrH, videoH]) const videoW = React.useMemo(
() => pickNum(anyJob.videoWidth, anyJob.width, anyJob.meta?.width),
[anyJob.videoWidth, anyJob.width, anyJob.meta?.width]
)
const videoH = React.useMemo(
() => pickNum(metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height),
[metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height]
)
const resolutionLabel = React.useMemo(
() =>
formatResolution(
intrSize ??
(videoW && videoH ? { w: videoW, h: videoH } : null)
),
[intrSize, videoW, videoH]
)
const fpsLabel = React.useMemo(() => formatFps(fps), [fps]) const fpsLabel = React.useMemo(() => formatFps(fps), [fps])
React.useEffect(() => { React.useEffect(() => {
@ -541,9 +543,15 @@ export default function Player({
const updateIntrinsicDims = React.useCallback(() => { const updateIntrinsicDims = React.useCallback(() => {
const p: any = playerRef.current const p: any = playerRef.current
if (!p || p.isDisposed?.()) return if (!p || p.isDisposed?.()) return
const w = typeof p.videoWidth === 'function' ? p.videoWidth() : 0
const h = typeof p.videoHeight === 'function' ? p.videoHeight() : 0 const h = typeof p.videoHeight === 'function' ? p.videoHeight() : 0
if (typeof h === 'number' && h > 0 && Number.isFinite(h)) {
setIntrH(h) if (
typeof w === 'number' && Number.isFinite(w) && w > 0 &&
typeof h === 'number' && Number.isFinite(h) && h > 0
) {
setIntrSize({ w, h })
} }
}, []) }, [])
@ -1199,7 +1207,7 @@ export default function Player({
const h = const h =
pickNum( pickNum(
intrH, intrSize?.h,
(playerRef.current as any)?.videoHeight?.(), (playerRef.current as any)?.videoHeight?.(),
anyJob.videoHeight, anyJob.videoHeight,
anyJob.height, anyJob.height,
@ -1211,7 +1219,7 @@ export default function Player({
// Fallback wenn nur Höhe bekannt ist // Fallback wenn nur Höhe bekannt ist
// (lieber stabiler fallback als kaputt) // (lieber stabiler fallback als kaputt)
return 16 / 9 return 16 / 9
}, [job, intrH]) }, [job, intrSize])
const clampMiniRect = React.useCallback( const clampMiniRect = React.useCallback(
(r: { x: number; y: number; w: number; h: number }) => { (r: { x: number; y: number; w: number; h: number }) => {
@ -1780,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

@ -60,7 +60,6 @@ const DEFAULTS: RecorderSettings = {
type Props = { type Props = {
onAssetsGenerated?: () => void onAssetsGenerated?: () => void
onTaskRunningChange?: (running: boolean) => void
} }
function shortTaskFilename(name?: string, max = 52) { function shortTaskFilename(name?: string, max = 52) {
@ -70,7 +69,16 @@ function shortTaskFilename(name?: string, max = 52) {
return '…' + s.slice(-(max - 1)) return '…' + s.slice(-(max - 1))
} }
export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChange }: Props) { function regenerateTaskId(file: string) {
return `regenerate-assets:${file}`
}
function regenerateTaskTitle(file: string) {
const short = shortTaskFilename(file, 44)
return short ? `Assets neu generieren · ${short}` : 'Assets neu generieren'
}
export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [value, setValue] = useState<RecorderSettings>(DEFAULTS) const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState<number>(0) const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState<number>(0)
@ -138,18 +146,11 @@ 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,
}) })
useEffect(() => { const [regenerateAssetsTasks, setRegenerateAssetsTasks] = useState<TaskItem[]>([])
const running =
assetsTask.status === 'running' ||
cleanupTask.status === 'running' ||
cleaning
onTaskRunningChange?.(running)
}, [assetsTask.status, cleanupTask.status, cleaning, onTaskRunningChange])
const pauseGB = Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB ?? 5) const pauseGB = Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB ?? 5)
const uiPauseGB = diskStatus?.pauseGB ?? pauseGB const uiPauseGB = diskStatus?.pauseGB ?? pauseGB
@ -205,6 +206,246 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
} }
}, []) }, [])
useEffect(() => {
let alive = true
const loadTasksStatus = async () => {
try {
const res = await fetch('/api/tasks/status', { cache: 'no-store' })
if (!res.ok) return
const data = await res.json().catch(() => null)
if (!alive || !data) return
const assets = data.generateAssets
if (assets?.running) {
setAssetsTask((t) => ({
...t,
status: 'running',
title: 'Assets generieren',
text: shortTaskFilename(String(assets.currentFile || assets.text || '')),
done: Number(assets.done ?? 0),
total: Number(assets.total ?? 0),
err: undefined,
cancellable: true,
fading: false,
}))
} else if (assets?.error) {
const assetsError = String(assets.error || '').trim()
const cancelled =
assetsError.toLowerCase() === 'abgebrochen' ||
assetsError.toLowerCase() === 'abbruch' ||
assetsError.toLowerCase() === 'cancelled' ||
assetsError.toLowerCase() === 'canceled'
setAssetsTask((t) => ({
...t,
status: cancelled ? 'cancelled' : 'error',
title: 'Assets generieren',
text: shortTaskFilename(String(assets.currentFile || assets.text || '')),
done: Number(assets.done ?? 0),
total: Number(assets.total ?? 0),
err: cancelled ? undefined : assetsError,
cancellable: false,
fading: false,
}))
} else if (assets?.text || assets?.finishedAt) {
setAssetsTask((t) => ({
...t,
status: 'done',
title: 'Assets generieren',
text: shortTaskFilename(String(assets.currentFile || assets.text || '')),
done: Number(assets.done ?? 0),
total: Number(assets.total ?? 0),
err: undefined,
cancellable: false,
fading: false,
}))
} else {
setAssetsTask((t) => ({
...t,
status: 'idle',
title: 'Assets generieren',
text: '',
done: 0,
total: 0,
err: undefined,
cancellable: true,
fading: false,
}))
}
const regenJobs = Array.isArray(data?.regenerateAssetsJobs)
? data.regenerateAssetsJobs
: []
const nextTasks: TaskItem[] = regenJobs
.map((job: any): TaskItem | null => {
const file = String(job?.file || '').trim()
if (!file) return null
const state = String(job?.state || '').trim().toLowerCase()
const error = String(job?.error || '').trim()
const cancelled =
state === 'cancelled' ||
error.toLowerCase() === 'abgebrochen' ||
error.toLowerCase() === 'abbruch' ||
error.toLowerCase() === 'cancelled' ||
error.toLowerCase() === 'canceled'
let status: TaskItem['status'] = 'idle'
if (state === 'queued' || state === 'running') {
status = 'running'
} else if (state === 'done') {
status = 'done'
} else if (state === 'error') {
status = 'error'
} else if (cancelled) {
status = 'cancelled'
}
const phases = Array.isArray(job?.phases) ? job.phases : []
const runningPhase = phases.find((p: any) => String(p?.state || '').toLowerCase() === 'running')
const queuedPhase = phases.find((p: any) => String(p?.state || '').toLowerCase() === 'queued')
const phaseLabel =
String(runningPhase?.label || '').trim() ||
String(queuedPhase?.label || '').trim()
const text =
status === 'running'
? phaseLabel
? `${shortTaskFilename(file, 38)} · ${phaseLabel}`
: shortTaskFilename(file, 52)
: shortTaskFilename(file, 52)
return {
id: regenerateTaskId(file),
status,
title: regenerateTaskTitle(file),
text,
err: status === 'error' ? error || 'Fehler beim Regenerieren.' : undefined,
cancellable: status === 'running',
fading: false,
}
})
.filter(Boolean) as TaskItem[]
nextTasks.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }))
setRegenerateAssetsTasks((prev) => {
const nextById = new Map(nextTasks.map((t) => [t.id, t]))
const prevById = new Map(prev.map((t) => [t.id, t]))
const merged: TaskItem[] = []
for (const next of nextTasks) {
const prevTask = prevById.get(next.id)
merged.push(
prevTask?.fading
? { ...next, fading: true }
: next
)
}
for (const prevTask of prev) {
if (nextById.has(prevTask.id)) continue
if (prevTask.status === 'running') continue
if (prevTask.fading) {
merged.push(prevTask)
continue
}
merged.push({ ...prevTask, fading: true })
const file = prevTask.id.startsWith('regenerate-assets:')
? prevTask.id.slice('regenerate-assets:'.length)
: ''
if (file) {
window.setTimeout(() => {
setRegenerateAssetsTasks((cur) => cur.filter((t) => t.id !== prevTask.id))
}, 500)
}
}
return merged.sort((a, b) =>
a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })
)
})
const cleanup = data.cleanup
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 (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 (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,
}))
}
} catch {
// ignore
}
}
void loadTasksStatus()
const timer = window.setInterval(loadTasksStatus, 1500)
return () => {
alive = false
window.clearInterval(timer)
}
}, [])
useEffect(() => { useEffect(() => {
let alive = true let alive = true
const load = async () => { const load = async () => {
@ -226,57 +467,6 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
} }
}, []) }, [])
useEffect(() => {
let alive = true
const loadTaskStatus = async () => {
try {
const res = await fetch('/api/tasks/generate-assets', { cache: 'no-store' })
if (!res.ok) return
const data = await res.json().catch(() => null)
if (!alive || !data) return
const running = Boolean(data.running)
const done = Number(data.done ?? 0)
const total = Number(data.total ?? 0)
const currentFile = shortTaskFilename(data.currentFile)
if (running) {
setAssetsTask((t) => ({
...t,
status: 'running',
title: 'Assets generieren',
text: currentFile || '',
done,
total,
err: undefined,
fading: false,
}))
} else {
setAssetsTask((t) => ({
...t,
status: 'idle',
title: 'Assets generieren',
text: '',
done: 0,
total: 0,
err: undefined,
fading: false,
}))
}
} catch {
// ignorieren
}
}
void loadTaskStatus()
return () => {
alive = false
}
}, [])
async function browse(target: 'record' | 'done' | 'ffmpeg') { async function browse(target: 'record' | 'done' | 'ffmpeg') {
setErr(null) setErr(null)
setMsg(null) setMsg(null)
@ -417,6 +607,20 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
}, delayMs) }, delayMs)
} }
function fadeOutRegenerateTask(file: string, delayMs = 3500, fadeMs = 500) {
const id = regenerateTaskId(file)
window.setTimeout(() => {
setRegenerateAssetsTasks((prev) =>
prev.map((t) => (t.id === id ? { ...t, fading: true } : t))
)
window.setTimeout(() => {
setRegenerateAssetsTasks((prev) => prev.filter((t) => t.id !== id))
}, fadeMs)
}, delayMs)
}
async function cleanupSmallDone() { async function cleanupSmallDone() {
setErr(null) setErr(null)
setMsg(null) setMsg(null)
@ -450,7 +654,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,
})) }))
@ -477,13 +681,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}`,
})) }))
fadeOutTask(setCleanupTask) // ✅ nach und nach ausfaden fadeOutTask(setCleanupTask, 7000, 500)
} catch (e: any) { } catch (e: any) {
const msg = e?.message ?? String(e) const msg = e?.message ?? String(e)
setErr(msg) setErr(msg)
@ -495,7 +699,7 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
err: msg, err: msg,
})) }))
fadeOutTask(setCleanupTask) fadeOutTask(setCleanupTask, 7000, 500)
} finally { } finally {
setCleaning(false) setCleaning(false)
} }
@ -521,6 +725,44 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
} }
} }
async function cancelRegenerateAssetsTask(file: string) {
const cleanFile = String(file || '').trim()
if (!cleanFile) return
setRegenerateAssetsTasks((prev) =>
prev.map((t) =>
t.id === regenerateTaskId(cleanFile)
? {
...t,
status: 'cancelled',
title: regenerateTaskTitle(cleanFile),
text: shortTaskFilename(cleanFile, 52),
err: undefined,
cancellable: false,
fading: false,
}
: t
)
)
try {
await fetch('/api/record/regenerate-assets', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store' as any,
body: JSON.stringify({ file: cleanFile }),
})
} catch {
// ignore
}
fadeOutRegenerateTask(cleanFile)
}
const visibleTasks = [assetsTask, ...regenerateAssetsTasks, cleanupTask].filter(
(t) => t.status !== 'idle'
)
return ( return (
<Card <Card
header={ header={
@ -553,9 +795,18 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
> >
<div className="space-y-4"> <div className="space-y-4">
<TaskList <TaskList
tasks={[assetsTask, cleanupTask]} // ✅ cleanupTask ist “am Ende” tasks={visibleTasks}
onCancel={(id: string) => { onCancel={(id: string) => {
if (id === 'generate-assets') cancelAssetsTask() if (id === 'generate-assets') {
void cancelAssetsTask()
return
}
if (id.startsWith('regenerate-assets:')) {
const file = id.slice('regenerate-assets:'.length)
void cancelRegenerateAssetsTask(file)
return
}
}} }}
/> />

View File

@ -56,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 = {
@ -109,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
) { ) {
@ -116,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)
@ -160,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
@ -170,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
@ -189,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) => {
@ -210,18 +239,19 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
dxRef.current = outDx dxRef.current = outDx
setDx(outDx) setDx(outDx)
let actionPromise: Promise<boolean | void>
if (runAction) {
actionPromise = Promise.resolve(dir === 'right' ? onSwipeRight() : onSwipeLeft()).catch(() => false)
} else {
actionPromise = Promise.resolve(true)
}
const animPromise = new Promise<void>((resolve) => { const animPromise = new Promise<void>((resolve) => {
window.setTimeout(resolve, commitMs) window.setTimeout(resolve, commitMs)
}) })
const [, ok] = await Promise.all([animPromise, actionPromise]) // Wichtig: erst rausanimieren lassen, dann Aktion starten
await animPromise
let ok: boolean | void = true
if (runAction) {
ok = await Promise.resolve(
dir === 'right' ? onSwipeRight() : onSwipeLeft()
).catch(() => false)
}
if (ok === false) { if (ok === false) {
setAnimMs(snapMs) setAnimMs(snapMs)
@ -373,10 +403,10 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
React.useEffect(() => { React.useEffect(() => {
return () => { return () => {
if (tapTimerRef.current != null) window.clearTimeout(tapTimerRef.current) clearTapTimer()
if (rafRef.current != null) cancelAnimationFrame(rafRef.current) clearPressTimer()
} }
}, []) }, [clearTapTimer, clearPressTimer])
React.useImperativeHandle( React.useImperativeHandle(
ref, ref,
@ -468,6 +498,30 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
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)
@ -492,6 +546,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
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
@ -502,6 +557,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
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
@ -517,6 +573,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
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)
@ -564,6 +621,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
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
@ -579,6 +639,12 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
;(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
@ -666,6 +732,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}} }}
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

View File

@ -35,6 +35,10 @@ export type Column<T> = {
sortFn?: (a: T, b: T) => number sortFn?: (a: T, b: T) => number
} }
type TableRowAttrs = React.HTMLAttributes<HTMLTableRowElement> & {
[key: `data-${string}`]: string | number | undefined
}
export type TableProps<T> = { export type TableProps<T> = {
columns: Array<Column<T>> columns: Array<Column<T>>
rows: T[] rows: T[]
@ -61,6 +65,9 @@ export type TableProps<T> = {
onRowClick?: (row: T) => void onRowClick?: (row: T) => void
onRowContextMenu?: (row: T, e: React.MouseEvent<HTMLTableRowElement>) => void onRowContextMenu?: (row: T, e: React.MouseEvent<HTMLTableRowElement>) => void
onRowMouseEnter?: (row: T, e: React.MouseEvent<HTMLTableRowElement>) => void
onRowMouseLeave?: (row: T, e: React.MouseEvent<HTMLTableRowElement>) => void
rowAttrs?: (row: T, rowIndex: number) => TableRowAttrs
/** /**
* Controlled Sorting: * Controlled Sorting:
@ -130,6 +137,9 @@ export default function Table<T>({
rowClassName, rowClassName,
onRowClick, onRowClick,
onRowContextMenu, onRowContextMenu,
onRowMouseEnter,
onRowMouseLeave,
rowAttrs,
sort, sort,
onSortChange, onSortChange,
defaultSort = null, defaultSort = null,
@ -318,15 +328,18 @@ export default function Table<T>({
) : ( ) : (
sortedRows.map((row, rowIndex) => { sortedRows.map((row, rowIndex) => {
const key = getRowKey ? getRowKey(row, rowIndex) : String(rowIndex) const key = getRowKey ? getRowKey(row, rowIndex) : String(rowIndex)
const extraRowAttrs = rowAttrs?.(row, rowIndex)
return ( return (
<tr <tr
key={key} key={key}
{...extraRowAttrs}
className={cn( className={cn(
striped && 'even:bg-gray-50 dark:even:bg-gray-800/50', striped && 'even:bg-gray-50 dark:even:bg-gray-800/50',
onRowClick && 'cursor-pointer', onRowClick && 'cursor-pointer',
onRowClick && 'hover:bg-gray-50 dark:hover:bg-white/5 transition-colors', onRowClick && 'hover:bg-gray-50 dark:hover:bg-white/5 transition-colors',
rowClassName?.(row, rowIndex) rowClassName?.(row, rowIndex),
extraRowAttrs?.className
)} )}
onClick={() => onRowClick?.(row)} onClick={() => onRowClick?.(row)}
onContextMenu={ onContextMenu={
@ -335,8 +348,16 @@ export default function Table<T>({
e.preventDefault() e.preventDefault()
onRowContextMenu(row, e) onRowContextMenu(row, e)
} }
: undefined : extraRowAttrs?.onContextMenu
} }
onMouseEnter={(e) => {
extraRowAttrs?.onMouseEnter?.(e)
onRowMouseEnter?.(row, e)
}}
onMouseLeave={(e) => {
extraRowAttrs?.onMouseLeave?.(e)
onRowMouseLeave?.(row, e)
}}
> >
{columns.map((col, colIndex) => { {columns.map((col, colIndex) => {
const xPad = xPadForColumn(colIndex, columns.length) const xPad = xPadForColumn(colIndex, columns.length)

View File

@ -105,15 +105,15 @@ export default function TaskList({ tasks, onCancel }: Props) {
{/* Status badge (running bewusst ohne Badge) */} {/* Status badge (running bewusst ohne Badge) */}
{t.status === 'done' ? ( {t.status === 'done' ? (
<span className="shrink-0 inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-semibold text-green-800 ring-1 ring-inset ring-green-200 dark:bg-green-500/20 dark:text-green-200 dark:ring-green-400/30"> <span className="shrink-0 inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-semibold text-green-800 ring-1 ring-inset ring-green-200 dark:bg-green-500/20 dark:text-green-200 dark:ring-green-400/30">
fertig Fertig
</span> </span>
) : t.status === 'cancelled' ? ( ) : t.status === 'cancelled' ? (
<span className="shrink-0 inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-semibold text-gray-700 ring-1 ring-inset ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10"> <span className="shrink-0 inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-semibold text-gray-700 ring-1 ring-inset ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10">
abgebrochen Abgebrochen
</span> </span>
) : t.status === 'error' ? ( ) : t.status === 'error' ? (
<span className="shrink-0 inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-[11px] font-semibold text-red-800 ring-1 ring-inset ring-red-200 dark:bg-red-500/20 dark:text-red-200 dark:ring-red-400/30"> <span className="shrink-0 inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-[11px] font-semibold text-red-800 ring-1 ring-inset ring-red-200 dark:bg-red-500/20 dark:text-red-200 dark:ring-red-400/30">
fehler Fehler
</span> </span>
) : null} ) : null}

View File

@ -30,6 +30,17 @@ type ToastInternal = Toast & {
open: boolean open: boolean
} }
type SwipeState = {
pointerId: number | null
startX: number
startY: number
dx: number
dy: number
dragging: boolean
lock: 'none' | 'x' | 'y'
dismissing: boolean
}
type ToastContextValue = { type ToastContextValue = {
push: (t: Omit<Toast, 'id'>) => string push: (t: Omit<Toast, 'id'>) => string
remove: (id: string) => void remove: (id: string) => void
@ -39,6 +50,8 @@ type ToastContextValue = {
const ToastContext = React.createContext<ToastContextValue | null>(null) const ToastContext = React.createContext<ToastContextValue | null>(null)
const TOAST_LEAVE_MS = 220 const TOAST_LEAVE_MS = 220
const TOAST_SWIPE_DISMISS_PX = 96
const TOAST_SWIPE_LOCK_PX = 10
function iconFor(type: ToastType) { function iconFor(type: ToastType) {
switch (type) { switch (type) {
@ -137,6 +150,9 @@ export function ToastProvider({
const toastRemainingMsRef = React.useRef<Record<string, number>>({}) const toastRemainingMsRef = React.useRef<Record<string, number>>({})
const toastPausedRef = React.useRef<Record<string, boolean>>({}) const toastPausedRef = React.useRef<Record<string, boolean>>({})
const swipeStateRef = React.useRef<Record<string, SwipeState>>({})
const [, forceSwipeRender] = React.useState(0)
const clearTimersFor = React.useCallback((id: string) => { const clearTimersFor = React.useCallback((id: string) => {
const autoId = autoCloseTimersRef.current[id] const autoId = autoCloseTimersRef.current[id]
if (autoId) { if (autoId) {
@ -153,6 +169,7 @@ export function ToastProvider({
delete toastStartedAtRef.current[id] delete toastStartedAtRef.current[id]
delete toastRemainingMsRef.current[id] delete toastRemainingMsRef.current[id]
delete toastPausedRef.current[id] delete toastPausedRef.current[id]
delete swipeStateRef.current[id]
}, []) }, [])
const loadNotificationSetting = React.useCallback(async () => { const loadNotificationSetting = React.useCallback(async () => {
@ -193,12 +210,23 @@ export function ToastProvider({
const remove = React.useCallback( const remove = React.useCallback(
(id: string) => { (id: string) => {
const cur = swipeStateRef.current[id]
if (cur) {
swipeStateRef.current[id] = {
...cur,
dismissing: true,
dragging: false,
}
forceSwipeRender((n) => n + 1)
}
setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, open: false } : t))) setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, open: false } : t)))
clearTimersFor(id) clearTimersFor(id)
finalizeRemoveTimersRef.current[id] = window.setTimeout(() => { finalizeRemoveTimersRef.current[id] = window.setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id)) setToasts((prev) => prev.filter((t) => t.id !== id))
delete finalizeRemoveTimersRef.current[id] delete finalizeRemoveTimersRef.current[id]
delete swipeStateRef.current[id]
}, TOAST_LEAVE_MS) }, TOAST_LEAVE_MS)
}, },
[clearTimersFor] [clearTimersFor]
@ -314,10 +342,134 @@ export function ToastProvider({
[remove] [remove]
) )
const isTouchLikePointer = (e: React.PointerEvent) => {
return e.pointerType === 'touch' || e.pointerType === 'pen'
}
const getSwipeState = (id: string): SwipeState => {
return (
swipeStateRef.current[id] ?? {
pointerId: null,
startX: 0,
startY: 0,
dx: 0,
dy: 0,
dragging: false,
lock: 'none',
dismissing: false,
}
)
}
const handleToastPointerDown = React.useCallback((id: string, e: React.PointerEvent<HTMLDivElement>) => {
if (!isTouchLikePointer(e)) return
swipeStateRef.current[id] = {
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
dx: 0,
dy: 0,
dragging: false,
lock: 'none',
dismissing: false,
}
pauseAutoCloseTimer(id)
forceSwipeRender((n) => n + 1)
}, [pauseAutoCloseTimer])
const handleToastPointerMove = React.useCallback((id: string, e: React.PointerEvent<HTMLDivElement>) => {
const s = swipeStateRef.current[id]
if (!s) return
if (s.pointerId !== e.pointerId) return
if (s.dismissing) return
const dx = e.clientX - s.startX
const dy = e.clientY - s.startY
let lock = s.lock
if (lock === 'none') {
const absX = Math.abs(dx)
const absY = Math.abs(dy)
if (absX < TOAST_SWIPE_LOCK_PX && absY < TOAST_SWIPE_LOCK_PX) {
swipeStateRef.current[id] = { ...s, dx, dy }
forceSwipeRender((n) => n + 1)
return
}
lock = absX > absY ? 'x' : 'y'
}
if (lock === 'y') {
swipeStateRef.current[id] = { ...s, dx, dy, lock, dragging: false }
forceSwipeRender((n) => n + 1)
return
}
swipeStateRef.current[id] = {
...s,
dx,
dy,
lock,
dragging: true,
}
forceSwipeRender((n) => n + 1)
}, [])
const handleToastPointerEnd = React.useCallback((id: string, e?: React.PointerEvent<HTMLDivElement>) => {
const s = swipeStateRef.current[id]
if (!s) return
if (e && s.pointerId !== e.pointerId) return
const shouldDismiss = s.lock === 'x' && s.dx <= -TOAST_SWIPE_DISMISS_PX
if (shouldDismiss) {
remove(id)
return
}
swipeStateRef.current[id] = {
...s,
pointerId: null,
dx: 0,
dy: 0,
dragging: false,
lock: 'none',
dismissing: false,
}
resumeAutoCloseTimer(id)
forceSwipeRender((n) => n + 1)
}, [remove, resumeAutoCloseTimer])
const handleToastPointerCancel = React.useCallback((id: string, e?: React.PointerEvent<HTMLDivElement>) => {
const s = swipeStateRef.current[id]
if (!s) return
if (e && s.pointerId !== e.pointerId) return
swipeStateRef.current[id] = {
...s,
pointerId: null,
dx: 0,
dy: 0,
dragging: false,
lock: 'none',
dismissing: false,
}
resumeAutoCloseTimer(id)
forceSwipeRender((n) => n + 1)
}, [resumeAutoCloseTimer])
React.useEffect(() => { React.useEffect(() => {
return () => { return () => {
Object.values(autoCloseTimersRef.current).forEach((n) => window.clearTimeout(n)) Object.values(autoCloseTimersRef.current).forEach((n) => window.clearTimeout(n))
Object.values(finalizeRemoveTimersRef.current).forEach((n) => window.clearTimeout(n)) Object.values(finalizeRemoveTimersRef.current).forEach((n) => window.clearTimeout(n))
swipeStateRef.current= {}
} }
}, []) }, [])
@ -370,6 +522,27 @@ export function ToastProvider({
const imgAlt = (t.imageAlt || title).trim() const imgAlt = (t.imageAlt || title).trim()
const isClickable = typeof t.onClick === 'function' const isClickable = typeof t.onClick === 'function'
const swipe = getSwipeState(t.id)
const absDx = Math.abs(swipe.dx)
const dragOpacity = swipe.dragging
? Math.max(0.35, 1 - absDx / 220)
: 1
const dragScale = swipe.dragging
? Math.max(0.985, 1 - absDx / 1600)
: 1
const swipeStyle: React.CSSProperties = {
transform: `translateX(${swipe.dx}px) scale(${dragScale})`,
opacity: dragOpacity,
transition: swipe.dragging
? 'none'
: swipe.dismissing
? 'transform 180ms ease-out, opacity 180ms ease-out'
: 'transform 220ms ease, opacity 220ms ease',
touchAction: 'pan-y',
}
return ( return (
<Transition <Transition
key={t.id} key={t.id}
@ -390,15 +563,22 @@ export function ToastProvider({
'shadow-sm', 'shadow-sm',
'ring-1 ring-black/5 dark:ring-white/5', 'ring-1 ring-black/5 dark:ring-white/5',
isClickable isClickable
? 'cursor-pointer transition-[box-shadow,transform,background-color] duration-150 hover:bg-gray-50 dark:hover:bg-slate-800/90 hover:shadow-lg hover:ring-black/10 dark:hover:ring-white/10 active:scale-[0.998]' ? 'cursor-pointer transition-[box-shadow,background-color] duration-150 hover:bg-gray-50 dark:hover:bg-slate-800/90 hover:shadow-lg hover:ring-black/10 dark:hover:ring-white/10'
: '', : '',
borderFor(t.type), borderFor(t.type),
].join(' ')} ].join(' ')}
style={swipeStyle}
onMouseEnter={() => pauseAutoCloseTimer(t.id)} onMouseEnter={() => pauseAutoCloseTimer(t.id)}
onMouseLeave={() => resumeAutoCloseTimer(t.id)} onMouseLeave={() => resumeAutoCloseTimer(t.id)}
onFocus={() => pauseAutoCloseTimer(t.id)} onFocus={() => pauseAutoCloseTimer(t.id)}
onBlur={() => resumeAutoCloseTimer(t.id)} onBlur={() => resumeAutoCloseTimer(t.id)}
onPointerDown={(e) => handleToastPointerDown(t.id, e)}
onPointerMove={(e) => handleToastPointerMove(t.id, e)}
onPointerUp={(e) => handleToastPointerEnd(t.id, e)}
onPointerCancel={(e) => handleToastPointerCancel(t.id, e)}
onLostPointerCapture={(e) => handleToastPointerCancel(t.id, e)}
onClick={() => { onClick={() => {
if (swipe.dragging || Math.abs(swipe.dx) > 8) return
if (!t.onClick) return if (!t.onClick) return
t.onClick() t.onClick()
if (t.closeOnClick !== false) remove(t.id) if (t.closeOnClick !== false) remove(t.id)

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,11 @@
// frontend/src/components/ui/formatters.ts // frontend/src/components/ui/formatters.ts
export function formatFps(n?: number | null): string {
if (!n || !Number.isFinite(n)) return '—'
const digits = n >= 10 ? 0 : 2
return `${n.toFixed(digits)} fps`
}
export function formatResolution(r?: { w: number; h: number } | null): string { export function formatResolution(r?: { w: number; h: number } | null): string {
if (!r) return '' if (!r) return ''

View File

@ -1,3 +1,5 @@
// frontend\src\components\ui\notify.ts
'use client' 'use client'
import type { Toast } from './ToastProvider' import type { Toast } from './ToastProvider'

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
}
}