# 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,
Label: bestLabel,
Score: bestScore,
Start: math.Max(0, t-4),
End: t + 4,
Start: t,
End: t,
})
}
@ -545,16 +545,12 @@ func analyzeSpriteCandidatesWithAI(
continue
}
span := inferredSpanSeconds(ps.StepSeconds, 8)
start := math.Max(0, c.Time-(span/2))
end := c.Time + (span / 2)
hits = append(hits, analyzeHit{
Time: c.Time,
Label: bestLabel,
Score: bestScore,
Start: start,
End: end,
Start: c.Time,
End: c.Time,
})
}
@ -579,14 +575,14 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit {
start := h.Start
end := h.End
if start <= 0 && end <= 0 {
if start < 0 && end < 0 {
start = h.Time
end = h.Time
} else {
if start <= 0 {
if start < 0 {
start = h.Time
}
if end <= 0 {
if end < 0 {
end = h.Time
}
}
@ -617,13 +613,11 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit {
for i := 1; i < len(cp); i++ {
n := cp[i]
// Nur direkt aufeinanderfolgende Treffer mit gleichem Label zusammenfassen
const mergeGapSeconds = 1.0
// Direkt aufeinanderfolgende Treffer mit gleichem Label immer zusammenfassen.
// Sobald ein anderes Label dazwischen liegt, wird automatisch nicht gemergt.
sameLabel := strings.EqualFold(cur.Label, n.Label)
touchesOrNear := n.Start <= cur.End+mergeGapSeconds
if sameLabel && touchesOrNear {
if sameLabel {
if n.Start < cur.Start {
cur.Start = n.Start
}
@ -660,18 +654,17 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme
start := hit.Start
end := hit.End
if start <= 0 && end <= 0 {
if start < 0 && end < 0 {
start = hit.Time
end = hit.Time
} else {
if start <= 0 {
if start < 0 {
start = hit.Time
}
if end <= 0 {
if end < 0 {
end = hit.Time
}
}
if start > end {
start, end = end, start
}

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 (
"context"
"encoding/json"
"errors"
"fmt"
"math"
@ -164,10 +163,9 @@ type EnsureAssetsResult struct {
}
type EnsurePrimaryAssetsResult struct {
Skipped bool
ThumbGenerated bool
PreviewGenerated bool
MetaOK bool
Skipped bool
ThumbGenerated bool
MetaOK bool
}
// Public wrappers (kompatibel zu deinem bisherigen API)
@ -231,7 +229,7 @@ func ensurePrimaryAssetsForVideoWithProgressCtx(
return out, nil
}
_, thumbPath, previewPath, _, metaPath, perr := assetPathsForID(id)
_, thumbPath, _, _, metaPath, perr := assetPathsForID(id)
if perr != nil {
return out, perr
}
@ -256,27 +254,17 @@ func ensurePrimaryAssetsForVideoWithProgressCtx(
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
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
out.MetaOK = meta.ok
if thumbBefore && previewBefore {
if thumbBefore {
out.Skipped = true
progress(1)
return out, nil
}
const (
thumbsW = 0.25
previewW = 0.75
)
const thumbsW = 1.0
progress(0)
@ -330,63 +318,299 @@ func ensurePrimaryAssetsForVideoWithProgressCtx(
progress(thumbsW)
}
// ----------------
// Preview.mp4
// ----------------
_ = sourceInputInvalid
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 (
previewClipLenSec = 0.75
previewMaxClips = 12
)
if previewBefore {
progress(1)
} else {
func() {
genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel()
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 {
return
}
defer genSem.Release()
progress(thumbsW + 0.05)
if err := generateTeaserClipsMP4WithProgress(
genCtx,
videoPath,
previewPath,
previewClipLenSec,
previewMaxClips,
func(r float64) {
if r < 0 {
r = 0
}
if r > 1 {
r = 1
}
progress(thumbsW + r*previewW)
},
); err != nil {
if isFFmpegInputInvalidError(err) {
sourceInputInvalid = true
fmt.Printf("⚠️ preview clips skipped (invalid/incomplete input): %s\n", videoPath)
return
}
fmt.Println("⚠️ preview clips:", err)
return
}
out.PreviewGenerated = true
}()
if err := generateTeaserClipsMP4WithProgress(
genCtx,
videoPath,
previewPath,
previewClipLenSec,
previewMaxClips,
nil,
); err != nil {
if isFFmpegInputInvalidError(err) {
return false, nil
}
return false, err
}
_ = sourceInputInvalid
progress(1)
return out, nil
var computedPreviewClips []previewClip
if meta.durSec > 0 {
opts := TeaserPreviewOptions{
Segments: previewMaxClips,
SegmentDuration: previewClipLenSec,
}
starts, segDur, _ := computeTeaserStarts(meta.durSec, opts)
clips := make([]previewClip, 0, len(starts))
for _, s := range starts {
clips = append(clips, previewClip{
StartSeconds: math.Round(s*1000) / 1000,
DurationSeconds: math.Round(segDur*1000) / 1000,
})
}
computedPreviewClips = clips
}
var spriteMeta *previewSpriteMeta
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.Preview.Sprite != nil {
spriteMeta = oldMeta.Preview.Sprite
}
if meta.durSec > 0 {
_ = writeVideoMetaWithPreviewClipsAndSprite(
metaPath,
fi,
meta.durSec,
meta.vw,
meta.vh,
meta.fps,
meta.sourceURL,
computedPreviewClips,
spriteMeta,
)
}
return true, nil
}
func ensureSpritesForVideoCtx(
ctx context.Context,
videoPath string,
sourceURL string,
) (bool, error) {
videoPath = strings.TrimSpace(videoPath)
if videoPath == "" {
return false, nil
}
fi, err := os.Stat(videoPath)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return false, nil
}
id := assetIDFromVideoPath(videoPath)
if id == "" {
return false, nil
}
_, _, _, spritePath, metaPath, perr := assetPathsForID(id)
if perr != nil {
return false, perr
}
if sfi, err := os.Stat(spritePath); err == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 {
return false, nil
}
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
if !(meta.durSec > 0) {
return false, nil
}
genCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
if err := genSem.Acquire(genCtx); err != nil {
return false, err
}
defer genSem.Release()
cols, rows, count, cellW, cellH := fixedPreviewSpriteLayout()
stepSec := previewSpriteStepSeconds(meta.durSec)
if err := generatePreviewSpriteJPG(
genCtx,
videoPath,
spritePath,
cols,
rows,
stepSec,
cellW,
cellH,
); err != nil {
if sfi, statErr := os.Stat(spritePath); statErr == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 {
return false, nil
}
return false, err
}
var computedPreviewClips []previewClip
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && len(oldMeta.Preview.Clips) > 0 {
computedPreviewClips = oldMeta.Preview.Clips
}
spriteMeta := &previewSpriteMeta{
Path: fmt.Sprintf("/api/preview-sprite/%s", id),
Count: count,
Cols: cols,
Rows: rows,
StepSeconds: stepSec,
CellWidth: cellW,
CellHeight: cellH,
}
_ = writeVideoMetaWithPreviewClipsAndSprite(
metaPath,
fi,
meta.durSec,
meta.vw,
meta.vh,
meta.fps,
meta.sourceURL,
computedPreviewClips,
spriteMeta,
)
return true, nil
}
func ensureAnalyzeForVideoCtx(
ctx context.Context,
videoPath string,
sourceURL string,
goal string,
) (bool, error) {
videoPath = strings.TrimSpace(videoPath)
if videoPath == "" {
return false, nil
}
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 {
@ -405,154 +629,27 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string)
return nil
}
_, _, _, spritePath, metaPath, perr := assetPathsForID(id)
if perr != nil {
return perr
if _, err := ensureTeaserForVideoCtx(ctx, videoPath, sourceURL); err != nil {
return fmt.Errorf("deferred teaser failed for %s: %w", videoPath, err)
}
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
// 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
}
if _, err := ensureSpritesForVideoCtx(ctx, videoPath, sourceURL); err != nil {
return fmt.Errorf("deferred sprite failed for %s: %w", videoPath, err)
}
// previewClips aus Dauer ableiten, falls noch nicht vorhanden
if len(computedPreviewClips) == 0 && meta.durSec > 0 {
const (
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
spriteTruth := regeneratePhaseTruthForVideo(videoPath, id)
if !spriteTruth.SpritesReady {
return fmt.Errorf("preview-sprite.jpg fehlt nach deferred generation: %s", id)
}
// Sprite erzeugen, wenn noch nicht vorhanden
spriteExists := false
if sfi, err := os.Stat(spritePath); err == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 {
spriteExists = true
analyzeReady, err := ensureAnalyzeForVideoCtx(ctx, videoPath, sourceURL, "nsfw")
if err != nil {
return fmt.Errorf("deferred analyze failed for %s: %w", videoPath, err)
}
if !spriteExists && meta.durSec > 0 {
genCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
if err := genSem.Acquire(genCtx); err == nil {
func() {
defer genSem.Release()
cols, rows, count, cellW, cellH := fixedPreviewSpriteLayout()
stepSec := previewSpriteStepSeconds(meta.durSec)
if err := generatePreviewSpriteJPG(
genCtx,
videoPath,
spritePath,
cols,
rows,
stepSec,
cellW,
cellH,
); err != nil {
fmt.Printf("⚠️ preview sprite failed for %s: %v\n", videoPath)
return
}
spriteMeta = &previewSpriteMeta{
Path: fmt.Sprintf("/api/preview-sprite/%s", id),
Count: count,
Cols: cols,
Rows: rows,
StepSeconds: stepSec,
CellWidth: cellW,
CellHeight: cellH,
}
}()
}
}
// Falls Sprite existiert, aber spriteMeta noch fehlt
if spriteMeta == nil {
if sfi, err := os.Stat(spritePath); err == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 && meta.durSec > 0 {
cols, rows, count, cellW, cellH := fixedPreviewSpriteLayout()
stepSec := previewSpriteStepSeconds(meta.durSec)
spriteMeta = &previewSpriteMeta{
Path: fmt.Sprintf("/api/preview-sprite/%s", id),
Count: count,
Cols: cols,
Rows: rows,
StepSeconds: stepSec,
CellWidth: cellW,
CellHeight: cellH,
}
}
}
// meta.json mit previewClips + sprite ergänzen
if meta.durSec > 0 {
_ = writeVideoMetaWithPreviewClipsAndSprite(
metaPath,
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)
}
}
analyzeTruth := regeneratePhaseTruthForVideo(videoPath, id)
if !analyzeReady && !analyzeTruth.AnalyzeReady {
return fmt.Errorf("KI-Analyse fehlt nach deferred analyze: %s", id)
}
return nil
@ -582,7 +679,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, videoPath, sourceURL string) boo
"enrich",
st.State,
"assets",
"Ergänze Sprite / AI / Meta…",
"Wird verarbeitet…",
st.Position,
st.Waiting,
st.Running,
@ -611,7 +708,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, videoPath, sourceURL string) boo
"enrich",
"done",
"",
"Sprite / AI / Meta fertig",
"Fertig",
0,
0,
0,
@ -631,7 +728,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, videoPath, sourceURL string) boo
"enrich",
st.State,
"assets",
"Warte auf Sprite / AI / Meta…",
"Wartet",
st.Position,
st.Waiting,
st.Running,
@ -727,7 +824,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
// aber NICHT sofort returnen, damit meta.json
// (previewClips / previewSprite) trotzdem sauber geschrieben wird.
metaHasSprite := false
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.PreviewSprite != nil {
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.Preview.Sprite != nil {
metaHasSprite = true
}
@ -841,11 +938,8 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
}); err != nil {
if isFFmpegInputInvalidError(err) {
sourceInputInvalid = true
fmt.Printf("⚠️ preview clips skipped (invalid/incomplete input): %s\n", videoPath)
return
}
fmt.Println("⚠️ preview clips:", err)
return
}
@ -907,7 +1001,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
return
}
genCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
genCtx, cancel := context.WithTimeout(ctx, 4*time.Minute)
defer cancel()
if err := genSem.Acquire(genCtx); err != nil {
@ -932,7 +1026,6 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
return
}
fmt.Printf("⚠️ preview sprite failed for %s: %v\n", videoPath)
return
}
@ -945,16 +1038,16 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
// Falls wir in diesem Lauf keine neuen Clips berechnet haben:
// alte aus gültigem meta.json übernehmen (best effort)
if len(computedPreviewClips) == 0 {
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && len(oldMeta.PreviewClips) > 0 {
computedPreviewClips = oldMeta.PreviewClips
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && len(oldMeta.Preview.Clips) > 0 {
computedPreviewClips = oldMeta.Preview.Clips
}
}
// Falls wir in diesem Lauf kein neues spriteMeta gesetzt haben:
// altes aus gültigem meta.json übernehmen
if spriteMeta == nil {
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.PreviewSprite != nil {
spriteMeta = oldMeta.PreviewSprite
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.Preview.Sprite != nil {
spriteMeta = oldMeta.Preview.Sprite
}
}
@ -991,19 +1084,7 @@ func hasAIResultsForOutput(outPath string) bool {
return false
}
b, err := os.ReadFile(metaPath)
if err != nil || len(b) == 0 {
return false
}
var m map[string]any
dec := json.NewDecoder(strings.NewReader(string(b)))
dec.UseNumber()
if err := dec.Decode(&m); err != nil {
return false
}
aiMap, ok := m["ai"].(map[string]any)
aiMap, ok := readVideoMetaAI(metaPath)
if !ok || aiMap == nil {
return false
}

View File

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

View File

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

View File

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

View File

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

View File

@ -11,22 +11,18 @@ import (
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/grafov/m3u8"
gocpu "github.com/shirou/gopsutil/v3/cpu"
godisk "github.com/shirou/gopsutil/v3/disk"
)
var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`)
@ -213,13 +209,6 @@ var (
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() {
t := time.NewTicker(5 * time.Second)
go func() {
@ -254,8 +243,8 @@ func startPreviewIdleKiller() {
}
func init() {
initFFmpegSemaphores()
startAdaptiveSemController(context.Background())
initFFmpegLimiters()
startCPUUsageSampler(context.Background())
startPreviewIdleKiller()
initSSE()
@ -271,7 +260,6 @@ func publishJob(jobID string) bool {
j.Hidden = false
jobsMu.Unlock()
publishJobUpsert(j)
return true
}
@ -328,228 +316,6 @@ func detectFFprobePath() string {
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 {
size int64
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})$`,
)
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) {
// returns: (shouldDelete, sizeBytes, thresholdBytes)
@ -796,12 +383,7 @@ func setJobPhase(job *RecordJob, phase string, progress int) {
jobsMu.Lock()
job.Phase = phase
job.Progress = progress
hidden := job.Hidden
jobsMu.Unlock()
if !hidden {
publishJobUpsert(job)
}
}
func durationSecondsCached(ctx context.Context, path string) (float64, error) {
@ -923,10 +505,46 @@ func detectFFmpegPath() string {
return "ffmpeg"
}
func removeGeneratedForID(id string) {
// ✅ canonical id: wie beim Erzeugen der generated Ordner
func isAssetIDInUse(id string) bool {
id = strings.TrimSpace(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
}
@ -948,11 +566,6 @@ func removeGeneratedForID(id string) {
_ = 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
// (%TEMP%/rec_preview/<assetID>)
_ = os.RemoveAll(filepath.Join(os.TempDir(), "rec_preview", id))
@ -1695,8 +1308,6 @@ func renameJobsOutputBasename(oldFile, newFile string) {
return
}
changedJobs := make([]*RecordJob, 0, 4)
jobsMu.Lock()
for _, j := range jobs {
if j == nil {
@ -1708,21 +1319,14 @@ func renameJobsOutputBasename(oldFile, newFile string) {
}
if filepath.Base(out) == oldFile {
j.Output = filepath.Join(filepath.Dir(out), newFile)
if !j.Hidden {
changedJobs = append(changedJobs, j)
}
}
}
jobsMu.Unlock()
for _, j := range changedJobs {
publishJobUpsert(j)
}
}
// nimmt jetzt *HTTPClient entgegen
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")
}
@ -1737,51 +1341,122 @@ func FetchPlaylist(ctx context.Context, hc *HTTPClient, hlsSource, httpCookie st
}
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)
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")
}
master := playlist.(*m3u8.MasterPlaylist)
var bestURI string
master, ok := playlist.(*m3u8.MasterPlaylist)
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 bestFramerate int
for _, variant := range master.Variants {
if variant == nil || variant.Resolution == "" {
continue
parseFramerate := func(v *m3u8.Variant) int {
if v == nil {
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 {
continue
return 0
}
width, err := strconv.Atoi(parts[0])
w, err := strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil {
return 0
}
return w
}
for _, variant := range master.Variants {
if variant == nil || strings.TrimSpace(variant.URI) == "" {
continue
}
fr := 30
if strings.Contains(variant.Name, "FPS:60.0") {
fr = 60
}
width := parseWidth(variant)
fr := parseFramerate(variant)
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
bestFramerate = fr
bestURI = variant.URI
}
}
if bestURI == "" {
return nil, errors.New("keine gültige Auflösung gefunden")
if bestVariant == nil {
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{
PlaylistURL: root + bestURI,
RootURL: root,
PlaylistURL: playlistURL,
RootURL: rootParsed.String(),
Resolution: bestWidth,
Framerate: bestFramerate,
}, nil

View File

@ -1,4 +1,5 @@
// backend/meta.go
// backend\meta.go
package main
import (
@ -25,28 +26,78 @@ type previewSpriteMeta struct {
Count int `json:"count"` // Anzahl Frames im Sprite
Cols int `json:"cols"` // Spalten im Tile
Rows int `json:"rows"` // Zeilen im Tile
StepSeconds float64 `json:"stepSeconds"` // Zeitabstand zwischen Frames (z.B. 5)
StepSeconds float64 `json:"stepSeconds"` // Zeitabstand zwischen Frames
CellWidth int `json:"cellWidth,omitempty"`
CellHeight int `json:"cellHeight,omitempty"`
}
type videoFileMeta struct {
Size int64 `json:"size"`
ModUnix int64 `json:"modUnix"`
SourceURL string `json:"sourceUrl,omitempty"`
}
type videoStreamMeta struct {
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
FPS float64 `json:"fps,omitempty"`
Resolution string `json:"resolution,omitempty"`
}
type videoMediaMeta struct {
DurationSeconds float64 `json:"durationSeconds"`
Video videoStreamMeta `json:"video"`
}
type previewThumbMeta struct {
Path string `json:"path,omitempty"`
Exists bool `json:"exists,omitempty"`
}
type previewMeta struct {
Clips []previewClip `json:"clips,omitempty"`
Thumb *previewThumbMeta `json:"thumb,omitempty"`
Sprite *previewSpriteMeta `json:"sprite,omitempty"`
}
type analysisMeta struct {
AI *aiAnalysisMeta `json:"ai,omitempty"`
}
type postworkHistoryEntry struct {
Queue string `json:"queue"`
Phase string `json:"phase"`
State string `json:"state"`
Label string `json:"label,omitempty"`
Position int `json:"position,omitempty"`
Waiting int `json:"waiting,omitempty"`
Running int `json:"running,omitempty"`
MaxParallel int `json:"maxParallel,omitempty"`
TS int64 `json:"ts"`
}
type postworkCompletedMeta struct {
Meta bool `json:"meta,omitempty"`
Teaser bool `json:"teaser,omitempty"`
Thumb bool `json:"thumb,omitempty"`
Sprites bool `json:"sprites,omitempty"`
Analyze bool `json:"analyze,omitempty"`
}
type postworkMeta struct {
Completed *postworkCompletedMeta `json:"completed,omitempty"`
History []postworkHistoryEntry `json:"history,omitempty"`
}
type videoMeta struct {
Version int `json:"version"`
DurationSeconds float64 `json:"durationSeconds"`
FileSize int64 `json:"fileSize"`
FileModUnix int64 `json:"fileModUnix"`
VideoWidth int `json:"videoWidth,omitempty"`
VideoHeight int `json:"videoHeight,omitempty"`
FPS float64 `json:"fps,omitempty"`
Resolution string `json:"resolution,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
PreviewClips []previewClip `json:"previewClips,omitempty"`
PreviewSprite *previewSpriteMeta `json:"previewSprite,omitempty"`
AI *aiAnalysisMeta `json:"ai,omitempty"`
Version int `json:"version"`
UpdatedAtUnix int64 `json:"updatedAtUnix"`
File videoFileMeta `json:"file"`
Media videoMediaMeta `json:"media"`
Preview previewMeta `json:"preview,omitempty"`
Analysis analysisMeta `json:"analysis,omitempty"`
Postwork postworkMeta `json:"postwork,omitempty"`
}
type aiSegmentMeta struct {
@ -66,54 +117,20 @@ type aiAnalysisMeta struct {
AnalyzedAtUnix int64 `json:"analyzedAtUnix,omitempty"`
}
// liest Meta (v2 ODER altes v1) und validiert gegen fi (Size/ModTime)
func readVideoMeta(metaPath string, fi os.FileInfo) (dur float64, w int, h int, fps float64, ok bool) {
b, err := os.ReadFile(metaPath)
if err != nil || len(b) == 0 {
m, ok := readVideoMetaIfValid(metaPath, fi)
if !ok || m == nil {
return 0, 0, 0, 0, false
}
// 1) Neues Format (oder v1 mit gleichen Feldern)
var m videoMeta
if err := json.Unmarshal(b, &m); err == nil && (m.Version == 2 || m.Version == 1) {
if m.FileSize != fi.Size() || m.FileModUnix != fi.ModTime().Unix() {
return 0, 0, 0, 0, false
}
if m.DurationSeconds <= 0 {
return 0, 0, 0, 0, false
}
return m.DurationSeconds, m.VideoWidth, m.VideoHeight, m.FPS, true
}
// 2) Fallback: ganz altes v1-Format (nur Duration etc.)
var m1 struct {
Version int `json:"version"`
DurationSeconds float64 `json:"durationSeconds"`
FileSize int64 `json:"fileSize"`
FileModUnix int64 `json:"fileModUnix"`
UpdatedAtUnix int64 `json:"updatedAtUnix"`
}
if err := json.Unmarshal(b, &m1); err != nil {
return 0, 0, 0, 0, false
}
if m1.Version != 1 {
return 0, 0, 0, 0, false
}
if m1.FileSize != fi.Size() || m1.FileModUnix != fi.ModTime().Unix() {
return 0, 0, 0, 0, false
}
if m1.DurationSeconds <= 0 {
return 0, 0, 0, 0, false
}
return m1.DurationSeconds, 0, 0, 0, true
return m.Media.DurationSeconds, m.Media.Video.Width, m.Media.Video.Height, m.Media.Video.FPS, true
}
func readVideoMetaDuration(metaPath string, fi os.FileInfo) (float64, bool) {
m, ok := readVideoMetaIfValid(metaPath, fi)
if !ok || m == nil || m.DurationSeconds <= 0 {
if !ok || m == nil || m.Media.DurationSeconds <= 0 {
return 0, false
}
return m.DurationSeconds, true
return m.Media.DurationSeconds, true
}
func metaJSONPathForAssetID(assetID string) (string, error) {
@ -128,14 +145,12 @@ func metaJSONPathForAssetID(assetID string) (string, error) {
}
func ensureVideoMetaForFile(ctx context.Context, fullPath string, fi os.FileInfo, sourceURL string) (*videoMeta, bool) {
// assetID aus Dateiname
stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath))
assetID := stripHotPrefix(strings.TrimSpace(stem))
if assetID == "" {
return nil, false
}
// sanitize wie bei deinen generated Ordnern
var err error
assetID, err = sanitizeID(assetID)
if err != nil || assetID == "" {
@ -147,12 +162,10 @@ func ensureVideoMetaForFile(ctx context.Context, fullPath string, fi os.FileInfo
return nil, false
}
// 1) valid meta vorhanden?
if m, ok := readVideoMetaIfValid(metaPath, fi); ok {
return m, true
}
// 2) sonst neu erzeugen (mit Concurrency-Limit)
if ctx == nil {
ctx = context.Background()
}
@ -166,38 +179,26 @@ func ensureVideoMetaForFile(ctx context.Context, fullPath string, fi os.FileInfo
defer durSem.Release()
}
// Dauer
dur, derr := durationSecondsCached(cctx, fullPath)
if derr != nil || dur <= 0 {
return nil, false
}
// Video props
w, h, fps, perr := probeVideoProps(cctx, fullPath)
if perr != nil {
// width/height/fps dürfen 0 bleiben, duration ist aber trotzdem nützlich
w, h, fps = 0, 0, 0
}
// meta dir anlegen
_ = os.MkdirAll(filepath.Dir(metaPath), 0o755)
m := &videoMeta{
Version: 2,
DurationSeconds: dur,
FileSize: fi.Size(),
FileModUnix: fi.ModTime().Unix(),
VideoWidth: w,
VideoHeight: h,
FPS: fps,
Resolution: formatResolution(w, h),
SourceURL: strings.TrimSpace(sourceURL),
UpdatedAtUnix: time.Now().Unix(),
if err := writeVideoMeta(metaPath, fi, dur, w, h, fps, sourceURL); err != nil {
return nil, false
}
b, _ := json.MarshalIndent(m, "", " ")
b = append(b, '\n')
_ = atomicWriteFile(metaPath, b) // best effort
m, ok := readVideoMetaLoose(metaPath)
if !ok || m == nil {
return nil, false
}
return m, true
}
@ -211,45 +212,41 @@ func attachMetaToJobBestEffort(ctx context.Context, job *RecordJob, fullPath str
return
}
// Stat
fi, err := os.Stat(fullPath)
if err != nil || fi == nil || fi.IsDir() {
return
}
// Größe immer mitgeben (macht Sort/Anzeige einfacher)
if job.SizeBytes <= 0 {
job.SizeBytes = fi.Size()
}
// Meta.json lesen/erzeugen (best effort)
m, ok := ensureVideoMetaForFileBestEffort(ctx, fullPath, job.SourceURL)
if !ok || m == nil {
return
}
// Optional: komplettes Meta mitsenden
job.Meta = m
// Und zusätzlich die "Top-Level" Felder befüllen (für Frontend bequem)
if job.DurationSeconds <= 0 && m.DurationSeconds > 0 {
job.DurationSeconds = m.DurationSeconds
if strings.Contains(strings.TrimSpace(fullPath), "alice_kosmos_03_24_2026__03-48-34") {
b, _ := json.MarshalIndent(m, "", " ")
fmt.Println("ATTACH META SNAPSHOT:", string(b))
}
if job.VideoWidth <= 0 && m.VideoWidth > 0 {
job.VideoWidth = m.VideoWidth
if job.DurationSeconds <= 0 && m.Media.DurationSeconds > 0 {
job.DurationSeconds = m.Media.DurationSeconds
}
if job.VideoHeight <= 0 && m.VideoHeight > 0 {
job.VideoHeight = m.VideoHeight
if job.VideoWidth <= 0 && m.Media.Video.Width > 0 {
job.VideoWidth = m.Media.Video.Width
}
if job.FPS <= 0 && m.FPS > 0 {
job.FPS = m.FPS
if job.VideoHeight <= 0 && m.Media.Video.Height > 0 {
job.VideoHeight = m.Media.Video.Height
}
if job.FPS <= 0 && m.Media.Video.FPS > 0 {
job.FPS = m.Media.Video.FPS
}
}
// ensureVideoMetaForFileBestEffort:
// - versucht zuerst echtes Generieren (ffprobe/ffmpeg) via ensureVideoMetaForFile
// - wenn das fehlschlägt, aber durationSecondsCacheOnly schon was weiß:
// schreibt eine Duration-only meta.json, damit wir künftig "aus meta.json" lesen können.
func ensureVideoMetaForFileBestEffort(ctx context.Context, fullPath string, sourceURL string) (*videoMeta, bool) {
fullPath = strings.TrimSpace(fullPath)
if fullPath == "" {
@ -261,23 +258,21 @@ func ensureVideoMetaForFileBestEffort(ctx context.Context, fullPath string, sour
return nil, false
}
// 1) Normaler Weg: meta erzeugen/lesen (ffprobe/ffmpeg)
if m, ok := ensureVideoMetaForFile(ctx, fullPath, fi, sourceURL); ok && m != nil {
return m, true
}
// 2) Fallback: wenn wir Duration schon im RAM-Cache haben -> meta.json (Duration-only) persistieren
dur := durationSecondsCacheOnly(fullPath, fi)
if dur <= 0 {
return nil, false
}
stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath))
assetID := stripHotPrefix(strings.TrimSpace(stem))
if assetID == "" {
return nil, false
}
assetID, err = sanitizeID(assetID)
if err != nil || assetID == "" {
return nil, false
@ -291,7 +286,6 @@ func ensureVideoMetaForFileBestEffort(ctx context.Context, fullPath string, sour
_ = os.MkdirAll(filepath.Dir(metaPath), 0o755)
_ = writeVideoMetaDuration(metaPath, fi, dur, sourceURL)
// nochmal lesen/validieren
if m, ok := readVideoMetaIfValid(metaPath, fi); ok && m != nil {
return m, true
}
@ -309,63 +303,162 @@ func readVideoMetaIfValid(metaPath string, fi os.FileInfo) (*videoMeta, bool) {
if err := json.Unmarshal(b, &m); err != nil {
return nil, false
}
if m.Version != 1 && m.Version != 2 {
if m.Version != 3 {
return nil, false
}
if m.FileSize != fi.Size() || m.FileModUnix != fi.ModTime().Unix() {
if m.File.Size != fi.Size() || m.File.ModUnix != fi.ModTime().Unix() {
return nil, false
}
if m.DurationSeconds <= 0 {
if m.Media.DurationSeconds <= 0 {
return nil, false
}
return &m, true
}
func readVideoMetaLoose(metaPath string) (*videoMeta, bool) {
b, err := os.ReadFile(metaPath)
if err != nil || len(b) == 0 {
return nil, false
}
var m videoMeta
if err := json.Unmarshal(b, &m); err != nil {
return nil, false
}
if m.Version == 0 {
m.Version = 3
}
return &m, true
}
func mergePreviewSpriteMeta(existing, incoming *previewSpriteMeta) *previewSpriteMeta {
if existing == nil && incoming == nil {
return nil
}
if existing == nil {
cp := *incoming
return &cp
}
if incoming == nil {
cp := *existing
return &cp
}
out := *incoming
if strings.TrimSpace(out.Path) == "" {
out.Path = existing.Path
}
if out.Count <= 0 {
out.Count = existing.Count
}
if out.Cols <= 0 {
out.Cols = existing.Cols
}
if out.Rows <= 0 {
out.Rows = existing.Rows
}
if out.StepSeconds <= 0 {
out.StepSeconds = existing.StepSeconds
}
if out.CellWidth <= 0 {
out.CellWidth = existing.CellWidth
}
if out.CellHeight <= 0 {
out.CellHeight = existing.CellHeight
}
return &out
}
func hasAIAnalysisMeta(ai *aiAnalysisMeta) bool {
if ai == nil {
return false
}
if strings.TrimSpace(ai.Goal) != "" {
return true
}
if strings.TrimSpace(ai.Mode) != "" {
return true
}
if len(ai.Hits) > 0 {
return true
}
if len(ai.Segments) > 0 {
return true
}
if ai.AnalyzedAtUnix > 0 {
return true
}
return false
}
func readVideoMetaSourceURL(metaPath string, fi os.FileInfo) (string, bool) {
m, ok := readVideoMetaIfValid(metaPath, fi)
if !ok || m == nil {
return "", false
}
u := strings.TrimSpace(m.SourceURL)
u := strings.TrimSpace(m.File.SourceURL)
if u == "" {
return "", false
}
return u, true
}
// Voll-Write (wenn du dur + props schon hast)
func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64, w int, h int, fps float64, sourceURL string) error {
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
return nil
}
var existing *videoMeta
if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil {
if old, ok := readVideoMetaLoose(metaPath); ok && old != nil {
existing = old
}
m := videoMeta{
Version: 2,
DurationSeconds: dur,
FileSize: fi.Size(),
FileModUnix: fi.ModTime().Unix(),
VideoWidth: w,
VideoHeight: h,
FPS: fps,
Resolution: formatResolution(w, h),
SourceURL: strings.TrimSpace(sourceURL),
UpdatedAtUnix: time.Now().Unix(),
PreviewClips: nil,
PreviewSprite: nil,
AI: nil,
Version: 3,
UpdatedAtUnix: time.Now().Unix(),
File: videoFileMeta{
Size: fi.Size(),
ModUnix: fi.ModTime().Unix(),
SourceURL: strings.TrimSpace(sourceURL),
},
Media: videoMediaMeta{
DurationSeconds: dur,
Video: videoStreamMeta{
Width: w,
Height: h,
FPS: fps,
Resolution: formatResolution(w, h),
},
},
}
if existing != nil {
m.PreviewClips = existing.PreviewClips
m.PreviewSprite = existing.PreviewSprite
m.AI = existing.AI
m.Preview = existing.Preview
m.Analysis = existing.Analysis
m.Postwork = existing.Postwork
if m.File.SourceURL == "" {
m.File.SourceURL = existing.File.SourceURL
}
if m.Media.Video.Width <= 0 {
m.Media.Video.Width = existing.Media.Video.Width
}
if m.Media.Video.Height <= 0 {
m.Media.Video.Height = existing.Media.Video.Height
}
if m.Media.Video.FPS <= 0 {
m.Media.Video.FPS = existing.Media.Video.FPS
}
if m.Media.Video.Resolution == "" {
m.Media.Video.Resolution = existing.Media.Video.Resolution
}
}
buf, err := json.Marshal(m)
if err != nil {
return err
@ -378,33 +471,63 @@ func writeVideoMetaWithPreviewClips(metaPath string, fi os.FileInfo, dur float64
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
return nil
}
assetID := strings.TrimSpace(filepath.Base(filepath.Dir(metaPath)))
var existing *videoMeta
if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil {
if old, ok := readVideoMetaLoose(metaPath); ok && old != nil {
existing = old
}
m := videoMeta{
Version: 2,
DurationSeconds: dur,
FileSize: fi.Size(),
FileModUnix: fi.ModTime().Unix(),
VideoWidth: w,
VideoHeight: h,
FPS: fps,
Resolution: formatResolution(w, h),
SourceURL: strings.TrimSpace(sourceURL),
PreviewClips: clips,
UpdatedAtUnix: time.Now().Unix(),
Version: 3,
UpdatedAtUnix: time.Now().Unix(),
File: videoFileMeta{
Size: fi.Size(),
ModUnix: fi.ModTime().Unix(),
SourceURL: strings.TrimSpace(sourceURL),
},
Media: videoMediaMeta{
DurationSeconds: dur,
Video: videoStreamMeta{
Width: w,
Height: h,
FPS: fps,
Resolution: formatResolution(w, h),
},
},
Preview: previewMeta{
Clips: clips,
Thumb: buildPreviewThumbMeta(assetID),
},
}
if existing != nil {
m.AI = existing.AI
m.Analysis = existing.Analysis
m.Postwork = existing.Postwork
if existing.Preview.Sprite != nil && m.Preview.Sprite == nil {
m.Preview.Sprite = existing.Preview.Sprite
}
if m.Preview.Thumb == nil && existing.Preview.Thumb != nil {
m.Preview.Thumb = existing.Preview.Thumb
}
if m.File.SourceURL == "" {
m.File.SourceURL = existing.File.SourceURL
}
if m.Media.Video.Width <= 0 {
m.Media.Video.Width = existing.Media.Video.Width
}
if m.Media.Video.Height <= 0 {
m.Media.Video.Height = existing.Media.Video.Height
}
if m.Media.Video.FPS <= 0 {
m.Media.Video.FPS = existing.Media.Video.FPS
}
if m.Media.Video.Resolution == "" {
m.Media.Video.Resolution = existing.Media.Video.Resolution
}
}
// ✅ vorhandenes Sprite (inkl. stepSeconds) nicht wegwerfen
if existing != nil && existing.PreviewSprite != nil {
m.PreviewSprite = existing.PreviewSprite
}
buf, err := json.Marshal(m)
if err != nil {
return err
@ -427,35 +550,67 @@ func writeVideoMetaWithPreviewClipsAndSprite(
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
return nil
}
assetID := strings.TrimSpace(filepath.Base(filepath.Dir(metaPath)))
var existing *videoMeta
if old, ok := readVideoMetaLoose(metaPath); ok && old != nil {
existing = old
}
m := videoMeta{
Version: 2,
DurationSeconds: dur,
FileSize: fi.Size(),
FileModUnix: fi.ModTime().Unix(),
VideoWidth: w,
VideoHeight: h,
FPS: fps,
Resolution: formatResolution(w, h),
SourceURL: strings.TrimSpace(sourceURL),
PreviewClips: clips,
PreviewSprite: sprite,
UpdatedAtUnix: time.Now().Unix(),
Version: 3,
UpdatedAtUnix: time.Now().Unix(),
File: videoFileMeta{
Size: fi.Size(),
ModUnix: fi.ModTime().Unix(),
SourceURL: strings.TrimSpace(sourceURL),
},
Media: videoMediaMeta{
DurationSeconds: dur,
Video: videoStreamMeta{
Width: w,
Height: h,
FPS: fps,
Resolution: formatResolution(w, h),
},
},
Preview: previewMeta{
Clips: clips,
Thumb: buildPreviewThumbMeta(assetID),
Sprite: sprite,
},
}
if sprite == nil {
if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil && old.PreviewSprite != nil {
m.PreviewSprite = old.PreviewSprite
}
}
if len(clips) == 0 {
if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil && len(old.PreviewClips) > 0 {
m.PreviewClips = old.PreviewClips
}
}
if existing != nil {
m.Analysis = existing.Analysis
m.Postwork = existing.Postwork
if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil && old.AI != nil {
m.AI = old.AI
m.Preview.Sprite = mergePreviewSpriteMeta(existing.Preview.Sprite, m.Preview.Sprite)
if m.Preview.Thumb == nil && existing.Preview.Thumb != nil {
m.Preview.Thumb = existing.Preview.Thumb
}
if len(m.Preview.Clips) == 0 && len(existing.Preview.Clips) > 0 {
m.Preview.Clips = existing.Preview.Clips
}
if m.File.SourceURL == "" {
m.File.SourceURL = existing.File.SourceURL
}
if m.Media.Video.Width <= 0 {
m.Media.Video.Width = existing.Media.Video.Width
}
if m.Media.Video.Height <= 0 {
m.Media.Video.Height = existing.Media.Video.Height
}
if m.Media.Video.FPS <= 0 {
m.Media.Video.FPS = existing.Media.Video.FPS
}
if m.Media.Video.Resolution == "" {
m.Media.Video.Resolution = existing.Media.Video.Resolution
}
if m.Media.DurationSeconds <= 0 {
m.Media.DurationSeconds = existing.Media.DurationSeconds
}
}
buf, err := json.Marshal(m)
@ -466,7 +621,6 @@ func writeVideoMetaWithPreviewClipsAndSprite(
return atomicWriteFile(metaPath, buf)
}
// Duration-only Write (ohne props)
func writeVideoMetaDuration(metaPath string, fi os.FileInfo, dur float64, sourceURL string) error {
return writeVideoMeta(metaPath, fi, dur, 0, 0, 0, sourceURL)
}
@ -477,7 +631,6 @@ func generatedMetaFile(assetID string) (string, error) {
return "", fmt.Errorf("empty assetID")
}
// exakt wie beim Schreiben normalisieren
id, err := sanitizeID(assetID)
if err != nil || id == "" {
return "", fmt.Errorf("invalid assetID: %w", err)
@ -486,7 +639,6 @@ func generatedMetaFile(assetID string) (string, error) {
return metaJSONPathForAssetID(id)
}
// ✅ Neu: /generated/meta/<id>/...
func generatedDirForID(id string) (string, error) {
id, err := sanitizeID(id)
if err != nil {
@ -544,6 +696,27 @@ func generatedPreviewSpriteFile(id string) (string, error) {
return filepath.Join(dir, "preview-sprite.jpg"), nil
}
func buildPreviewThumbMeta(assetID string) *previewThumbMeta {
assetID = stripHotPrefix(strings.TrimSpace(assetID))
if assetID == "" {
return nil
}
thumbPath, err := generatedThumbFile(assetID)
if err != nil || strings.TrimSpace(thumbPath) == "" {
return nil
}
if _, err := os.Stat(thumbPath); err != nil {
return nil
}
return &previewThumbMeta{
Path: "/api/generated/cover?id=" + assetID,
Exists: true,
}
}
func ensureGeneratedDirs() error {
root, err := generatedMetaRoot()
if err != nil {
@ -581,44 +754,57 @@ func writeVideoMetaAI(
}
var existing *videoMeta
if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil {
if old, ok := readVideoMetaLoose(metaPath); ok && old != nil {
existing = old
}
m := videoMeta{
Version: 2,
DurationSeconds: dur,
FileSize: fi.Size(),
FileModUnix: fi.ModTime().Unix(),
VideoWidth: w,
VideoHeight: h,
FPS: fps,
Resolution: formatResolution(w, h),
SourceURL: strings.TrimSpace(sourceURL),
UpdatedAtUnix: time.Now().Unix(),
PreviewClips: nil,
PreviewSprite: nil,
AI: ai,
Version: 3,
UpdatedAtUnix: time.Now().Unix(),
File: videoFileMeta{
Size: fi.Size(),
ModUnix: fi.ModTime().Unix(),
SourceURL: strings.TrimSpace(sourceURL),
},
Media: videoMediaMeta{
DurationSeconds: dur,
Video: videoStreamMeta{
Width: w,
Height: h,
FPS: fps,
Resolution: formatResolution(w, h),
},
},
Analysis: analysisMeta{
AI: ai,
},
}
if existing != nil {
m.PreviewClips = existing.PreviewClips
m.PreviewSprite = existing.PreviewSprite
if m.VideoWidth <= 0 {
m.VideoWidth = existing.VideoWidth
m.Preview = existing.Preview
m.Postwork = existing.Postwork
if !hasAIAnalysisMeta(ai) && hasAIAnalysisMeta(existing.Analysis.AI) {
m.Analysis = existing.Analysis
}
if m.VideoHeight <= 0 {
m.VideoHeight = existing.VideoHeight
if m.Media.Video.Width <= 0 {
m.Media.Video.Width = existing.Media.Video.Width
}
if m.FPS <= 0 {
m.FPS = existing.FPS
if m.Media.Video.Height <= 0 {
m.Media.Video.Height = existing.Media.Video.Height
}
if m.Resolution == "" {
m.Resolution = existing.Resolution
if m.Media.Video.FPS <= 0 {
m.Media.Video.FPS = existing.Media.Video.FPS
}
if m.SourceURL == "" {
m.SourceURL = existing.SourceURL
if m.Media.Video.Resolution == "" {
m.Media.Video.Resolution = existing.Media.Video.Resolution
}
if m.File.SourceURL == "" {
m.File.SourceURL = existing.File.SourceURL
}
if m.Media.DurationSeconds <= 0 {
m.Media.DurationSeconds = existing.Media.DurationSeconds
}
}
@ -670,15 +856,103 @@ func writeVideoAIForFile(
return writeVideoMetaAI(
metaPath,
fi,
m.DurationSeconds,
m.VideoWidth,
m.VideoHeight,
m.FPS,
m.Media.DurationSeconds,
m.Media.Video.Width,
m.Media.Video.Height,
m.Media.Video.FPS,
sourceURL,
ai,
)
}
func writeVideoMetaPostworkEntry(
metaPath string,
fi os.FileInfo,
entry postworkHistoryEntry,
) error {
if strings.TrimSpace(metaPath) == "" {
return nil
}
var existing videoMeta
if old, ok := readVideoMetaLoose(metaPath); ok && old != nil {
existing = *old
}
// Datei-Metadaten immer aktualisieren
existing.Version = 3
existing.UpdatedAtUnix = time.Now().Unix()
existing.File.Size = fi.Size()
existing.File.ModUnix = fi.ModTime().Unix()
queue := strings.TrimSpace(strings.ToLower(entry.Queue))
phase := strings.TrimSpace(strings.ToLower(entry.Phase))
if queue == "" {
queue = "postwork"
}
if phase == "" {
phase = "postwork"
}
entry.Queue = queue
entry.Phase = phase
if entry.TS <= 0 {
entry.TS = time.Now().Unix()
}
history := existing.Postwork.History
replaced := false
for i := range history {
if strings.EqualFold(history[i].Queue, entry.Queue) &&
strings.EqualFold(history[i].Phase, entry.Phase) {
// nur ersetzen, wenn neuer oder gleich neu
if history[i].TS <= entry.TS {
history[i] = entry
}
replaced = true
break
}
}
if !replaced {
history = append(history, entry)
}
existing.Postwork.History = history
if existing.Postwork.Completed == nil {
existing.Postwork.Completed = &postworkCompletedMeta{}
}
if strings.EqualFold(entry.State, "done") {
switch queue {
case "postwork":
switch phase {
case "postwork", "probe", "remuxing", "moving", "meta":
existing.Postwork.Completed.Meta = true
case "assets", "teaser":
existing.Postwork.Completed.Teaser = true
case "thumb":
existing.Postwork.Completed.Thumb = true
case "sprites", "sprite":
existing.Postwork.Completed.Sprites = true
}
case "enrich":
switch phase {
case "analyze", "analysis", "ai":
existing.Postwork.Completed.Analyze = true
}
}
}
buf, err := json.MarshalIndent(existing, "", " ")
if err != nil {
return err
}
buf = append(buf, '\n')
return atomicWriteFile(metaPath, buf)
}
func readVideoMetaAI(metaPath string) (map[string]any, bool) {
b, err := os.ReadFile(metaPath)
if err != nil || len(b) == 0 {
@ -692,10 +966,70 @@ func readVideoMetaAI(metaPath string) (map[string]any, bool) {
return nil, false
}
ai, ok := m["ai"].(map[string]any)
rawAnalysis, ok := m["analysis"].(map[string]any)
if !ok || rawAnalysis == nil {
return nil, false
}
ai, ok := rawAnalysis["ai"].(map[string]any)
if !ok || ai == nil {
return nil, false
}
return ai, true
}
func writePostworkEntryForFile(
fullPath string,
queue string,
phase string,
state string,
label string,
position int,
waiting int,
running int,
maxParallel int,
) error {
fullPath = strings.TrimSpace(fullPath)
if fullPath == "" {
return fmt.Errorf("fullPath fehlt")
}
fi, err := os.Stat(fullPath)
if err != nil || fi == nil || fi.IsDir() {
return fmt.Errorf("datei nicht gefunden")
}
stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath))
assetID := stripHotPrefix(strings.TrimSpace(stem))
if assetID == "" {
return fmt.Errorf("asset id fehlt")
}
assetID, err = sanitizeID(assetID)
if err != nil {
return err
}
metaPath, err := metaJSONPathForAssetID(assetID)
if err != nil {
return err
}
// sorgt dafür, dass meta.json grundsätzlich existiert
if _, ok := ensureVideoMetaForFileBestEffort(context.Background(), fullPath, ""); !ok {
return fmt.Errorf("meta konnte nicht erzeugt werden")
}
return writeVideoMetaPostworkEntry(metaPath, fi, postworkHistoryEntry{
Queue: queue,
Phase: phase,
State: state,
Label: label,
Position: position,
Waiting: waiting,
Running: running,
MaxParallel: maxParallel,
TS: time.Now().Unix(),
})
}

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
waitingKeys []string // FIFO der Keys, die noch NICHT gestartet sind
runningKeys map[string]struct{} // Keys, die gerade wirklich laufen (Semaphor gehalten)
cancelByKey map[string]context.CancelFunc
}
func NewPostWorkQueue(queueSize int, maxParallelFFmpeg int) *PostWorkQueue {
@ -45,6 +46,7 @@ func NewPostWorkQueue(queueSize int, maxParallelFFmpeg int) *PostWorkQueue {
waitingKeys: make([]string, 0, queueSize),
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) {
for i, k := range pq.waitingKeys {
if k == key {
@ -171,7 +190,18 @@ func (pq *PostWorkQueue) workerLoop(id int) {
// 3) Optional: Task timeout (gegen hängende ffmpeg)
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
if task.Run != nil {
@ -291,9 +321,9 @@ func startEnrichStatusRefresher() {
continue
}
label := "Warte auf Sprite / AI / Meta…"
label := "Wartet"
if st.State == "running" {
label = "Ergänze Sprite / AI / Meta…"
label = "Wird verarbeitet…"
}
publishFinishedPostworkStateForJob(
@ -388,10 +418,6 @@ func startPostWorkStatusRefresher() {
}
}
jobsMu.Unlock()
for _, job := range changedJobs {
publishJobUpsert(job)
}
}
}()
}

View File

@ -6,6 +6,7 @@ import (
"bytes"
"context"
"crypto/sha1"
"embed"
"encoding/hex"
"encoding/json"
"errors"
@ -58,6 +59,9 @@ func setCoverModelStore(s *ModelStore) { coverModelStore = s }
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
// Routes:
@ -1323,51 +1327,121 @@ func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, er
}
func latestPreviewSegment(previewDir string) (string, error) {
entries, err := os.ReadDir(previewDir)
segDir := previewSegmentsDir(previewDir)
entries, err := os.ReadDir(segDir)
if err != nil {
return "", err
}
var best string
for _, e := range entries {
if e.IsDir() {
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
}
if best == "" || name > best {
best = name
best = e.Name()
}
}
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) {
seg, err := latestPreviewSegment(previewDir)
inputPath, cleanup, err := buildPreviewInputFromDir(previewDir)
if err != nil {
return nil, err
}
img, err := extractLastFrameJPGScaled(seg, 320, 70)
defer cleanup()
img, err := extractLastFrameJPGScaled(inputPath, 320, 70)
if err == nil && len(img) > 0 {
return img, nil
}
return extractFirstFrameJPGScaled(seg, 320, 70)
return extractFirstFrameJPGScaled(inputPath, 320, 70)
}
func extractLastFrameFromPreviewDirJPG(previewDir string) ([]byte, error) {
seg, err := latestPreviewSegment(previewDir)
inputPath, cleanup, err := buildPreviewInputFromDir(previewDir)
if err != nil {
return nil, err
}
img, err := extractLastFrameJPG(seg)
if err != nil {
return extractFirstFrameJPGScaled(seg, 720, 75)
defer cleanup()
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) {
@ -1431,6 +1505,99 @@ func serveLivePreviewJPGBytes(w http.ResponseWriter, b []byte) {
_, _ = 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) {
jobsMu.Lock()
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
}
@ -1552,23 +1719,8 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
previewDir := strings.TrimSpace(j.PreviewDir)
if previewDir != "" {
img, genErr = extractLastFrameFromPreviewDirJPG(previewDir)
}
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)
}
}
if genErr != nil {
//fmt.Println("preview from previewDir failed:", previewDir, genErr)
}
}
@ -1594,16 +1746,15 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
jobsMu.Unlock()
if state == "private" {
servePreviewStatusSVG(w, "Private", http.StatusOK)
serveProviderFallbackOrStatus(w, r, job, job.Output, "Private", http.StatusOK)
return
}
if state == "offline" {
servePreviewStatusSVG(w, "Offline", http.StatusOK)
serveProviderFallbackOrStatus(w, r, job, job.Output, "Offline", http.StatusOK)
return
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK)
return
}
@ -1738,6 +1889,11 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
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)
defer cancel()

View File

@ -57,17 +57,19 @@ type previewSpriteMetaResp struct {
StepSeconds float64 `json:"stepSeconds,omitempty"`
}
type finishedPhaseTruth struct {
MetaReady bool `json:"metaReady"`
TeaserReady bool `json:"teaserReady"`
ThumbReady bool `json:"thumbReady"`
SpritesReady bool `json:"spritesReady"`
AnalyzeReady bool `json:"analyzeReady"`
}
type doneMetaFileResp struct {
File string `json:"file"`
MetaExists bool `json:"metaExists"`
DurationSeconds float64 `json:"durationSeconds,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
FPS float64 `json:"fps,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
PreviewSprite previewSpriteMetaResp `json:"previewSprite"`
AI any `json:"ai,omitempty"`
Error string `json:"error,omitempty"`
File string `json:"file"`
MetaExists bool `json:"metaExists"`
Meta *videoMeta `json:"meta,omitempty"`
Error string `json:"error,omitempty"`
}
type doneMetaResp struct {
@ -90,6 +92,10 @@ type undoDeleteToken struct {
File string `json:"file"` // original basename, z.B. "HOT xyz.mp4"
}
type bulkFilesRequest struct {
Files []string `json:"files"`
}
func encodeUndoDeleteToken(t undoDeleteToken) (string, error) {
b, err := json.Marshal(t)
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) ----------------
type previewSpriteMetaFileInfo struct {
@ -185,7 +488,12 @@ func readPreviewSpriteMetaFromMetaFile(metaPath string) (previewSpriteMetaFileIn
return out, false
}
ps, ok := m["previewSprite"].(map[string]any)
previewMap, ok := m["preview"].(map[string]any)
if !ok || previewMap == nil {
return out, false
}
ps, ok := previewMap["sprite"].(map[string]any)
if !ok || ps == nil {
return out, false
}
@ -314,14 +622,11 @@ func previewSpriteTruthForID(id string) previewSpriteMetaResp {
return out
}
metaPath, err := generatedMetaFile(id)
if err != nil || strings.TrimSpace(metaPath) == "" {
_, _, _, spriteFile, metaPath, err := assetPathsForID(id)
if err != nil {
return out
}
genDir := filepath.Dir(metaPath)
spriteFile := filepath.Join(genDir, "preview-sprite.jpg")
fi, err := os.Stat(spriteFile)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return out
@ -348,11 +653,154 @@ func previewSpriteTruthForID(id string) previewSpriteMetaResp {
return out
}
func numberFromAny(v any) (float64, bool) {
switch x := v.(type) {
case float32:
return float64(x), true
case float64:
return x, true
case int:
return float64(x), true
case int8:
return float64(x), true
case int16:
return float64(x), true
case int32:
return float64(x), true
case int64:
return float64(x), true
case uint:
return float64(x), true
case uint8:
return float64(x), true
case uint16:
return float64(x), true
case uint32:
return float64(x), true
case uint64:
return float64(x), true
case json.Number:
if f, err := x.Float64(); err == nil {
return f, true
}
case string:
s := strings.TrimSpace(x)
if s == "" {
return 0, false
}
if f, err := strconv.ParseFloat(s, 64); err == nil {
return f, true
}
}
return 0, false
}
func finishedPhaseTruthForID(id string) finishedPhaseTruth {
var out finishedPhaseTruth
id = strings.TrimSpace(id)
if id == "" || strings.Contains(id, "/") || strings.Contains(id, "\\") {
return out
}
_, thumbPath, previewPath, spritePath, metaPath, err := assetPathsForID(id)
if err != nil || strings.TrimSpace(metaPath) == "" {
return out
}
b, err := os.ReadFile(metaPath)
if err == nil && len(b) > 0 {
var m map[string]any
dec := json.NewDecoder(strings.NewReader(string(b)))
dec.UseNumber()
if err := dec.Decode(&m); err == nil {
media, _ := m["media"].(map[string]any)
if media != nil {
if d, ok := numberFromAny(media["durationSeconds"]); ok && d > 0 {
video, _ := media["video"].(map[string]any)
if video != nil {
if w, wok := numberFromAny(video["width"]); wok && w > 0 {
if h, hok := numberFromAny(video["height"]); hok && h > 0 {
out.MetaReady = true
}
}
}
}
}
preview, _ := m["preview"].(map[string]any)
if preview != nil {
if clips, ok := preview["clips"].([]any); ok && len(clips) > 0 {
out.TeaserReady = true
}
if thumb, ok := preview["thumb"].(map[string]any); ok && thumb != nil {
if exists, ok := thumb["exists"].(bool); ok && exists {
out.ThumbReady = true
}
if p, ok := thumb["path"].(string); ok && strings.TrimSpace(p) != "" {
out.ThumbReady = true
}
}
if sprite, ok := preview["sprite"].(map[string]any); ok && sprite != nil {
if p, ok := sprite["path"].(string); ok && strings.TrimSpace(p) != "" {
out.SpritesReady = true
}
}
}
analysis, _ := m["analysis"].(map[string]any)
if analysis != nil {
if ai, ok := analysis["ai"].(map[string]any); ok && ai != nil && len(ai) > 0 {
out.AnalyzeReady = true
}
}
}
}
if !out.ThumbReady {
if fi, err := os.Stat(thumbPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
out.ThumbReady = true
}
}
if !out.SpritesReady {
if fi, err := os.Stat(spritePath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
out.SpritesReady = true
}
}
if !out.TeaserReady {
if fi, err := os.Stat(previewPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
out.TeaserReady = true
}
}
return out
}
func applyPreviewSpriteTruthToDoneMetaResp(id string, resp *doneMetaFileResp) {
if resp == nil {
return
}
resp.PreviewSprite = previewSpriteTruthForID(id)
ps := previewSpriteTruthForID(id)
if !ps.Exists {
return
}
if resp.Meta == nil {
resp.Meta = &videoMeta{}
}
resp.Meta.Preview.Sprite = &previewSpriteMeta{
Path: ps.Path,
Count: ps.Count,
Cols: ps.Cols,
Rows: ps.Rows,
StepSeconds: ps.StepSeconds,
}
}
// robust meta setter into RecordJob.Meta (any/string/[]byte/typed map)
@ -502,9 +950,6 @@ func applyPreviewSpriteTruthToRecordJobMeta(j *RecordJob) {
meta = map[string]any{}
}
delete(meta, "previewScrubberPath")
delete(meta, "previewScrubberCount")
psMap := map[string]any{"exists": ps.Exists}
if ps.Exists {
psMap["path"] = ps.Path
@ -521,8 +966,81 @@ func applyPreviewSpriteTruthToRecordJobMeta(j *RecordJob) {
psMap["stepSeconds"] = ps.StepSeconds
}
}
meta["previewSprite"] = psMap
previewMap, _ := meta["preview"].(map[string]any)
if previewMap == nil {
previewMap = map[string]any{}
}
previewMap["sprite"] = psMap
meta["preview"] = previewMap
setStructFieldJSONMap(fv, meta)
}
func applyFinishedPhaseTruthToRecordJobMeta(j *RecordJob) {
if j == nil {
return
}
outPath := strings.TrimSpace(j.Output)
if outPath == "" {
return
}
base := filepath.Base(outPath)
id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base)))
id = strings.TrimSpace(id)
if id == "" {
return
}
truth := finishedPhaseTruthForID(id)
rv := reflect.ValueOf(j)
if rv.Kind() != reflect.Pointer || rv.IsNil() {
return
}
sv := rv.Elem()
if !sv.IsValid() || sv.Kind() != reflect.Struct {
return
}
fv := sv.FieldByName("Meta")
if !fv.IsValid() || !fv.CanSet() {
return
}
var raw any
switch fv.Kind() {
case reflect.Interface:
if fv.IsNil() {
raw = nil
} else {
raw = fv.Interface()
}
default:
raw = fv.Interface()
}
meta := metaMapFromAny(raw)
if meta == nil {
meta = map[string]any{}
}
postworkMap, _ := meta["postwork"].(map[string]any)
if postworkMap == nil {
postworkMap = map[string]any{}
}
postworkMap["completed"] = map[string]any{
"meta": truth.MetaReady,
"teaser": truth.TeaserReady,
"thumb": truth.ThumbReady,
"sprites": truth.SpritesReady,
"analyze": truth.AnalyzeReady,
}
meta["postwork"] = postworkMap
setStructFieldJSONMap(fv, meta)
}
@ -1261,35 +1779,31 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
resp := doneMetaFileResp{File: filepath.Base(outPath)}
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(outPath), filepath.Ext(outPath)))
applyPreviewSpriteTruthToDoneMetaResp(id, &resp)
if strings.TrimSpace(id) != "" {
if mp, merr := generatedMetaFile(id); merr == nil && strings.TrimSpace(mp) != "" {
if mfi, serr := os.Stat(mp); serr == nil && mfi != nil && !mfi.IsDir() && mfi.Size() > 0 {
resp.MetaExists = true
if dur, w2, h2, fps2, ok := readVideoMeta(mp, fi); ok {
resp.DurationSeconds = dur
resp.Width = w2
resp.Height = h2
resp.FPS = fps2
}
if u, ok := readVideoMetaSourceURL(mp, fi); ok {
resp.SourceURL = u
}
if ai, ok := readVideoMetaAI(mp); ok {
resp.AI = ai
if meta, ok := readVideoMetaIfValid(mp, fi); ok && meta != nil {
resp.MetaExists = true
resp.Meta = meta
}
}
}
}
if resp.DurationSeconds <= 0 {
// Sprite-Wahrheit aus Dateisystem/meta ggf. ergänzen/überschreiben
applyPreviewSpriteTruthToDoneMetaResp(id, &resp)
if resp.Meta == nil {
resp.Meta = &videoMeta{}
}
// Fallback Duration, falls Meta fehlt oder unvollständig
if resp.Meta.Media.DurationSeconds <= 0 {
pctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if d, derr := durationSecondsCached(pctx, outPath); derr == nil && d > 0 {
resp.DurationSeconds = d
resp.Meta.Media.DurationSeconds = d
}
}
@ -1715,6 +2229,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
}
applyPreviewSpriteTruthToRecordJobMeta(&c)
applyFinishedPhaseTruthToRecordJobMeta(&c)
out = append(out, &c)
}
@ -1763,6 +2278,62 @@ func renameWithRetryAggressive(src, dst string) error {
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) {
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
http.Error(w, "Nur POST oder DELETE erlaubt", http.StatusMethodNotAllowed)
@ -1779,6 +2350,9 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
return
}
// ✅ laufende/queued späte Nacharbeiten für dieses Asset vorher stoppen
cancelDeferredEnrichForFile(file)
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
@ -2046,6 +2620,9 @@ func recordUnkeepVideo(w http.ResponseWriter, r *http.Request) {
return
}
// ✅ späte Nacharbeiten abbrechen, bevor die Datei aus keep zurück verschoben wird
cancelDeferredEnrichForFile(file)
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
@ -2116,6 +2693,9 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
return
}
// ✅ späte Nacharbeiten abbrechen, bevor die Datei verschoben wird
cancelDeferredEnrichForFile(file)
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
@ -2222,6 +2802,9 @@ func recordToggleHot(w http.ResponseWriter, r *http.Request) {
return
}
// optional, aber sinnvoll
cancelDeferredEnrichForFile(file)
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {

View File

@ -9,7 +9,10 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@ -17,6 +20,25 @@ import (
"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 ---
// Entspricht grob dem RecordStream aus dem Channel-Snippet:
func RecordStream(
@ -28,8 +50,6 @@ func RecordStream(
httpCookie string,
job *RecordJob,
) error {
// 1) Seite laden
// Domain sauber zusammenbauen (mit/ohne Slash)
base := strings.TrimRight(domain, "/")
pageURL := base + "/" + username
@ -38,58 +58,155 @@ func RecordStream(
return fmt.Errorf("seite laden: %w", err)
}
// 2) HLS-URL aus roomDossier extrahieren (wie DVR.ParseStream)
hlsURL, err := ParseStream(body)
if err != nil {
return fmt.Errorf("stream-parsing: %w", err)
}
// 3) Playlist holen (wie stream.GetPlaylist im DVR)
playlist, err := FetchPlaylist(ctx, hc, hlsURL, httpCookie)
if err != nil {
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 {
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()
job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL)
job.PreviewCookie = httpCookie
job.PreviewUA = hc.userAgent
if previewDir != "" {
job.PreviewDir = previewDir
}
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)
if err != nil {
return fmt.Errorf("datei erstellen: %w", err)
}
defer func() {
_ = file.Close()
}()
// live size tracking (für UI)
var written int64
var lastPush time.Time
var lastBytes int64
published := false
// 5) Segmente „watchen“ analog zu WatchSegments + HandleSegment im DVR
err = playlist.WatchSegments(ctx, hc, httpCookie, func(b []byte, duration float64) error {
// Hier wäre im DVR ch.HandleSegment bei dir einfach in eine Datei schreiben
trimPreviewSegments := func(dir string, keep int) {
if strings.TrimSpace(dir) == "" || keep <= 0 {
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 {
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 {
published = true
_ = publishJob(job.ID)
}
// ✅ live size (UI) throttled
written += int64(len(b))
if job != nil {
now := time.Now()
@ -97,15 +214,14 @@ func RecordStream(
jobsMu.Lock()
job.SizeBytes = written
jobsMu.Unlock()
publishJobUpsert(job)
lastPush = now
lastBytes = written
}
}
// Könntest hier z.B. auch Dauer/Größe loggen, wenn du möchtest
_ = duration // aktuell unbenutzt
_ = duration
_ = segmentURL
return nil
})
if err != nil {
@ -160,11 +276,68 @@ func (p *Playlist) WatchSegments(
ctx context.Context,
hc *HTTPClient,
httpCookie string,
handler func([]byte, float64) error,
previewDir string,
handler func(segmentURL string, b []byte, duration float64) error,
) error {
var lastSeq int64 = -1
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 {
select {
@ -173,41 +346,119 @@ func (p *Playlist) WatchSegments(
default:
}
// Playlist holen
if strings.TrimSpace(p.PlaylistURL) == "" {
return errors.New("playlist url fehlt")
}
req, err := hc.NewRequest(ctx, http.MethodGet, p.PlaylistURL, httpCookie)
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)
if err != nil {
emptyRounds++
//fmt.Println("playlist download failed:", p.PlaylistURL, err)
if emptyRounds >= maxEmptyRounds {
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
}
playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
resp.Body.Close()
if err != nil || listType != m3u8.MEDIA {
if err != nil {
emptyRounds++
if emptyRounds >= maxEmptyRounds {
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
}
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
successfulSegment := false
for _, segment := range media.Segments {
if segment == nil {
continue
}
if int64(segment.SeqId) <= lastSeq {
continue
}
@ -215,31 +466,66 @@ func (p *Playlist) WatchSegments(
lastSeq = int64(segment.SeqId)
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)
if err != nil {
//fmt.Println("segment request build failed:", segmentURL, err)
continue
}
segResp, err := hc.client.Do(segReq)
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
}
data, err := io.ReadAll(segResp.Body)
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
}
if err := handler(data, segment.Duration); err != nil {
if err := handler(segmentURL, data, segment.Duration); err != nil {
return err
}
successfulSegment = true
}
if newSegment {
if successfulSegment {
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 {
emptyRounds++
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
}
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)
@ -379,7 +379,6 @@ func handleM3U8Mode(ctx context.Context, m3u8URL, outFile string, job *RecordJob
jobsMu.Lock()
job.SizeBytes = sz
jobsMu.Unlock()
publishJobUpsert(job)
last = sz
}
}

View File

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

View File

@ -70,6 +70,15 @@ type finishedPostworkEvent struct {
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(
j *RecordJob,
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) {
if j == nil {
return
@ -125,10 +152,7 @@ func publishFinishedPostworkStatusForJob(j *RecordJob, ev finishedPostworkEvent)
b, _ := json.Marshal(ev)
// 1) modellbezogen für Running/Downloads
publishSSE(eventName, b)
// 2) global für FinishedDownloads
// nur global für FinishedDownloads
publishSSE("finishedPostwork", b)
}
@ -269,54 +293,6 @@ func visibleJobEventsJSON() []ssePublishItem {
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) {
if j == nil || j.Hidden {
return

View File

@ -3,7 +3,6 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
@ -29,6 +28,9 @@ type AssetsTaskState struct {
FinishedAt *time.Time `json:"finishedAt,omitempty"`
Error string `json:"error,omitempty"`
CurrentFile string `json:"currentFile,omitempty"`
CurrentQueue string `json:"currentQueue,omitempty"`
CurrentPhase string `json:"currentPhase,omitempty"`
CurrentLabel string `json:"currentLabel,omitempty"`
}
var assetsTaskMu sync.Mutex
@ -44,6 +46,7 @@ func updateAssetsState(fn func(st *AssetsTaskState)) AssetsTaskState {
assetsTaskMu.Unlock()
notifyAssetsChanged()
publishTaskState()
return st
}
@ -91,12 +94,15 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
FinishedAt: nil,
Error: "",
CurrentFile: "",
CurrentQueue: "",
CurrentPhase: "",
CurrentLabel: "",
}
st := assetsTaskState
assetsTaskMu.Unlock()
// ✅ SSE: Start pushen
notifyAssetsChanged()
publishTaskState()
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))
st := updateAssetsState(func(st *AssetsTaskState) {
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)
if fileName == "" {
return
@ -143,52 +149,31 @@ func publishAssetsTaskFinishedPostworkState(fileName string, state string, label
base := strings.TrimSuffix(fileName, filepath.Ext(fileName))
assetID := stripHotPrefix(base)
ev := finishedPostworkEvent{
Type: "finished_postwork",
File: fileName,
AssetID: assetID,
Queue: "enrich",
State: state, // "queued" | "running" | "done" | "error" | "missing"
Phase: "assets",
Label: label,
TS: time.Now().UnixMilli(),
}
b, err := json.Marshal(ev)
if err != nil {
return
}
// Für FinishedDownloads reicht der globale Kanal.
publishSSE("finishedPostwork", b)
publishFinishedPostworkPhase(
fileName,
assetID,
queue,
phase,
state,
label,
)
}
func clearAssetsTaskFinishedPostworkState(fileName string) {
fileName = strings.TrimSpace(fileName)
if fileName == "" {
return
func clearAssetsTaskFinishedPostworkStates(fileName string) {
defs := []struct {
queue string
phase string
}{
{queue: "postwork", phase: "meta"},
{queue: "postwork", phase: "thumb"},
{queue: "postwork", phase: "teaser"},
{queue: "postwork", phase: "sprites"},
{queue: "enrich", phase: "analyze"},
}
base := strings.TrimSuffix(fileName, filepath.Ext(fileName))
assetID := stripHotPrefix(base)
ev := finishedPostworkEvent{
Type: "finished_postwork",
File: fileName,
AssetID: assetID,
Queue: "enrich",
State: "missing",
Phase: "assets",
Label: "",
TS: time.Now().UnixMilli(),
for _, def := range defs {
publishAssetsTaskPhase(fileName, def.queue, def.phase, "missing", "")
}
b, err := json.Marshal(ev)
if err != nil {
return
}
publishSSE("finishedPostwork", b)
}
func runGenerateMissingAssets(ctx context.Context) {
@ -206,16 +191,17 @@ func runGenerateMissingAssets(ctx context.Context) {
st.Running = false
st.FinishedAt = &now
st.CurrentFile = ""
st.CurrentQueue = ""
st.CurrentPhase = ""
st.CurrentLabel = ""
if err == nil {
// Erfolg: Error leeren
st.Error = ""
return
}
// stabiler Text für UI
if errors.Is(err, context.Canceled) {
st.Error = "abgebrochen"
st.Error = "Abgebrochen"
} else {
st.Error = err.Error()
}
@ -234,77 +220,48 @@ func runGenerateMissingAssets(ctx context.Context) {
path string
}
// .trash niemals verarbeiten
isTrashPath := func(full string) bool {
p := strings.ToLower(strings.ReplaceAll(full, "\\", "/"))
return strings.Contains(p, "/.trash/") || strings.HasSuffix(p, "/.trash")
}
indexedItems, sortedIdx := buildDoneIndex(doneAbs)
// gleiche Grundreihenfolge wie FinishedDownloads-Default:
// includeKeep = true, sort = completed_desc
order := sortedIdx["1|completed_desc"]
seen := map[string]struct{}{}
items := make([]item, 0, 512)
items := make([]item, 0, len(order))
addIfVideo := func(full string) {
if isTrashPath(full) {
return
for _, idx := range order {
if idx < 0 || idx >= len(indexedItems) {
continue
}
name := filepath.Base(full)
low := strings.ToLower(name)
if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") {
return
j := indexedItems[idx].job
if j == nil {
continue
}
ext := strings.ToLower(filepath.Ext(name))
if ext != ".mp4" && ext != ".ts" {
return
full := strings.TrimSpace(j.Output)
if full == "" {
continue
}
if _, ok := seen[full]; ok {
return
continue
}
seen[full] = struct{}{}
items = append(items, item{name: name, path: full})
items = append(items, item{
name: filepath.Base(full),
path: full,
})
}
scanOneLevel := func(dir string) {
ents, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range ents {
if e.IsDir() && strings.EqualFold(e.Name(), ".trash") {
continue
}
full := filepath.Join(dir, e.Name())
if e.IsDir() {
sub, err := os.ReadDir(full)
if err != nil {
continue
}
for _, se := range sub {
if se.IsDir() {
continue
}
addIfVideo(filepath.Join(full, se.Name()))
}
continue
}
addIfVideo(full)
}
}
// done + done/<model>/ + done/keep + done/keep/<model>/
scanOneLevel(doneAbs)
scanOneLevel(filepath.Join(doneAbs, "keep"))
// ✅ Initialisierung: Total etc. + SSE Push
// Initialisierung: Total etc. + SSE Push
updateAssetsState(func(st *AssetsTaskState) {
st.Total = len(items)
st.Done = 0
st.GeneratedThumbs = 0
st.GeneratedPreviews = 0
st.Skipped = 0
// Start hat Error schon geleert — hier nur sicherheitshalber:
st.Error = ""
})
@ -314,22 +271,16 @@ func runGenerateMissingAssets(ctx context.Context) {
return
}
// aktuellen Dateinamen für UI setzen
// aktuellen Dateinamen für UI setzen
updateAssetsState(func(st *AssetsTaskState) {
st.CurrentFile = it.name
})
publishAssetsTaskFinishedPostworkState(
it.name,
"running",
"Erstelle Vorschau / Thumbnails…",
)
// ID aus Dateiname
base := strings.TrimSuffix(it.name, filepath.Ext(it.name))
id := stripHotPrefix(base)
if strings.TrimSpace(id) == "" {
clearAssetsTaskFinishedPostworkState(it.name)
clearAssetsTaskFinishedPostworkStates(it.name)
updateAssetsState(func(st *AssetsTaskState) {
st.Done = i + 1
@ -340,7 +291,7 @@ func runGenerateMissingAssets(ctx context.Context) {
// Datei-Info (validieren)
vfi, verr := os.Stat(it.path)
if verr != nil || vfi.IsDir() || vfi.Size() <= 0 {
clearAssetsTaskFinishedPostworkState(it.name)
clearAssetsTaskFinishedPostworkStates(it.name)
updateAssetsState(func(st *AssetsTaskState) {
st.Done = i + 1
@ -348,17 +299,46 @@ func runGenerateMissingAssets(ctx context.Context) {
continue
}
// Vorher-Wahrheit ermitteln: was fehlt wirklich?
beforeTruth := finishedPhaseTruthForID(id)
needMeta := !beforeTruth.MetaReady
needThumb := !beforeTruth.ThumbReady
needTeaser := !beforeTruth.TeaserReady
needSprites := !beforeTruth.SpritesReady
needAnalyze := !beforeTruth.AnalyzeReady
// Wenn gar nichts fehlt -> alte evtl. Live-States wegräumen und skippen
if !needMeta && !needThumb && !needTeaser && !needSprites && !needAnalyze {
clearAssetsTaskFinishedPostworkStates(it.name)
updateAssetsState(func(st *AssetsTaskState) {
st.Skipped++
st.Done = i + 1
})
continue
}
// Pfade einmalig über zentralen Helper
_, _, _, _, metaPath, perr := assetPathsForID(id)
if perr != nil {
publishAssetsTaskFinishedPostworkState(
it.name,
"error",
"Assets fehlgeschlagen",
)
if needMeta {
publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen")
}
if needThumb {
publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen")
}
if needTeaser {
publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen")
}
if needSprites {
publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen")
}
if needAnalyze {
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen")
}
updateAssetsState(func(st *AssetsTaskState) {
// UI bekommt stabilen Hinweis, aber Task läuft weiter
st.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)"
st.Done = i + 1
})
@ -372,43 +352,133 @@ func runGenerateMissingAssets(ctx context.Context) {
sourceURL = u
}
// Generate/Ensure (einheitliche Core-Funktion)
res, e := ensureAssetsForVideoWithProgressCtx(ctx, it.path, sourceURL, nil)
if e != nil {
publishAssetsTaskFinishedPostworkState(
it.name,
"error",
"Assets fehlgeschlagen",
)
finishWithErr(e)
return
}
thumbGenerated := false
previewGenerated := false
if _, aerr := prepareVideoForSplit(ctx, it.path, sourceURL, "nsfw"); aerr != nil {
// ----------------
// META
// ----------------
if needMeta {
updateAssetsState(func(st *AssetsTaskState) {
st.Error = "mindestens ein Eintrag konnte nicht vollständig analysiert werden (siehe Logs)"
st.CurrentFile = it.name
st.CurrentQueue = "postwork"
st.CurrentPhase = "meta"
st.CurrentLabel = "Meta"
})
//fmt.Println("⚠️ tasks generate assets analyze:", aerr)
publishAssetsTaskPhase(it.name, "postwork", "meta", "running", "Meta")
okMeta, merr := ensureMetaForVideoCtx(ctx, it.path, sourceURL)
if merr != nil || !okMeta {
publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen")
} else {
publishAssetsTaskPhase(it.name, "postwork", "meta", "done", "Meta")
}
}
if res.Skipped {
clearAssetsTaskFinishedPostworkState(it.name)
} else {
publishAssetsTaskFinishedPostworkState(
it.name,
"done",
"Vorschau / Thumbnails fertig",
)
// ----------------
// THUMB
// ----------------
if needThumb {
updateAssetsState(func(st *AssetsTaskState) {
st.CurrentFile = it.name
st.CurrentQueue = "postwork"
st.CurrentPhase = "thumb"
st.CurrentLabel = "Vorschaubild"
})
publishAssetsTaskPhase(it.name, "postwork", "thumb", "running", "Vorschaubild")
resThumb, terr := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, it.path, sourceURL, nil)
if terr != nil {
publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen")
} else if finishedPhaseTruthForID(id).ThumbReady {
if resThumb.ThumbGenerated {
thumbGenerated = true
}
publishAssetsTaskPhase(it.name, "postwork", "thumb", "done", "Vorschaubild")
} else {
publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen")
}
}
// ----------------
// TEASER
// ----------------
if needTeaser {
updateAssetsState(func(st *AssetsTaskState) {
st.CurrentFile = it.name
st.CurrentQueue = "postwork"
st.CurrentPhase = "teaser"
st.CurrentLabel = "Teaser"
})
publishAssetsTaskPhase(it.name, "postwork", "teaser", "running", "Teaser")
genTeaser, terr := ensureTeaserForVideoCtx(ctx, it.path, sourceURL)
if terr != nil {
publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen")
} else if finishedPhaseTruthForID(id).TeaserReady {
if genTeaser {
previewGenerated = true
}
publishAssetsTaskPhase(it.name, "postwork", "teaser", "done", "Teaser")
} else {
publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen")
}
}
// ----------------
// SPRITES
// ----------------
if needSprites {
updateAssetsState(func(st *AssetsTaskState) {
st.CurrentFile = it.name
st.CurrentQueue = "postwork"
st.CurrentPhase = "sprites"
st.CurrentLabel = "Sprites"
})
publishAssetsTaskPhase(it.name, "postwork", "sprites", "running", "Sprites")
_, serr := ensureSpritesForVideoCtx(ctx, it.path, sourceURL)
if serr != nil {
publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen")
} else if finishedPhaseTruthForID(id).SpritesReady {
publishAssetsTaskPhase(it.name, "postwork", "sprites", "done", "Sprites")
} else {
publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen")
}
}
// ----------------
// ANALYZE
// ----------------
if needAnalyze {
updateAssetsState(func(st *AssetsTaskState) {
st.CurrentFile = it.name
st.CurrentQueue = "enrich"
st.CurrentPhase = "analyze"
st.CurrentLabel = "KI-Analyse"
})
publishAssetsTaskPhase(it.name, "enrich", "analyze", "running", "KI-Analyse")
_, aerr := ensureAnalyzeForVideoCtx(ctx, it.path, sourceURL, "nsfw")
if aerr != nil {
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen")
} else if finishedPhaseTruthForID(id).AnalyzeReady {
publishAssetsTaskPhase(it.name, "enrich", "analyze", "done", "KI-Analyse")
} else {
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen")
}
}
updateAssetsState(func(st *AssetsTaskState) {
if res.Skipped {
st.Skipped++
}
if res.ThumbGenerated {
if thumbGenerated {
st.GeneratedThumbs++
}
if res.PreviewGenerated {
if previewGenerated {
st.GeneratedPreviews++
}
st.Done = i + 1

View File

@ -14,6 +14,12 @@ import (
"time"
)
type cleanupCandidate struct {
path string
name string
size int64
}
type cleanupResp struct {
// Small downloads cleanup
ScannedFiles int `json:"scannedFiles"`
@ -34,6 +40,14 @@ type cleanupReq struct {
BelowMB *int `json:"belowMB,omitempty"`
}
func clearCleanupTaskState() {
cleanupTaskMu.Lock()
cleanupTaskState = CleanupTaskState{}
cleanupTaskMu.Unlock()
publishTaskState()
}
// /api/settings/cleanup (POST)
// - löscht kleine Dateien < threshold MB (mp4/ts; skip .part/.tmp; skip keep-Ordner)
// - räumt Orphans (preview/thumbs + generated) auf
@ -49,6 +63,11 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil || strings.TrimSpace(doneAbs) == "" {
setCleanupTaskError("doneDir auflösung fehlgeschlagen")
time.AfterFunc(7*time.Second, func() {
clearCleanupTaskState()
})
http.Error(w, "doneDir auflösung fehlgeschlagen", http.StatusBadRequest)
return
}
@ -90,6 +109,10 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
orphansTotalRemoved := resp.GeneratedOrphansRemoved
setCleanupTaskDone(fmt.Sprintf("geprüft: %d · Orphans: %d", resp.ScannedFiles, orphansTotalRemoved))
time.AfterFunc(7*time.Second, func() {
clearCleanupTaskState()
})
writeJSON(w, http.StatusOK, resp)
}
@ -103,8 +126,9 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
return ext == ".mp4" || ext == ".ts"
}
// scan: doneAbs + 1-level subdirs, "keep" wird übersprungen
scanDir := func(dir string, allowSubdirs bool) {
candidates := make([]cleanupCandidate, 0, 1024)
collectCandidates := func(dir string, allowSubdirs bool) {
ents, err := os.ReadDir(dir)
if err != nil {
return
@ -125,10 +149,12 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
if err != nil {
continue
}
for _, se := range sub {
if se.IsDir() {
continue
}
name := se.Name()
if !isCandidate(name) {
resp.SkippedFiles++
@ -142,31 +168,15 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
continue
}
resp.ScannedFiles++
if fi.Size() < threshold {
base := strings.TrimSuffix(filepath.Base(p), filepath.Ext(p))
id := stripHotPrefix(base)
if derr := removeWithRetry(p); derr == nil || os.IsNotExist(derr) {
resp.DeletedFiles++
resp.DeletedBytes += fi.Size()
// generated + legacy cleanup (best effort)
if strings.TrimSpace(id) != "" {
removeGeneratedForID(id)
}
purgeDurationCacheForPath(p)
} else {
resp.ErrorCount++
}
}
candidates = append(candidates, cleanupCandidate{
path: p,
name: name,
size: fi.Size(),
})
}
continue
}
// root-level file
name := e.Name()
if !isCandidate(name) {
resp.SkippedFiles++
@ -179,29 +189,43 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
continue
}
resp.ScannedFiles++
if fi.Size() < threshold {
base := strings.TrimSuffix(filepath.Base(full), filepath.Ext(full))
id := stripHotPrefix(base)
if derr := removeWithRetry(full); derr == nil || os.IsNotExist(derr) {
resp.DeletedFiles++
resp.DeletedBytes += fi.Size()
if strings.TrimSpace(id) != "" {
removeGeneratedForID(id)
}
purgeDurationCacheForPath(full)
} else {
resp.ErrorCount++
}
}
candidates = append(candidates, cleanupCandidate{
path: full,
name: name,
size: fi.Size(),
})
}
}
scanDir(doneAbs, true)
collectCandidates(doneAbs, true)
resp.ScannedFiles = len(candidates)
setCleanupTaskProgress(0, resp.ScannedFiles, "", "Prüfe Dateien…")
for i, c := range candidates {
setCleanupTaskProgress(i+1, resp.ScannedFiles, c.name, fmt.Sprintf("Prüfe %s", c.name))
if c.size >= threshold {
continue
}
base := strings.TrimSuffix(filepath.Base(c.path), filepath.Ext(c.path))
id := stripHotPrefix(base)
if derr := removeWithRetry(c.path); derr == nil || os.IsNotExist(derr) {
resp.DeletedFiles++
resp.DeletedBytes += c.size
if strings.TrimSpace(id) != "" {
removeGeneratedForID(id)
}
purgeDurationCacheForPath(c.path)
} else {
resp.ErrorCount++
}
}
}
var generatedGCRunning int32
@ -286,6 +310,23 @@ func runGeneratedGarbageCollector() generatedGCStats {
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
metaRoot, err := generatedMetaRoot()
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"
"strings"
"sync"
"time"
)
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"`
File string `json:"file,omitempty"`
Text string `json:"text,omitempty"`
Error string `json:"error,omitempty"`
}
type TasksStatusResponse struct {
GenerateAssets AssetsTaskState `json:"generateAssets"`
Cleanup CleanupTaskState `json:"cleanup"`
AnyRunning bool `json:"anyRunning"`
GenerateAssets AssetsTaskState `json:"generateAssets"`
RegenerateAssets RegenerateAssetsTaskState `json:"regenerateAssets"`
RegenerateAssetsJobs []map[string]any `json:"regenerateAssetsJobs"`
Cleanup CleanupTaskState `json:"cleanup"`
AnyRunning bool `json:"anyRunning"`
}
var (
cleanupTaskMu sync.Mutex
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 {
cleanupTaskMu.Lock()
st := cleanupTaskState
@ -36,31 +123,60 @@ func snapshotCleanupTaskState() CleanupTaskState {
func setCleanupTaskRunning(text string) {
cleanupTaskMu.Lock()
cleanupTaskState = CleanupTaskState{
Running: true,
Text: strings.TrimSpace(text),
Error: "",
Running: true,
Done: 0,
Total: 0,
Text: strings.TrimSpace(text),
CurrentFile: "",
Error: "",
FinishedAt: nil,
}
cleanupTaskMu.Unlock()
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) {
cleanupTaskMu.Lock()
cleanupTaskState = CleanupTaskState{
Running: false,
Text: strings.TrimSpace(text),
Error: "",
cleanupTaskState.Running = false
cleanupTaskState.Text = strings.TrimSpace(text)
cleanupTaskState.CurrentFile = ""
cleanupTaskState.Error = ""
cleanupTaskState.FinishedAt = cleanupNowISO()
if cleanupTaskState.Total > 0 {
cleanupTaskState.Done = cleanupTaskState.Total
}
cleanupTaskMu.Unlock()
publishTaskState()
}
func setCleanupTaskError(errText string) {
cleanupTaskMu.Lock()
cleanupTaskState = CleanupTaskState{
Running: false,
Text: "",
Error: strings.TrimSpace(errText),
}
cleanupTaskState.Running = false
cleanupTaskState.Text = ""
cleanupTaskState.Error = strings.TrimSpace(errText)
cleanupTaskState.CurrentFile = ""
cleanupTaskState.FinishedAt = cleanupNowISO()
cleanupTaskMu.Unlock()
publishTaskState()
}
func tasksStatusHandler(w http.ResponseWriter, r *http.Request) {
@ -70,12 +186,27 @@ func tasksStatusHandler(w http.ResponseWriter, r *http.Request) {
}
assets := getGenerateAssetsTaskStatus()
regenerate := snapshotRegenerateAssetsTaskState()
regenerateJobs := getRegenerateAssetsJobsSnapshot()
cleanup := snapshotCleanupTaskState()
anyRegenerateRunning := regenerate.Running
if !anyRegenerateRunning {
for _, job := range regenerateJobs {
state := strings.TrimSpace(strings.ToLower(anyToString(job["state"])))
if state == "queued" || state == "running" {
anyRegenerateRunning = true
break
}
}
}
resp := TasksStatusResponse{
GenerateAssets: assets,
Cleanup: cleanup,
AnyRunning: assets.Running || cleanup.Running,
GenerateAssets: assets,
RegenerateAssets: regenerate,
RegenerateAssetsJobs: regenerateJobs,
Cleanup: cleanup,
AnyRunning: assets.Running || anyRegenerateRunning || cleanup.Running,
}
w.Header().Set("Content-Type", "application/json")

View File

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

View File

@ -65,10 +65,11 @@ type RecorderSettingsState = {
teaserPlayback?: 'still' | 'hover' | 'all'
teaserAudio?: boolean
lowDiskPauseBelowGB?: number
enableNotifications?: boolean
}
type JobEvent = {
type?: 'job_upsert' | 'job_remove' | 'finished_postwork'
type?: 'job_upsert' | 'job_remove'
model?: string
jobId?: string
status?: string
@ -110,6 +111,15 @@ type JobEvent = {
maxParallel?: number
}
type TaskStateEvent = {
type?: 'task_state'
generateAssetsRunning?: boolean
regenerateAssetsRunning?: boolean
cleanupRunning?: boolean
anyRunning?: boolean
ts?: number
}
const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
recordDir: 'records',
doneDir: 'records/done',
@ -124,6 +134,7 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
teaserPlayback: 'hover',
teaserAudio: false,
lowDiskPauseBelowGB: 5,
enableNotifications: true,
}
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 {
let v = (raw ?? '').trim()
if (!v) return null
@ -450,6 +482,9 @@ export default function App() {
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)
const cookieProblemLastAtRef = useRef(0)
@ -507,6 +542,22 @@ export default function App() {
const eventSourceRef = useRef<EventSource | null>(null)
const modelEventNamesRef = useRef<Set<string>>(new Set())
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 el = sourceUrlInputRef.current
@ -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(() => {
void 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 now = Date.now()
const cooldownMs = 700 // 👈 wichtig gegen Event-Stürme
@ -853,20 +891,14 @@ export default function App() {
void loadDoneCount()
}, [authed, loadDoneCount])
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)
useEffect(() => {
if (!authed) return
void loadDonePage(donePage, doneSort)
}, [authed, donePage, doneSort, loadDonePage])
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>>({})
useEffect(() => {
setModelsCount(Object.keys(modelsByKey).length)
}, [modelsByKey])
const isChaturbateStoreModel = useCallback((m?: StoredModel | null) => {
const h = String(m?.host ?? '').toLowerCase()
@ -1124,13 +1156,76 @@ export default function App() {
const refreshModelsByKey = useCallback(async () => {
try {
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())
} catch {
// ignore
}
}, [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(
(item: StartQueueItem) => {
const norm0 = normalizeHttpUrl(item.url)
@ -1163,56 +1258,6 @@ export default function App() {
const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent
const modelKey = String(msg?.model ?? '').trim().toLowerCase()
// ✅ finished_postwork direkt an FinishedDownloads weiterreichen
if (msg?.type === 'finished_postwork') {
const file = String(msg?.file ?? '').trim()
if (file) {
window.dispatchEvent(
new CustomEvent('finished-downloads:postwork', {
detail: {
file,
assetId: String(msg?.assetId ?? '').trim() || undefined,
queue:
msg?.queue === 'enrich'
? 'enrich'
: 'postwork',
state:
msg?.state === 'queued' ||
msg?.state === 'running' ||
msg?.state === 'done' ||
msg?.state === 'error' ||
msg?.state === 'missing'
? msg.state
: 'missing',
phase: String(msg?.phase ?? '').trim() || undefined,
label: String(msg?.label ?? '').trim() || undefined,
position:
typeof msg?.position === 'number' && Number.isFinite(msg.position)
? msg.position
: undefined,
waiting:
typeof msg?.waiting === 'number' && Number.isFinite(msg.waiting)
? msg.waiting
: undefined,
running:
typeof msg?.running === 'number' && Number.isFinite(msg.running)
? msg.running
: undefined,
maxParallel:
typeof msg?.maxParallel === 'number' && Number.isFinite(msg.maxParallel)
? msg.maxParallel
: undefined,
ts:
typeof msg?.ts === 'number' && Number.isFinite(msg.ts)
? msg.ts
: Date.now(),
},
})
)
}
return
}
if (msg?.type === 'job_upsert') {
if (modelKey) {
const roomStatus = String(msg?.roomStatus ?? '').trim().toLowerCase()
@ -1223,6 +1268,10 @@ export default function App() {
? Boolean(msg.isOnline)
: roomStatus !== 'offline'
maybeNotifyWatchedModelStatusChange(modelKey, roomStatus, {
imageUrl: String(msg?.modelImageUrl ?? '').trim(),
})
applyRoomStateToModel(modelKey, {
roomStatus,
isOnline,
@ -1247,7 +1296,7 @@ export default function App() {
// ignore
}
}
}, [applyJobDelta, applyRoomStateToModel, enqueueStart])
}, [applyJobDelta, applyRoomStateToModel, enqueueStart, maybeNotifyWatchedModelStatusChange])
useEffect(() => {
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)
if (updated && typeof updated === 'object') {
const k = String(updated.modelKey ?? '').toLowerCase().trim()
if (k) setModelsByKey((prev) => ({ ...prev, [k]: updated }))
if (k) {
setModelsByKey((prev) => ({ ...prev, [k]: updated }))
}
try {
upsertModelCache(updated)
@ -1614,6 +1665,24 @@ export default function App() {
modelsByKeyRef.current = 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)
useEffect(() => {
pendingAutoStartByKeyRef.current = pendingAutoStartByKey
@ -1707,6 +1776,10 @@ export default function App() {
for (const key of keys) {
const snap = byKey[key] ?? { show: 'offline' as const }
maybeNotifyWatchedModelStatusChange(key, snap.show, {
imageUrl: snap.imageUrl,
})
applyPendingRoomSnapshot(key, snap)
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 openPlayer = useCallback((job: RecordJob, startAtSec?: number) => {
@ -2333,6 +2381,7 @@ export default function App() {
void loadDoneCount()
void loadJobs()
void loadTaskStateOnce()
if (selectedTabRef.current === 'finished') {
requestFinishedReload('sse fallback poll')
@ -2340,31 +2389,23 @@ export default function App() {
}, document.hidden ? 60000 : 5000)
}
const lastDoneFireRef = { t: 0 }
let doneCoalesceTimer: number | null = null
const loadTaskStateOnce = async () => {
try {
const res = await fetch('/api/tasks/status', { cache: 'no-store' as any })
if (!res.ok) return
const requestDoneRefresh = () => {
const now = Date.now()
const since = now - lastDoneFireRef.t
const data = await res.json().catch(() => null)
if (since < 800) {
if (doneCoalesceTimer != null) return
doneCoalesceTimer = window.setTimeout(() => {
doneCoalesceTimer = null
lastDoneFireRef.t = Date.now()
void loadDoneCount()
if (selectedTabRef.current === 'finished') {
requestFinishedReload('doneChanged coalesced')
}
}, 900)
return
}
lastDoneFireRef.t = now
void loadDoneCount()
if (selectedTabRef.current === 'finished') {
requestFinishedReload('doneChanged')
setSettingsTaskRunning(
Boolean(
data?.anyRunning ||
data?.generateAssets?.running ||
data?.regenerateAssets?.running ||
data?.cleanup?.running
)
)
} catch {
// ignore
}
}
@ -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 loadDoneCount()
void loadTaskStateOnce()
// Optional initialer Fallback für autostart, bis erstes SSE kommt
void apiJSON<AutostartState>('/api/autostart/state', { cache: 'no-store' as any })
.then((s) => {
setAutostartState({
@ -2398,112 +2507,24 @@ export default function App() {
es.onopen = () => {
stopFallbackPoll()
void loadTaskStateOnce()
}
es.onerror = () => {
startFallbackPoll()
}
const onDoneChanged = () => {
requestDoneRefresh()
}
const onAutostart = (ev: MessageEvent) => {
try {
applyAutostartState(JSON.parse(String(ev.data ?? 'null')))
} catch {
// ignore
}
}
const onFinishedPostwork = (ev: MessageEvent) => {
try {
const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent
const file = String(msg?.file ?? '').trim()
if (!file) return
window.dispatchEvent(
new CustomEvent('finished-downloads:postwork', {
detail: {
file,
assetId: String(msg?.assetId ?? '').trim() || undefined,
queue:
msg?.queue === 'enrich'
? 'enrich'
: 'postwork',
state:
msg?.state === 'queued' ||
msg?.state === 'running' ||
msg?.state === 'done' ||
msg?.state === 'error' ||
msg?.state === 'missing'
? msg.state
: 'missing',
phase: String(msg?.phase ?? '').trim() || undefined,
label: String(msg?.label ?? '').trim() || undefined,
position:
typeof msg?.position === 'number' && Number.isFinite(msg.position)
? msg.position
: undefined,
waiting:
typeof msg?.waiting === 'number' && Number.isFinite(msg.waiting)
? msg.waiting
: undefined,
running:
typeof msg?.running === 'number' && Number.isFinite(msg.running)
? msg.running
: undefined,
maxParallel:
typeof msg?.maxParallel === 'number' && Number.isFinite(msg.maxParallel)
? msg.maxParallel
: undefined,
ts:
typeof msg?.ts === 'number' && Number.isFinite(msg.ts)
? msg.ts
: Date.now(),
},
})
)
} catch {
// ignore
}
}
es.addEventListener('doneChanged', onDoneChanged as any)
es.addEventListener('autostart', onAutostart as any)
es.addEventListener('finishedPostwork', onFinishedPostwork as any)
// initial nur Listener für wirklich relevante Jobs / Queue setzen
// 1) sichtbare Downloads + sichtbare Nacharbeiten
for (const j of jobsRef.current) {
if (!isVisibleDownloadJobForSSE(j) && !isVisiblePostworkJobForSSE(j)) continue
const key = modelEventKeyFromJob(j)
if (key) ensureModelEventListener(key)
}
// 2) sichtbare Finished-Jobs der aktuellen Seite
for (const j of doneJobs) {
const key = modelEventKeyFromDoneJob(j)
if (key) ensureModelEventListener(key)
}
// 3) sichtbare Wartenden-Einträge
for (const p of pendingWatchedRooms) {
const k = String(p?.modelKey ?? '').trim().toLowerCase()
if (k) ensureModelEventListener(k)
}
// 4) watched Models ebenfalls abonnieren
for (const key of watchedModelKeysLower) {
if (key) ensureModelEventListener(key)
}
es.addEventListener('taskState', onTaskState as any)
const onVis = () => {
if (document.hidden) return
void loadJobs()
void loadDoneCount()
void loadTaskStateOnce()
if (selectedTabRef.current === 'finished') {
requestFinishedReload('visibilitychange')
}
@ -2513,17 +2534,13 @@ export default function App() {
return () => {
document.removeEventListener('visibilitychange', onVis)
if (doneCoalesceTimer != null) {
window.clearTimeout(doneCoalesceTimer)
}
stopFallbackPoll()
if (es) {
es.removeEventListener('doneChanged', onDoneChanged as any)
es.removeEventListener('autostart', onAutostart as any)
es.removeEventListener('finishedPostwork', onFinishedPostwork as any)
es.removeEventListener('taskState', onTaskState as any)
const handler = onModelJobEventRef.current
if (handler) {
@ -2537,18 +2554,8 @@ export default function App() {
eventSourceRef.current = null
modelEventNamesRef.current = new Set()
es = null
}
}, [
authed,
loadJobs,
loadDoneCount,
requestFinishedReload,
ensureModelEventListener,
doneJobs,
pendingWatchedRooms,
watchedModelKeysLower,
])
}, [authed, loadJobs, loadDoneCount, requestFinishedReload])
useEffect(() => {
const desired = new Set<string>()
@ -2853,6 +2860,10 @@ export default function App() {
// ✅ Prefetch wieder nachfüllen
void prefetchDonePage(donePage + 1)
// ✅ wichtig
requestFinishedReload('delete success')
void loadDoneCount()
}, 320)
},
onError: async () => {
@ -2881,16 +2892,48 @@ export default function App() {
run: () => apiJSON(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' }),
onSuccess: async () => {
window.setTimeout(() => {
// ✅ Entfernt aus Finished + Running + Player
setDoneJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
// ✅ Done-Liste lokal bereinigen + Seite direkt wieder auffüllen (aus Prefetch)
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))
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))
// Optional: nächste Seite vorladen (schadet nicht)
// ✅ Prefetch wieder nachfüllen
void prefetchDonePage(donePage + 1)
// ✅ Finished-Liste aktiv neu laden
requestFinishedReload('keep success')
void loadDoneCount()
}, 320)
},
onError: async () => {
@ -2901,7 +2944,15 @@ export default function App() {
return
}
},
[runFinishedFileAction, notify, donePage, prefetchDonePage]
[
runFinishedFileAction,
notify,
donePage,
doneSort,
prefetchDonePage,
requestFinishedReload,
loadDoneCount,
]
)
const handleToggleHot = useCallback(
@ -2957,7 +3008,7 @@ export default function App() {
return res
} catch (e: any) {
notify.error('Umbenennen fehlgeschlagen', e?.message ?? String(e))
notify.error('Umbenennen fehlgeschlagen: ', baseName(job.output))
return
}
},
@ -3322,7 +3373,7 @@ export default function App() {
</div>
<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="flex items-center sm:items-start justify-between gap-3 sm:gap-4">
<div className="min-w-0">
@ -3476,7 +3527,7 @@ export default function App() {
</div>
</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">
<Tabs tabs={tabs} value={selectedTab} onChange={setSelectedTab} ariaLabel="Tabs" variant="barUnderline" />
</div>
@ -3535,6 +3586,7 @@ export default function App() {
blurPreviews={Boolean(recSettings.blurPreviews)}
teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
teaserAudio={Boolean(recSettings.teaserAudio)}
pauseTeasers={Boolean(detailsModelKey) || splitModalOpen}
assetNonce={assetNonce}
sortMode={doneSort}
onSortModeChange={(m) => {
@ -3552,7 +3604,6 @@ export default function App() {
{selectedTab === 'settings' ? (
<RecorderSettings
onAssetsGenerated={bumpAssets}
onTaskRunningChange={setSettingsTaskRunning}
/>
) : null}
</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 (
<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 flex items-center justify-center p-4">
@ -147,11 +147,11 @@ export default function CookieModal({
</div>
) : (
<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">
<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="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>
</tr>
</thead>
@ -166,7 +166,7 @@ export default function CookieModal({
<div
className="
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
dark:border-white/10 dark:bg-white/5 dark:text-gray-100
"
@ -189,18 +189,16 @@ export default function CookieModal({
</div>
</td>
<td className="px-4 py-3 text-right align-top">
<button
<td className="w-[110px] px-4 py-3 text-right align-top whitespace-nowrap">
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => removeCookie(c.name)}
className="
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
"
className="text-red-700 dark:text-red-300"
>
Entfernen
</button>
</Button>
</td>
</tr>
))}

View File

@ -642,6 +642,9 @@ export default function Downloads({
const [stopRequestedIds, setStopRequestedIds] = 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 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(() => {
setStopRequestedIds((prev) => {
const keys = Object.keys(prev)
@ -690,6 +723,38 @@ export default function Downloads({
})
}, [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 prevSizeBytesRef = useRef<Record<string, number>>({})
@ -1058,19 +1123,30 @@ export default function Downloads({
widthClassName: 'w-[320px] min-w-[300px]',
cell: (r) => {
if (r.kind !== 'job') {
const pendingKey = pendingRowKey(r.pending)
const isRemovingPending = Boolean(removePendingRequestedKeys[pendingKey])
return (
<div className="flex justify-end pr-2">
<Button
size="sm"
variant="primary"
color="red"
disabled={isRemovingPending || !onRemovePending}
className="shrink-0"
onClick={async (e) => {
e.stopPropagation()
await onRemovePending?.(r.pending)
if (isRemovingPending) return
markRemovePendingRequested(pendingKey)
try {
await onRemovePending?.(r.pending)
} catch {
clearRemovePendingRequested(pendingKey)
}
}}
>
Entfernen
{isRemovingPending ? 'Entferne…' : 'Entfernen'}
</Button>
</div>
)
@ -1083,10 +1159,13 @@ export default function Downloads({
const postworkState = getEffectivePostworkState(j)
const isQueuedPostwork = postworkState === 'queued'
const isRemovingQueuedPostwork = Boolean(removeQueuedPostworkRequestedIds[j.id])
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
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 flags = key ? modelsByKey[key] : undefined
@ -1103,7 +1182,7 @@ export default function Downloads({
<RecordJobActions
job={j}
variant="table"
busy={isQueuedPostwork ? false : isStopping}
busy={buttonBusy}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
@ -1124,7 +1203,13 @@ export default function Downloads({
e.stopPropagation()
if (isQueuedPostwork) {
await onRemoveQueuedPostworkJob?.(j.id)
if (isRemovingQueuedPostwork) return
markRemoveQueuedPostworkRequested(j.id)
try {
await onRemoveQueuedPostworkJob?.(j.id)
} catch {
clearRemoveQueuedPostworkRequested(j.id)
}
return
}
@ -1133,7 +1218,9 @@ export default function Downloads({
await onStopJob(j.id)
}}
>
{isQueuedPostwork ? 'Entfernen' : (isStopping ? 'Stoppe…' : 'Stop')}
{isQueuedPostwork
? (isRemovingQueuedPostwork ? 'Entferne…' : 'Entfernen')
: (isStopping ? 'Stoppe…' : 'Stoppen')}
</Button>
</div>
)
@ -1247,6 +1334,33 @@ export default function Downloads({
}
}, [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 (
<div className="grid gap-3">
<div className="sticky top-[56px] z-20">
@ -1338,7 +1452,7 @@ export default function Downloads({
return (
<SwipeActionRow
key={`dl:${j.id}`}
actionLabel="Stoppen"
actionLabel={isStopping ? 'Stoppe…' : 'Stoppen'}
actionColor="indigo"
disabled={isStopping || isQueuedPostwork}
onAction={async () => {
@ -1347,23 +1461,30 @@ export default function Downloads({
await onStopJob(j.id)
}}
>
<DownloadsCardRow
r={r}
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds}
growingByJobId={growingByJobId}
markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
<div
className={[
'transition-all duration-300',
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',
].filter(Boolean).join(' ')}
>
<DownloadsCardRow
r={r}
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds}
growingByJobId={growingByJobId}
markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
</div>
</SwipeActionRow>
)
})}
@ -1378,33 +1499,49 @@ export default function Downloads({
{pendingRows.map((r) => {
if (r.kind !== 'pending') return null
const pKey = pendingRowKey(r.pending)
const isRemovingPending = Boolean(removePendingRequestedKeys[pKey])
return (
<SwipeActionRow
key={`pending:${pendingRowKey(r.pending)}`}
actionLabel="Entfernen"
key={`pending:${pKey}`}
actionLabel={isRemovingPending ? 'Entferne…' : 'Entfernen'}
actionColor="red"
disabled={!onRemovePending}
disabled={!onRemovePending || isRemovingPending}
onAction={async () => {
await onRemovePending?.(r.pending)
if (isRemovingPending) return
markRemovePendingRequested(pKey)
try {
await onRemovePending?.(r.pending)
} catch {
clearRemovePendingRequested(pKey)
}
}}
>
<DownloadsCardRow
r={r}
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds}
growingByJobId={growingByJobId}
markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
<div
className={[
'transition-all duration-300',
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',
].filter(Boolean).join(' ')}
>
<DownloadsCardRow
r={r}
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds}
growingByJobId={growingByJobId}
markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
</div>
</SwipeActionRow>
)
})}
@ -1429,9 +1566,14 @@ export default function Downloads({
const isBusyPhase = phase !== '' && phase !== 'recording'
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 disabled = isQueuedPostwork ? false : isStopping
const disabled = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping
return (
<SwipeActionRow
@ -1441,7 +1583,13 @@ export default function Downloads({
disabled={disabled}
onAction={async () => {
if (isQueuedPostwork) {
await onRemoveQueuedPostworkJob?.(j.id)
if (isRemovingQueuedPostwork) return
markRemoveQueuedPostworkRequested(j.id)
try {
await onRemoveQueuedPostworkJob?.(j.id)
} catch {
clearRemoveQueuedPostworkRequested(j.id)
}
return
}
@ -1450,23 +1598,31 @@ export default function Downloads({
await onStopJob(j.id)
}}
>
<DownloadsCardRow
r={r}
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds}
growingByJobId={growingByJobId}
markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
<div
className={[
'transition-all duration-300',
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',
!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',
].filter(Boolean).join(' ')}
>
<DownloadsCardRow
r={r}
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds}
growingByJobId={growingByJobId}
markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
</div>
</SwipeActionRow>
)
})}
@ -1483,6 +1639,7 @@ export default function Downloads({
</div>
<Table
rows={downloadJobRows}
rowClassName={rowClassName}
columns={columns}
getRowKey={(r) => (r.kind === 'job' ? `dl:job:${r.job.id}` : `dl:pending:${pendingRowKey(r.pending)}`)}
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'
import * as React from 'react'
import type { RecordJob } from '../../types'
import type {
RecordJob,
GalleryModelFlags,
FinishedPostworkSummary,
} from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview'
import {
StarIcon as StarSolidIcon,
HeartIcon as HeartSolidIcon,
EyeIcon as EyeSolidIcon,
} from '@heroicons/react/24/solid'
import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow'
import { isHotName, stripHotPrefix } from './hotName'
@ -20,43 +19,30 @@ import {
shouldEnableTeaserAudio,
shouldObserveTeasers,
} from './teaserPlayback'
type ModelFlags = {
favorite?: boolean
liked?: boolean | null
watching?: boolean | null
tags?: string
// ✅ Optional für Model-Hover-Preview
image?: string
imageUrl?: string | null
// ✅ Optional für stashapp-artigen Preview-Scrubber
previewScrubberPath?: string
previewScrubberCount?: number
}
type FinishedPostworkBadge = {
file: string
assetId?: string
queue: 'postwork' | 'enrich'
state: 'queued' | 'running' | 'done' | 'error' | 'missing'
phase?: string
label?: string
position?: number
waiting?: number
running?: number
maxParallel?: number
ts: number
}
import Checkbox from './Checkbox'
import {
buildSpriteFrameStyle,
parseJobMeta,
firstNonEmptyString,
readPreviewSpriteInfo,
scrubProgressRatioFromIndex,
} from './previewSprite'
type Props = {
rows: RecordJob[]
selectedKeys: Set<string>
onToggleSelected: (job: RecordJob) => void
onToggleSelectAllPage: (checked: boolean) => void
isLoading?: boolean
blurPreviews?: boolean
durations: Record<string, number>
postworkByFile: Record<string, FinishedPostworkBadge>
postworkByFile: Record<string, FinishedPostworkSummary>
getPostworkSummaryForOutput: (
output: string | undefined,
summaries: Record<string, FinishedPostworkSummary>
) => FinishedPostworkSummary | undefined
teaserState: FinishedDownloadsTeaserState
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode
handleDuration: (job: RecordJob, seconds: number) => void
@ -83,7 +69,7 @@ type Props = {
lower: (s: string) => string
modelsByKey: Record<string, ModelFlags>
modelsByKey: Record<string, GalleryModelFlags>
activeTagSet: Set<string>
onHoverPreviewKeyChange?: (key: string | null) => void
onToggleTagFilter: (tag: string) => void
@ -94,33 +80,7 @@ type Props = {
onSplit?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
// optional queued actions (bevorzugt verwenden, falls vorhanden)
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
forcePreviewMuted?: boolean
}
function normalizeDurationSeconds(value: unknown): number | undefined {
@ -129,49 +89,17 @@ function normalizeDurationSeconds(value: unknown): number | undefined {
return value > 24 * 60 * 60 ? value / 1000 : value
}
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n))
}
const DEFAULT_SPRITE_STEP_SECONDS = 5
function chooseSpriteGrid(count: number): [number, number] {
if (count <= 1) return [1, 1]
const targetRatio = 16 / 9
let bestCols = 1
let bestRows = count
let bestWaste = Number.POSITIVE_INFINITY
let bestRatioScore = Number.POSITIVE_INFINITY
for (let c = 1; c <= count; c++) {
const r = Math.max(1, Math.ceil(count / c))
const waste = c * r - count
const ratio = c / r
const ratioScore = Math.abs(ratio - targetRatio)
if (
waste < bestWaste ||
(waste === bestWaste && ratioScore < bestRatioScore) ||
(waste === bestWaste && ratioScore === bestRatioScore && r < bestRows)
) {
bestWaste = waste
bestRatioScore = ratioScore
bestCols = c
bestRows = r
}
}
return [bestCols, bestRows]
}
export default function FinishedDownloadsGalleryView({
rows,
isLoading,
blurPreviews,
durations,
postworkByFile,
getPostworkSummaryForOutput,
teaserState,
renderPostworkBadge,
selectedKeys,
onToggleSelected,
handleDuration,
handleScrubberClickIndex,
@ -186,7 +114,6 @@ export default function FinishedDownloadsGalleryView({
deletingKeys,
keepingKeys,
removingKeys,
deletedKeys,
registerTeaserHost,
@ -204,12 +131,11 @@ export default function FinishedDownloadsGalleryView({
onToggleWatch,
onSplit,
onAddToDownloads,
enqueueDeleteVideo,
enqueueKeepVideo,
enqueueToggleHot,
forcePreviewMuted,
}: Props) {
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
const observeTeasers = shouldObserveTeasers(teaserState.mode)
const hasAnySelection = selectedKeys.size > 0
// ✅ Wrapper: bei still unregistrieren / nicht registrieren
const registerTeaserHostIfNeeded = React.useCallback(
@ -244,22 +170,27 @@ export default function FinishedDownloadsGalleryView({
// ✅ Auflösung als {w,h} aus meta.json bevorzugen
const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => {
const w =
(typeof (j as any)?.meta?.videoWidth === 'number' && Number.isFinite((j as any).meta.videoWidth)
? (j as any).meta.videoWidth
: 0) ||
(typeof (j as any)?.videoWidth === 'number' && Number.isFinite((j as any).videoWidth)
? (j as any).videoWidth
: 0)
const h =
(typeof (j as any)?.meta?.videoHeight === 'number' && Number.isFinite((j as any).meta.videoHeight)
? (j as any).meta.videoHeight
: 0) ||
(typeof (j as any)?.videoHeight === 'number' && Number.isFinite((j as any).videoHeight)
? (j as any).videoHeight
: 0)
const meta = parseJobMeta((j as any)?.meta)
const w =
meta?.media?.video?.width ??
meta?.file?.video?.width
const h =
meta?.media?.video?.height ??
meta?.file?.video?.height
if (
typeof w === 'number' &&
Number.isFinite(w) &&
w > 0 &&
typeof h === 'number' &&
Number.isFinite(h) &&
h > 0
) {
return { w, h }
}
if (w > 0 && h > 0) return { w, h }
return null
}, [])
@ -294,13 +225,17 @@ export default function FinishedDownloadsGalleryView({
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{rows.map((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
const allowSound = shouldEnableTeaserAudio({
state: teaserState,
itemKey: k,
})
const previewMuted = !allowSound
const previewMuted = forcePreviewMuted ? true : !allowSound
const model = modelNameFromOutput(j.output)
const modelKey = lower(model)
@ -315,15 +250,8 @@ export default function FinishedDownloadsGalleryView({
const isHot = isHotName(fileRaw)
const file = stripHotPrefix(fileRaw)
const postworkBadge =
postworkByFile[fileRaw] ??
postworkByFile[stripHotPrefix(fileRaw)]
const showPostworkBadge =
postworkBadge &&
(postworkBadge.state === 'queued' ||
postworkBadge.state === 'running' ||
postworkBadge.state === 'done' ||
postworkBadge.state === 'error')
const postworkBadge = getPostworkSummaryForOutput(j.output, postworkByFile)
const showPostworkBadge = !!postworkBadge
const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j))
@ -331,8 +259,10 @@ export default function FinishedDownloadsGalleryView({
const resObj = resolutionObjOf(j)
const resLabel = formatResolution(resObj)
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
const deleted = deletedKeys.has(k)
const isRemoving = removingKeys.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)
const modelImageSrc = firstNonEmptyString(
@ -342,10 +272,6 @@ export default function FinishedDownloadsGalleryView({
(j as any)?.meta?.modelImageUrl
)
// Preview-ID wie in FinishedVideoPreview ableiten (HOT entfernen + ext entfernen)
const fileForPreviewId = stripHotPrefix(baseName(j.output || ''))
const previewId = fileForPreviewId.replace(/\.[^.]+$/, '').trim()
// meta robust lesen (Objekt oder JSON-String)
const meta = parseJobMeta((j as any)?.meta)
@ -353,119 +279,30 @@ export default function FinishedDownloadsGalleryView({
// ✅ STASHAPP-LIKE: Sprite-Preview (1 Bild + CSS background-position)
// Erwartet z.B. meta.previewSprite = { path, count, cols, rows, stepSeconds }
// ------------------------------------------------------------
const spritePathRaw = firstNonEmptyString(
meta?.previewSprite?.path,
(meta as any)?.previewSpritePath,
flags?.previewScrubberPath,
previewId ? `/api/preview-sprite/${encodeURIComponent(previewId)}` : undefined
)
const spritePath = spritePathRaw ? spritePathRaw.replace(/\/+$/, '') : undefined
const sprite = readPreviewSpriteInfo((j as any)?.meta, {
fallbackPath: flags?.previewScrubberPath,
fallbackCount: flags?.previewScrubberCount,
})
const spriteStepSecondsRaw =
meta?.previewSprite?.stepSeconds ?? (meta as any)?.previewSpriteStepSeconds
const spriteStepSeconds =
typeof spriteStepSecondsRaw === 'number' &&
Number.isFinite(spriteStepSecondsRaw) &&
spriteStepSecondsRaw > 0
? spriteStepSecondsRaw
: DEFAULT_SPRITE_STEP_SECONDS
// ✅ Fallback-Dauer (meta -> job -> durations cache)
const durationForSprite =
normalizeDurationSeconds(meta?.durationSeconds) ??
normalizeDurationSeconds((j as any)?.durationSeconds) ??
normalizeDurationSeconds(durations[k])
// ✅ Count aus Meta/Flags ODER aus Dauer ableiten
const inferredSpriteCountFromDuration =
typeof durationForSprite === 'number' && durationForSprite > 0
? Math.max(1, Math.min(200, Math.floor(durationForSprite / spriteStepSeconds) + 1))
: undefined
const spriteCountRaw =
meta?.previewSprite?.count ??
(meta as any)?.previewSpriteCount ??
flags?.previewScrubberCount ??
inferredSpriteCountFromDuration
const spriteColsRaw = meta?.previewSprite?.cols ?? (meta as any)?.previewSpriteCols
const spriteRowsRaw = meta?.previewSprite?.rows ?? (meta as any)?.previewSpriteRows
const spriteCount =
typeof spriteCountRaw === 'number' && Number.isFinite(spriteCountRaw)
? Math.max(0, Math.floor(spriteCountRaw))
: 0
// ✅ Wenn cols/rows fehlen, aus Count ableiten (wie Backend)
const [inferredCols, inferredRows] =
spriteCount > 1 ? chooseSpriteGrid(spriteCount) : [0, 0]
const spriteCols =
typeof spriteColsRaw === 'number' && Number.isFinite(spriteColsRaw)
? Math.max(0, Math.floor(spriteColsRaw))
: inferredCols
const spriteRows =
typeof spriteRowsRaw === 'number' && Number.isFinite(spriteRowsRaw)
? Math.max(0, Math.floor(spriteRowsRaw))
: inferredRows
// Optionaler Cache-Buster (wenn du sowas in meta hast)
const spriteVersion =
(typeof meta?.updatedAtUnix === 'number' && Number.isFinite(meta.updatedAtUnix)
? meta.updatedAtUnix
: undefined) ??
(typeof (meta as any)?.fileModUnix === 'number' && Number.isFinite((meta as any).fileModUnix)
? (meta as any).fileModUnix
: undefined) ??
0
const spriteUrl =
spritePath && spriteVersion
? `${spritePath}?v=${encodeURIComponent(String(spriteVersion))}`
: spritePath || undefined
const hasScrubberUi =
Boolean(spriteUrl) &&
spriteCount > 1
const hasSpriteScrubber =
hasScrubberUi &&
spriteCols > 0 &&
spriteRows > 0
// Finales Scrubber-Setup
const scrubberCount = hasScrubberUi ? spriteCount : 0
const scrubberStepSeconds = hasScrubberUi ? spriteStepSeconds : 0
const spriteUrl = sprite.url
const hasScrubberUi = sprite.hasScrubberUi
const hasSpriteScrubber = sprite.hasSpriteScrubber
const scrubberCount = sprite.count
const scrubberStepSeconds = sprite.stepSeconds
const hasScrubber = hasScrubberUi
const activeScrubIndex = scrubIndexByKey[k]
const scrubProgressRatio =
typeof activeScrubIndex === 'number' && scrubberCount > 1
? clamp(activeScrubIndex / (scrubberCount - 1), 0, 1)
: undefined
const scrubProgressRatio = scrubProgressRatioFromIndex(activeScrubIndex, scrubberCount)
// Sprite-Overlay-Frame (kein Request pro Move)
const spriteFrameStyle: React.CSSProperties | undefined =
hasSpriteScrubber && typeof activeScrubIndex === 'number'
? (() => {
const idx = clamp(activeScrubIndex, 0, Math.max(0, spriteCount - 1))
const col = idx % spriteCols
const row = Math.floor(idx / spriteCols)
const posX = spriteCols <= 1 ? 0 : (col / (spriteCols - 1)) * 100
const posY = spriteRows <= 1 ? 0 : (row / (spriteRows - 1)) * 100
return {
backgroundImage: `url("${spriteUrl}")`,
backgroundRepeat: 'no-repeat',
backgroundSize: `${spriteCols * 100}% ${spriteRows * 100}%`,
backgroundPosition: `${posX}% ${posY}%`,
}
})()
: undefined
const spriteFrameStyle = buildSpriteFrameStyle({
spriteUrl,
spriteCols: sprite.cols,
spriteRows: sprite.rows,
spriteCount: sprite.count,
activeIndex: activeScrubIndex,
})
const showModelPreviewInThumb = hoveredModelPreviewKey === k && Boolean(modelImageSrc)
const showScrubberSpriteInThumb = !showModelPreviewInThumb && Boolean(spriteFrameStyle)
@ -473,300 +310,283 @@ export default function FinishedDownloadsGalleryView({
const hideTeaserUnderOverlay =
showModelPreviewInThumb || showScrubberSpriteInThumb
const previewDurationSeconds =
durations[k] ??
normalizeDurationSeconds(meta?.media?.durationSeconds) ??
normalizeDurationSeconds(meta?.file?.durationSeconds)
return (
<div
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 key={k} className="relative">
<div
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
ref={registerTeaserHostIfNeeded(k)}
role="button"
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={() => {
setHoveredThumbKey(k)
onHoverPreviewKeyChange?.(k)
}}
onMouseLeave={() => {
setHoveredThumbKey((prev) => (prev === k ? null : prev))
onHoverPreviewKeyChange?.(null)
setHoveredThumbKey((prev) => (prev === k ? null : prev))
clearScrubIndex(k)
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 className="absolute inset-0 overflow-hidden rounded-t-lg">
<div className="absolute inset-0">
<FinishedVideoPreview
job={j}
getFileName={(p) => stripHotPrefix(baseName(p))}
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
onDuration={handleDuration}
variant="fill"
showPopover={false}
blur={blurPreviews}
animated={shouldAnimateTeaser({
state: teaserState,
itemKey: k,
disabled: hideTeaserUnderOverlay,
})}
animatedMode="teaser"
animatedTrigger="always"
muted={previewMuted}
popoverMuted={previewMuted}
scrubProgressRatio={scrubProgressRatio}
preferScrubProgress={typeof activeScrubIndex === 'number'}
<div
className={[
'relative rounded-lg overflow-visible outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
'bg-white dark:bg-gray-900/40',
'transition-[transform,opacity,box-shadow,background-color] duration-220 ease-out',
'will-change-[transform,opacity]',
!busy && 'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
busy && !isRemoving && 'pointer-events-none opacity-70',
isDeleting &&
'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
isKeeping &&
'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
isRemoving
? 'pointer-events-none opacity-0 translate-y-2 scale-[0.98]'
: 'opacity-100 translate-y-0 scale-100',
]
.filter(Boolean)
.join(' ')}
>
<div
className={[
'absolute left-3 top-3 z-20 transition-opacity duration-150',
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
].join(' ')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onMouseDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={checked}
ariaLabel={`${checked ? 'Abwählen' : 'Auswählen'}: ${baseName(j.output || '') || 'Download'}`}
onChange={() => onToggleSelected(j)}
/>
</div>
{/* ✅ Sprite vorladen (einmal), damit erster Scrub-Move sofort sichtbar ist */}
{hasSpriteScrubber && spriteUrl ? (
<img
src={spriteUrl}
alt=""
className="hidden"
loading="lazy"
decoding="async"
aria-hidden="true"
/>
) : null}
{/* Thumb */}
<div
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
ref={registerTeaserHostIfNeeded(k)}
onMouseEnter={() => {
setHoveredThumbKey(k)
}}
onMouseLeave={() => {
setHoveredThumbKey((prev) => (prev === k ? null : prev))
clearScrubIndex(k)
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
}}
>
{/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */}
<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) */}
{showScrubberSpriteInThumb && spriteFrameStyle ? (
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
<div
className="h-full w-full"
style={spriteFrameStyle}
/>
</div>
) : null}
{/* ✅ Sprite vorladen (einmal), damit erster Scrub-Move sofort sichtbar ist */}
{hasSpriteScrubber && spriteUrl ? (
<img
src={spriteUrl}
alt=""
className="hidden"
loading="lazy"
decoding="async"
aria-hidden="true"
/>
) : null}
{/* ✅ Modelbild-Preview Overlay (hover auf Modelname) */}
{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
{/* ✅ Scrubber-Frame Overlay (Sprite-first = stashapp-like, kein Request pro Move) */}
{showScrubberSpriteInThumb && spriteFrameStyle ? (
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
<div
className="h-full w-full"
style={spriteFrameStyle}
/>
</div>
) : null}
{/* ✅ Modelbild-Preview Overlay (hover auf Modelname) */}
{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>
) : 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>
{/* 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">
{/* ✅ stashapp-like: Dateiname zuerst */}
<div className="min-w-0">
<div className="mt-0.5 flex items-start gap-2 min-w-0">
{isHot ? (
<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">
HOT
</span>
) : null}
{/* 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"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onOpenPlayer(j)
}}
>
{/* 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
className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white"
title={stripHotPrefix(file) || '—'}
>
{stripHotPrefix(file) || '—'}
</span>
</div>
</div>
{showPostworkBadge && renderPostworkBadge ? (
<div className="relative z-20 shrink-0">
{renderPostworkBadge(postworkBadge)}
</div>
) : null}
</div>
{/* ✅ Model darunter + Postwork-Badge inline + rechts die Flags */}
<div className="mt-1 flex items-center justify-between gap-2">
<div className="min-w-0 flex items-center gap-2">
<span
className={[
'block min-w-0 truncate text-xs text-gray-500 dark:text-gray-400',
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>
<div className="mt-0.5 flex items-start gap-2 min-w-0">
{isHot ? (
<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">
HOT
</span>
) : null}
{showPostworkBadge ? (
<span
className={[
'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'
? '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
className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400"
title={stripHotPrefix(file) || '—'}
>
{stripHotPrefix(file) || '—'}
</span>
</span>
) : null}
</div>
</div>
</div>
<div className="shrink-0 flex items-center gap-1.5">
{isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
{isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
{isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
</div>
</div>
{/* Actions (wie CardView: im Footer statt im Video) */}
<div
className="mt-2 shrink-0"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<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) */}
<div
className="mt-2 shrink-0"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<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={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"
{/* 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>
{/* 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>

View File

@ -4,7 +4,12 @@
import * as React from 'react'
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 RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow'
@ -15,34 +20,15 @@ import {
shouldAnimateTeaser,
shouldEnableTeaserAudio,
} from './teaserPlayback'
type SortMode =
| 'completed_desc'
| 'completed_asc'
| 'file_asc'
| 'file_desc'
| 'duration_desc'
| 'duration_asc'
| 'size_desc'
| 'size_asc'
type FinishedPostworkBadge = {
file: string
assetId?: string
queue: 'postwork' | 'enrich'
state: 'queued' | 'running' | 'done' | 'error' | 'missing'
phase?: string
label?: string
position?: number
waiting?: number
running?: number
maxParallel?: number
ts: number
}
import Checkbox from './Checkbox'
import { parseJobMeta } from './previewSprite'
type Props = {
rows: RecordJob[]
isLoading?: boolean
selectedKeys: Set<string>
onToggleSelected: (job: RecordJob) => void
onToggleSelectAllPage: (checked: boolean) => void
// helpers
keyFor: (j: RecordJob) => string
@ -54,8 +40,13 @@ type Props = {
formatBytes: (bytes?: number | null) => string
resolutions: Record<string, { w: number; h: 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
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode
// teaser/preview
canHover: boolean
@ -72,14 +63,14 @@ type Props = {
keepingKeys: 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>
onToggleTagFilter: (tag: string) => void
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
// actions
onOpenPlayer: (job: RecordJob) => void
onSortModeChange: (m: SortMode) => void
onSortModeChange: (m: DoneSortMode) => void
page: number
onPageChange: (page: number) => void
@ -92,16 +83,15 @@ type Props = {
deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean>
// optional queued actions (bevorzugt verwenden, falls vorhanden)
enqueueDeleteVideo?: (job: RecordJob) => boolean
enqueueKeepVideo?: (job: RecordJob) => boolean
enqueueToggleHot?: (job: RecordJob) => boolean
forcePreviewMuted?: boolean
}
export default function FinishedDownloadsTableView({
rows,
isLoading,
selectedKeys,
onToggleSelected,
onToggleSelectAllPage,
keyFor,
baseName,
lower,
@ -112,7 +102,9 @@ export default function FinishedDownloadsTableView({
resolutions,
durations,
postworkByFile,
getPostworkSummaryForOutput,
jobForDetails,
renderPostworkBadge,
canHover,
teaserState,
@ -145,15 +137,13 @@ export default function FinishedDownloadsTableView({
deleteVideo,
keepVideo,
enqueueDeleteVideo,
enqueueKeepVideo,
enqueueToggleHot,
forcePreviewMuted,
}: Props) {
const [sort, setSort] = React.useState<SortState>(null)
const runtimeSecondsForSort = React.useCallback((job: RecordJob) => {
// 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
// 2) Fallback: endedAt-startedAt (only if plausible)
@ -188,16 +178,7 @@ export default function FinishedDownloadsTableView({
}, [])
const parseMeta = React.useCallback((j: RecordJob): any | null => {
const raw: any = (j as any)?.meta
if (!raw) return null
if (typeof raw === 'string') {
try {
return JSON.parse(raw)
} catch {
return null
}
}
return raw
return parseJobMeta((j as any)?.meta)
}, [])
const 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>[]>(() => {
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',
header: 'Vorschau',
widthClassName: 'w-[140px]',
cell: (j) => {
const k = keyFor(j)
const isPreviewActive =
teaserState.activeKey === k || teaserState.hoverKey === k
const allowSound = shouldEnableTeaserAudio({
state: teaserState,
itemKey: k,
})
const previewMuted = !allowSound
const previewMuted = forcePreviewMuted ? true : !allowSound
const spriteInfo = previewSpriteInfoOf(j)
const scrubActiveIndex = scrubActiveByKey[k]
@ -270,18 +299,15 @@ export default function FinishedDownloadsTableView({
className="group relative py-1"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onMouseEnter={() => {
if (canHover) setHoverTeaserKey(k)
}}
onMouseLeave={() => {
if (canHover) setHoverTeaserKey(null)
setScrubActiveIndex(k, undefined)
}}
>
<FinishedVideoPreview
key={`table-preview-${k}-${isPreviewActive ? 'active' : 'idle'}`}
job={jobForDetails(j)}
getFileName={baseName}
durationSeconds={durations[k]}
durationSeconds={durations[k] ?? (j as any)?.meta?.file?.durationSeconds}
muted={previewMuted}
popoverMuted={previewMuted}
onDuration={handleDuration}
@ -296,6 +322,8 @@ export default function FinishedDownloadsTableView({
animatedMode="teaser"
animatedTrigger="always"
assetNonce={assetNonce}
forceActive={isPreviewActive}
teaserPreloadEnabled
/>
{/* Scrub-Frame Overlay */}
@ -332,15 +360,9 @@ export default function FinishedDownloadsTableView({
const modelKey = lower(modelNameFromOutput(j.output))
const tags = parseTags(modelsByKey[modelKey]?.tags)
const postworkBadge =
postworkByFile[fileRaw] ??
postworkByFile[stripHotPrefix(fileRaw)]
const postworkBadge = getPostworkSummaryForOutput(j.output, postworkByFile)
const showPostworkBadge =
postworkBadge &&
(postworkBadge.state === 'queued' ||
postworkBadge.state === 'running' ||
postworkBadge.state === 'done' ||
postworkBadge.state === 'error')
!!postworkBadge && postworkBadge.state !== 'missing'
return (
<div className="min-w-0">
@ -349,32 +371,9 @@ export default function FinishedDownloadsTableView({
{model}
</div>
{showPostworkBadge ? (
<span
className={[
'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}
{showPostworkBadge && renderPostworkBadge
? renderPostworkBadge(postworkBadge)
: null}
</div>
<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}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={
onToggleHot
? async (job) => {
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)
}}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
onSplit={onSplit}
onAddToDownloads={onAddToDownloads}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
@ -572,9 +549,17 @@ export default function FinishedDownloadsTableView({
runtimeOf,
parseTags,
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'
const anyS = s as any
const key = String(anyS.key ?? anyS.columnKey ?? anyS.id ?? '')
@ -629,6 +614,18 @@ export default function FinishedDownloadsTableView({
sort={sort}
onSortChange={handleSortChange}
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}
/>

View File

@ -1,7 +1,7 @@
// frontend\src\components\ui\FinishedVideoPreview.tsx
'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 HoverPopover from './HoverPopover'
import { DEFAULT_INLINE_MUTED, applyInlineVideoPolicy } from './videoPolicy'
@ -79,6 +79,11 @@ export type FinishedVideoPreviewProps = {
scrubProgressRatio?: number
preferScrubProgress?: boolean
forceActive?: boolean
activationNonce?: number
registerTeaserPlay?: (fn: (() => void) | null) => void
holdSpeedActive?: boolean
}
function baseName(path: string) {
@ -124,6 +129,10 @@ export default function FinishedVideoPreview({
teaserPreloadRootMargin = '700px 0px',
scrubProgressRatio,
preferScrubProgress = false,
forceActive= false,
activationNonce = 0,
registerTeaserPlay,
holdSpeedActive = false,
}: FinishedVideoPreviewProps) {
const file = baseName(job.output || '') || getFileName(job.output || '')
const blurCls = blur ? 'blur-md' : ''
@ -142,47 +151,10 @@ export default function FinishedVideoPreview({
return m
}, [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"
// job.meta bleibt Basis, fetchedMeta ergänzt fehlende Felder (z.B. previewClips)
const metaForPreview = useMemo(() => {
if (!meta && !fetchedMeta) return null
if (!meta) return fetchedMeta
if (!fetchedMeta) return meta
return { ...meta, ...fetchedMeta }
}, [meta, fetchedMeta])
// ✅ stabiler Guard für den fallback-fetch (berücksichtigt auch bereits geholtes fetchedMeta)
const hasPreviewClipsInAnyMeta = useMemo(() => {
let pcs: any = (metaForPreview as any)?.previewClips
if (typeof pcs === 'string') {
const s = pcs.trim()
if (s.length > 0) {
try {
const parsed = JSON.parse(s)
if (Array.isArray(parsed) && parsed.length > 0) return true
// falls string vorhanden aber nicht parsebar, behandeln wir ihn trotzdem als "vorhanden"
return true
} catch {
return true
}
}
}
if (Array.isArray(pcs) && pcs.length > 0) return true
const nested = (metaForPreview as any)?.preview?.clips
if (Array.isArray(nested) && nested.length > 0) return true
return false
}, [metaForPreview])
const metaForPreview = meta
const [progressMountTick, setProgressMountTick] = useState(0)
// previewClips mapping: preview.mp4 ist Concatenation von Segmenten
@ -341,7 +313,7 @@ export default function FinishedVideoPreview({
const [inView, setInView] = 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)
// Tick nur für frames-Mode
@ -350,6 +322,15 @@ export default function FinishedVideoPreview({
// Hover-State (für inline hover ODER teaser hover)
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' =
inlineVideo === true || inlineVideo === 'always' ? 'always' : inlineVideo === 'hover' ? 'hover' : 'never'
@ -374,6 +355,68 @@ export default function FinishedVideoPreview({
const inlineRef = useRef<HTMLVideoElement | null>(null)
const teaserMp4Ref = 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<{
inline: HTMLVideoElement | null
@ -477,14 +520,41 @@ export default function FinishedVideoPreview({
} 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(() => {
setTeaserReady(false)
setTeaserOk(true)
setFetchedMeta(null) // ✅ neue Datei/Asset -> alten fallback-meta cache in state verwerfen
// ✅ neue Datei/Asset -> Callback-Dedupe zurücksetzen
emittedDurationRef.current = ''
emittedResolutionRef.current = ''
teaserSoundForcedRef.current = false
setMetaLoaded(false)
}, [previewId, assetNonce, noGenerateTeaser])
@ -560,14 +630,14 @@ export default function FinishedVideoPreview({
}, [teaserPreloadEnabled, teaserPreloadRootMargin, inView])
// --- Tick für "frames"
useEffect(() => {
useEffect(() => {
if (!animated) return
if (animatedMode !== 'frames') return
if (!inView || document.hidden) return
if (!effectiveInView || !pageVisible) return
const id = window.setInterval(() => setLocalTick((t) => t + 1), autoTickMs)
return () => window.clearInterval(id)
}, [animated, animatedMode, inView, autoTickMs])
}, [animated, animatedMode, effectiveInView, pageVisible, autoTickMs])
// --- Thumbnail time (nur frames!)
const thumbTimeSec = useMemo(() => {
@ -606,30 +676,43 @@ export default function FinishedVideoPreview({
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}${noGen}&v=${v}`
}, [previewId, v, noGenerateTeaser])
// --- Inline Video sichtbar?
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 =
animated &&
inView &&
!document.hidden &&
effectiveInView &&
pageVisible &&
videoOk &&
!showingInlineVideo &&
(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 =
hasDuration && typeof effectiveDurationSec === 'number' ? effectiveDurationSec : undefined
// ✅ 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
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)
// aber pro Datei/Wert nur 1x (verhindert doppelte Parent-Requests)
@ -662,63 +745,6 @@ export default function FinishedVideoPreview({
if (did) setMetaLoaded(true)
}, [file, job, onDuration, onResolution, hasDuration, hasResolution, effectiveDurationSec, effectiveW, effectiveH])
// ✅ meta fallback fetch: nur wenn previewClips fehlen (für teaser-sprünge)
// und pro Datei in dieser Komponenteninstanz nur 1x
useEffect(() => {
if (!previewId) return
if (!file) return
if (!animated || animatedMode !== 'teaser') return
if (!shouldPreloadAnimatedAssets) return
// ✅ wenn wir schon previewClips aus job.meta ODER fetchedMeta haben -> kein Request
if (hasPreviewClipsInAnyMeta) return
// ✅ gleicher file-Request in dieser Instanz schon gemacht -> nicht nochmal feuern
if (fetchedMetaFilesRef.current.has(file)) return
let aborted = false
const ctrl = new AbortController()
const tryFetch = async (url: string) => {
try {
const res = await fetch(url, {
signal: ctrl.signal,
cache: 'no-store',
credentials: 'include',
})
if (!res.ok) return null
return await res.json()
} catch {
return null
}
}
;(async () => {
const byFile = await tryFetch(`/api/record/done/meta?file=${encodeURIComponent(file)}`)
if (aborted) return
// ✅ als "versucht" markieren (auch wenn null), damit nearView/inView/hover nicht spammt
fetchedMetaFilesRef.current.add(file)
if (byFile) {
setFetchedMeta(byFile)
}
})()
return () => {
aborted = true
ctrl.abort()
}
}, [
previewId,
file,
animated,
animatedMode,
shouldPreloadAnimatedAssets,
hasPreviewClipsInAnyMeta,
])
// ✅ Fallback: nur wenn wir wirklich noch nichts haben, über loadedmetadata nachladen
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
setMetaLoaded(true)
@ -784,9 +810,9 @@ export default function FinishedVideoPreview({
? clipsRef
: null
const showProgressBar =
Boolean(progressVideoRef) &&
inView
const showProgressBar =
Boolean(progressVideoRef) &&
effectiveInView
const progressKind: ProgressKind =
showingInlineVideo ? 'inline' : teaserActive && animatedMode === 'teaser' ? 'teaser' : 'clips'
@ -888,18 +914,226 @@ export default function FinishedVideoPreview({
if (!vv) return
const active = teaserActive && animatedMode === 'teaser'
if (!active) {
setTeaserPlaybackRate(1)
}
const clearRetryTimer = () => {
if (teaserPlayRetryTimerRef.current != null) {
window.clearTimeout(teaserPlayRetryTimerRef.current)
teaserPlayRetryTimerRef.current = null
}
}
if (!active) {
clearRetryTimer()
try {
vv.pause()
} catch {}
return
}
applyInlineVideoPolicy(vv, { muted })
let cancelled = false
const p = vv.play?.()
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
}, [teaserActive, animatedMode, teaserSrc, muted])
const tryPlay = (opts?: { resetToStart?: boolean }) => {
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
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)
useEffect(() => {
@ -1008,7 +1242,7 @@ export default function FinishedVideoPreview({
// ✅ brauchen wir noch hidden-metadata-load?
const needHiddenMeta =
(nearView || inView) &&
(forceActive || effectiveNearView || effectiveInView) &&
(onDuration || onResolution) &&
!metaLoaded &&
!showingInlineVideo &&
@ -1024,9 +1258,22 @@ export default function FinishedVideoPreview({
const previewNode = (
<div
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}
onMouseLeave={wantsHover ? () => setHovered(false) : undefined}
onFocus={wantsHover ? () => setHovered(true) : undefined}
onBlur={wantsHover ? () => setHovered(false) : undefined}
data-duration={hasDuration ? String(effectiveDurationSec) : undefined}
@ -1041,7 +1288,16 @@ export default function FinishedVideoPreview({
loading={alwaysLoadStill ? 'eager' : 'lazy'}
decoding="async"
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)}
/>
) : (
@ -1100,7 +1356,7 @@ export default function FinishedVideoPreview({
teaserMp4Ref.current = el
bumpMountTickIfNew('teaser', el)
}}
key={`teaser-mp4-${previewId}`}
key={`teaser-mp4-${previewId}-${forceActive ? 'force' : 'normal'}-${activationNonce}`}
src={teaserSrc}
className={[
'absolute inset-0 w-full h-full object-cover pointer-events-none',
@ -1110,14 +1366,12 @@ export default function FinishedVideoPreview({
]
.filter(Boolean)
.join(' ')}
muted={muted}
muted={teaserSoundForcedRef.current ? false : muted}
playsInline
autoPlay
loop
preload={teaserReady ? 'auto' : 'metadata'}
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
onLoadedData={() => setTeaserReady(true)}
onPlaying={() => setTeaserReady(true)}
onError={() => {
setTeaserOk(false)
setTeaserReady(false)
@ -1143,6 +1397,20 @@ export default function FinishedVideoPreview({
/>
) : 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 */}
{showAnyProgress ? (
<div

View File

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

View File

@ -56,7 +56,7 @@ export default function LabeledSwitch({
// With left label and description Beispiel
return (
<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}
</label>

View File

@ -56,6 +56,7 @@ type ModalProps = {
bodyClassName?: string
leftClassName?: string
rightClassName?: string
className?: string
/**
@ -87,15 +88,14 @@ export default function Modal({
footer,
icon,
width = 'max-w-lg',
layout = 'single',
left,
leftWidthClass = 'lg:w-80',
scroll,
bodyClassName,
leftClassName,
rightClassName,
className,
rightHeader,
rightBodyClassName,
mobileCollapsedImageSrc,
@ -112,30 +112,6 @@ export default function Modal({
useEffect(() => {
if (!open) return
const html = document.documentElement
const body = document.body
const prevHtmlOverflow = html.style.overflow
const prevBodyOverflow = body.style.overflow
const prevBodyPaddingRight = body.style.paddingRight
// verhindert Layout-Shift wenn Scrollbar verschwindet
const scrollBarWidth = window.innerWidth - html.clientWidth
html.style.overflow = 'hidden'
body.style.overflow = 'hidden'
if (scrollBarWidth > 0) body.style.paddingRight = `${scrollBarWidth}px`
return () => {
html.style.overflow = prevHtmlOverflow
body.style.overflow = prevBodyOverflow
body.style.paddingRight = prevBodyPaddingRight
}
}, [open])
useEffect(() => {
if (!open) return
// reset when opening
setMobileCollapsed(false)
}, [open])
@ -165,7 +141,7 @@ export default function Modal({
return (
<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 */}
<Transition.Child
as={Fragment}
@ -180,7 +156,7 @@ export default function Modal({
</Transition.Child>
{/* 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">
<Transition.Child
as={Fragment}

View File

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

View File

@ -57,10 +57,15 @@ export default function ModelPreview({
// ✅ page visibility als REF (kein Rerender-Fanout bei visibilitychange)
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)
const [inView, setInView] = useState(false)
const inViewRef = useRef(false)
const shouldRenderPreview = inView && pageVisible
const [localTick, setLocalTick] = useState(0)
const [directImgError, setDirectImgError] = useState(false)
@ -71,10 +76,6 @@ export default function ModelPreview({
const hadSuccess = 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 => {
if (typeof v === 'number' && Number.isFinite(v)) return v
if (v instanceof Date) return v.getTime()
@ -365,57 +366,58 @@ export default function ModelPreview({
e.stopPropagation()
}}
>
{!apiImgError ? (
<img
src={currentImgSrc}
loading={inView ? 'eager' : 'lazy'}
fetchPriority={inView ? 'high' : 'auto'}
decoding="async"
alt=""
className={['block w-full h-full object-cover object-center', blurCls].filter(Boolean).join(' ')}
onLoad={() => {
hadSuccess.current = true
fastTries.current = 0
if (retryT.current) window.clearTimeout(retryT.current)
{shouldRenderPreview ? (
!apiImgError ? (
<img
src={currentImgSrc}
loading="eager"
fetchPriority="high"
decoding="async"
alt=""
className={['block w-full h-full object-cover object-center', blurCls].filter(Boolean).join(' ')}
onLoad={() => {
hadSuccess.current = true
fastTries.current = 0
if (retryT.current) window.clearTimeout(retryT.current)
// nur den aktuell genutzten Pfad als "ok" markieren
if (useDirectThumb) setDirectImgError(false)
else setApiImgError(false)
}}
onError={() => {
// 1) Wenn direkte preview.jpg fehlschlägt -> auf API-Fallback umschalten
if (useDirectThumb) {
setDirectImgError(true)
return
}
if (useDirectThumb) setDirectImgError(false)
else setApiImgError(false)
}}
onError={() => {
if (useDirectThumb) {
setDirectImgError(true)
return
}
// 2) API-Fallback fehlschlägt -> bisherige Retry-Logik
setApiImgError(true)
setApiImgError(true)
if (!fastRetryMs) return
if (!inViewRef.current || !pageVisibleRef.current) return
if (hadSuccess.current) return
if (!fastRetryMs) return
if (!inViewRef.current || !pageVisibleRef.current) return
if (hadSuccess.current) return
const startMs = alignStartAt ? toMs(alignStartAt) : NaN
const windowMs = Number(fastRetryWindowMs ?? 60_000)
const withinWindow = !Number.isFinite(startMs) || Date.now() - startMs < windowMs
if (!withinWindow) return
const startMs = alignStartAt ? toMs(alignStartAt) : NaN
const windowMs = Number(fastRetryWindowMs ?? 60_000)
const withinWindow = !Number.isFinite(startMs) || Date.now() - startMs < windowMs
if (!withinWindow) return
const max = Number(fastRetryMax ?? 25)
if (fastTries.current >= max) return
const max = Number(fastRetryMax ?? 25)
if (fastTries.current >= max) return
if (retryT.current) window.clearTimeout(retryT.current)
retryT.current = window.setTimeout(() => {
fastTries.current += 1
setApiImgError(false) // API erneut probieren
setLocalTick((x) => x + 1)
}, fastRetryMs)
}}
/>
if (retryT.current) window.clearTimeout(retryT.current)
retryT.current = window.setTimeout(() => {
fastTries.current += 1
setApiImgError(false)
setLocalTick((x) => x + 1)
}, 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">
keine Vorschau
</div>
<div className="absolute inset-0 bg-gray-100 dark:bg-white/5" />
)}
</div>
</HoverPopover>

View File

@ -1,10 +1,16 @@
// frontend\src\components\ui\Pagination.tsx
'use client'
import * as React from 'react'
import clsx from 'clsx'
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/20/solid'
type PageItem = number | 'ellipsis'
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronDoubleLeftIcon,
ChevronDoubleRightIcon,
ChevronDownIcon,
} from '@heroicons/react/20/solid'
export type PaginationProps = {
page: number // 1-based
@ -12,106 +18,27 @@ export type PaginationProps = {
totalItems: number
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
/** Wrapper Klassen */
className?: string
/** Labels (optional) */
ariaLabel?: string
prevLabel?: string
nextLabel?: string
firstLabel?: string
lastLabel?: string
}
function clamp(n: number, min: number, max: number) {
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({
active,
disabled,
rounded,
onClick,
children,
title,
}: {
active?: boolean
disabled?: boolean
rounded?: 'l' | 'r' | 'none'
onClick?: () => void
@ -128,14 +55,12 @@ function PageButton({
onClick={disabled ? undefined : onClick}
title={title}
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,
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
active
? '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'
: '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'
disabled ? 'opacity-100 cursor-not-allowed' : 'cursor-pointer',
'bg-white text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50',
'dark:bg-slate-800 dark:text-gray-200 dark:inset-ring-slate-700 dark:hover:bg-slate-700'
)}
aria-current={active ? 'page' : undefined}
>
{children}
</button>
@ -147,15 +72,18 @@ export default function Pagination({
pageSize,
totalItems,
onPageChange,
siblingCount = 1,
boundaryCount = 1,
showSummary = true,
showSummary = false,
className,
ariaLabel = 'Pagination',
prevLabel = 'Previous',
nextLabel = 'Next',
prevLabel = 'Vorherige Seite',
nextLabel = 'Nächste Seite',
firstLabel = 'Erste Seite',
lastLabel = 'Letzte Seite',
}: 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)
if (totalPages <= 1) return null
@ -163,100 +91,97 @@ export default function Pagination({
const from = totalItems === 0 ? 0 : (current - 1) * pageSize + 1
const to = Math.min(current * pageSize, totalItems)
const items = getPageItems(totalPages, current, boundaryCount, siblingCount)
const go = (p: number) => onPageChange(clamp(p, 1, totalPages))
return (
<div
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
)}
>
{/* Mobile: nur Previous/Next */}
<div className="flex flex-1 justify-between sm:hidden">
<button
type="button"
onClick={() => go(current - 1)}
disabled={current <= 1}
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"
>
{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>
<div className="flex flex-col items-center gap-2">
{showSummary ? (
<p className="text-sm text-gray-700 dark:text-gray-300 px-2">
Showing <span className="font-medium">{from}</span> to{' '}
<span className="font-medium">{to}</span> of{' '}
<span className="font-medium">{totalItems}</span> results
</p>
) : null}
{/* Desktop: Summary + Zahlen */}
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
{showSummary ? (
<p className="text-sm text-gray-700 dark:text-gray-300 px-2">
Showing <span className="font-medium">{from}</span> to{' '}
<span className="font-medium">{to}</span> of{' '}
<span className="font-medium">{totalItems}</span> results
</p>
) : null}
</div>
<div>
<nav
aria-label={ariaLabel}
className="isolate inline-flex -space-x-px rounded-md shadow-xs dark:shadow-none"
<nav
aria-label={ariaLabel}
className="isolate inline-flex -space-x-px rounded-md shadow-xs dark:shadow-none"
>
<PageButton
rounded="l"
onClick={() => go(1)}
disabled={current <= 1}
title={firstLabel}
>
<button
type="button"
onClick={() => go(current - 1)}
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>
<span className="sr-only">{firstLabel}</span>
<ChevronDoubleLeftIcon aria-hidden="true" className="size-5" />
</PageButton>
{items.map((it, idx) => {
if (it === 'ellipsis') {
<PageButton
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 (
<span
key={`e-${idx}`}
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"
>
</span>
<option key={p} value={p} className="text-black dark:text-white">
{p} von {totalPages}
</option>
)
}
})}
</select>
return (
<PageButton
key={it}
active={it === current}
onClick={() => go(it)}
rounded="none"
>
{it}
</PageButton>
)
})}
<ChevronDownIcon
aria-hidden="true"
className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-gray-500 dark:text-gray-400"
/>
</div>
<button
type="button"
onClick={() => go(current + 1)}
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"
>
<span className="sr-only">{nextLabel}</span>
<ChevronRightIcon aria-hidden="true" className="size-5" />
</button>
</nav>
</div>
<PageButton
rounded="none"
onClick={() => go(current + 1)}
disabled={current >= totalPages}
title={nextLabel}
>
<span className="sr-only">{nextLabel}</span>
<ChevronRightIcon aria-hidden="true" className="size-5" />
</PageButton>
<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>
)
}
}

View File

@ -20,6 +20,7 @@ import RecordJobActions from './RecordJobActions'
import Button from './Button'
import { apiUrl, apiFetch } from '../../lib/api'
import LiveVideo from './LiveVideo'
import { formatResolution, formatFps } from './formatters'
const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || ''
const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
@ -103,17 +104,6 @@ const pickNum = (...vals: any[]): number | 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 {
const fileRaw = baseName(output || '')
const file = stripHotPrefix(fileRaw)
@ -432,19 +422,31 @@ export default function Player({
setPreviewSrc(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(
() => 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]
)
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])
React.useEffect(() => {
@ -541,9 +543,15 @@ export default function Player({
const updateIntrinsicDims = React.useCallback(() => {
const p: any = playerRef.current
if (!p || p.isDisposed?.()) return
const w = typeof p.videoWidth === 'function' ? p.videoWidth() : 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 =
pickNum(
intrH,
intrSize?.h,
(playerRef.current as any)?.videoHeight?.(),
anyJob.videoHeight,
anyJob.height,
@ -1211,7 +1219,7 @@ export default function Player({
// Fallback wenn nur Höhe bekannt ist
// (lieber stabiler fallback als kaputt)
return 16 / 9
}, [job, intrH])
}, [job, intrSize])
const clampMiniRect = React.useCallback(
(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">
<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'}
aria-label={liveMuted ? 'Ton an' : 'Stumm'}
onClick={() => {

View File

@ -60,7 +60,6 @@ const DEFAULTS: RecorderSettings = {
type Props = {
onAssetsGenerated?: () => void
onTaskRunningChange?: (running: boolean) => void
}
function shortTaskFilename(name?: string, max = 52) {
@ -70,7 +69,16 @@ function shortTaskFilename(name?: string, max = 52) {
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 [saving, setSaving] = useState(false)
const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState<number>(0)
@ -138,18 +146,11 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
status: 'idle',
title: 'Aufräumen',
text: '',
cancellable: false,
cancellable: true,
fading: false,
})
useEffect(() => {
const running =
assetsTask.status === 'running' ||
cleanupTask.status === 'running' ||
cleaning
onTaskRunningChange?.(running)
}, [assetsTask.status, cleanupTask.status, cleaning, onTaskRunningChange])
const [regenerateAssetsTasks, setRegenerateAssetsTasks] = useState<TaskItem[]>([])
const pauseGB = Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB ?? 5)
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(() => {
let alive = true
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') {
setErr(null)
setMsg(null)
@ -417,6 +607,20 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
}, 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() {
setErr(null)
setMsg(null)
@ -450,7 +654,7 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
text: 'Räume auf…',
err: undefined,
done: 0,
total: 1,
total: 0,
fading: false,
}))
@ -477,13 +681,13 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
setCleanupTask((t) => ({
...t,
status: 'done',
done: 1,
total: 1,
done: scannedFiles,
total: scannedFiles,
title: 'Aufräumen',
text: `geprüft: ${scannedFiles} · Orphans: ${orphansTotalRemoved}`,
}))
fadeOutTask(setCleanupTask) // ✅ nach und nach ausfaden
fadeOutTask(setCleanupTask, 7000, 500)
} catch (e: any) {
const msg = e?.message ?? String(e)
setErr(msg)
@ -494,8 +698,8 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
text: 'Fehler beim Aufräumen.',
err: msg,
}))
fadeOutTask(setCleanupTask)
fadeOutTask(setCleanupTask, 7000, 500)
} finally {
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 (
<Card
header={
@ -553,9 +795,18 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
>
<div className="space-y-4">
<TaskList
tasks={[assetsTask, cleanupTask]} // ✅ cleanupTask ist “am Ende”
tasks={visibleTasks}
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
doubleTapMs?: number
doubleTapMaxMovePx?: number
pressDelayMs?: number
enablePressHold?: boolean
onPressStart?: (info: { pointerType: string; button: number }) => void
onPressEnd?: () => void
}
export type SwipeCardHandle = {
@ -109,6 +113,10 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
hotTargetSelector = '[data-hot-target]',
doubleTapMs = 360,
doubleTapMaxMovePx = 48,
pressDelayMs = 220,
enablePressHold = true,
onPressStart,
onPressEnd,
},
ref
) {
@ -116,6 +124,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const outerRef = React.useRef<HTMLDivElement | null>(null)
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 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) => {
dxRef.current = nextDx
if (rafRef.current != null) return
@ -170,6 +196,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}, [])
const reset = React.useCallback(() => {
clearPressTimer()
longPressActiveRef.current = false
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
@ -189,7 +218,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
window.setTimeout(() => {
setAnimMs(0)
}, snapMs)
}, [snapMs])
}, [snapMs, clearPressTimer])
const commit = React.useCallback(
async (dir: 'left' | 'right', runAction: boolean) => {
@ -210,18 +239,19 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
dxRef.current = 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) => {
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) {
setAnimMs(snapMs)
@ -373,10 +403,10 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
React.useEffect(() => {
return () => {
if (tapTimerRef.current != null) window.clearTimeout(tapTimerRef.current)
if (rafRef.current != null) cancelAnimationFrame(rafRef.current)
clearTapTimer()
clearPressTimer()
}
}, [])
}, [clearTapTimer, clearPressTimer])
React.useImperativeHandle(
ref,
@ -468,6 +498,30 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
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
widthRef.current = w
thresholdRef.current = Math.min(thresholdPx, w * thresholdRatio)
@ -492,6 +546,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
if (!enabled || disabled) return
if (pointer.current.id !== e.pointerId) return
if (pointer.current.noSwipe) return
if (longPressActiveRef.current) return
const ddx = e.clientX - pointer.current.startX
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 (absY > absX * LOCK_RATIO) {
clearPressTimer()
pointer.current.axisLocked = 'y'
pointer.current.id = null
return
@ -517,6 +573,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
if (pointer.current.axisLocked !== 'x') return
if (!pointer.current.dragging) {
clearPressTimer()
pointer.current.dragging = true
;(e.currentTarget as HTMLElement).style.touchAction = 'none'
setAnimMs(0)
@ -564,6 +621,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const wasCaptured = pointer.current.captured
const wasTapIgnored = pointer.current.tapIgnored
const wasLongPress = longPressActiveRef.current
clearPressTimer()
pointer.current.id = null
pointer.current.dragging = 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'
if (wasLongPress) {
longPressActiveRef.current = false
onPressEnd?.()
return
}
if (!wasDragging) {
const now = Date.now()
const last = lastTapRef.current
@ -666,6 +732,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}}
onPointerCancel={(e) => {
if (!enabled || disabled) return
endLongPress()
clearPressTimer()
if (pointer.current.captured && pointer.current.id != null) {
try {

View File

@ -8,33 +8,23 @@ type SwitchSize = 'default' | 'short'
type SwitchVariant = 'simple' | 'icon'
export type SwitchProps = {
/** Controlled */
checked: boolean
onChange: (checked: boolean) => void
/** Optional wiring */
id?: string
name?: string
disabled?: boolean
required?: boolean
/** Labeling / a11y */
ariaLabel?: string
ariaLabelledby?: string
ariaDescribedby?: string
/** UI */
size?: SwitchSize
variant?: SwitchVariant
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({
checked,
onChange,
@ -60,27 +50,16 @@ export default function Switch({
)
if (size === 'short') {
// Short toggle Beispiel
return (
<div
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',
className
)}
>
<span
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'
)}
/>
<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" />
<input
id={id}
name={name}
@ -98,24 +77,16 @@ export default function Switch({
)
}
// Default size (simple / icon) Beispiele
return (
<div
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',
checked && 'bg-indigo-600 dark:bg-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',
disabled && 'opacity-60',
className
)}
>
{variant === 'icon' ? (
<span
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 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">
<span
aria-hidden="true"
className={clsx(
@ -134,7 +105,6 @@ export default function Switch({
</svg>
</span>
{/* On icon */}
<span
aria-hidden="true"
className={clsx(
@ -148,12 +118,7 @@ export default function Switch({
</span>
</span>
) : (
<span
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'
)}
/>
<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" />
)}
<input
@ -171,4 +136,4 @@ export default function Switch({
/>
</div>
)
}
}

View File

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

View File

@ -30,6 +30,17 @@ type ToastInternal = Toast & {
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 = {
push: (t: Omit<Toast, 'id'>) => string
remove: (id: string) => void
@ -39,6 +50,8 @@ type ToastContextValue = {
const ToastContext = React.createContext<ToastContextValue | null>(null)
const TOAST_LEAVE_MS = 220
const TOAST_SWIPE_DISMISS_PX = 96
const TOAST_SWIPE_LOCK_PX = 10
function iconFor(type: ToastType) {
switch (type) {
@ -137,6 +150,9 @@ export function ToastProvider({
const toastRemainingMsRef = React.useRef<Record<string, number>>({})
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 autoId = autoCloseTimersRef.current[id]
if (autoId) {
@ -153,8 +169,9 @@ export function ToastProvider({
delete toastStartedAtRef.current[id]
delete toastRemainingMsRef.current[id]
delete toastPausedRef.current[id]
delete swipeStateRef.current[id]
}, [])
const loadNotificationSetting = React.useCallback(async () => {
try {
const r = await fetch('/api/settings', { cache: 'no-store' })
@ -193,12 +210,23 @@ export function ToastProvider({
const remove = React.useCallback(
(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)))
clearTimersFor(id)
finalizeRemoveTimersRef.current[id] = window.setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id))
delete finalizeRemoveTimersRef.current[id]
delete swipeStateRef.current[id]
}, TOAST_LEAVE_MS)
},
[clearTimersFor]
@ -314,10 +342,134 @@ export function ToastProvider({
[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(() => {
return () => {
Object.values(autoCloseTimersRef.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 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 (
<Transition
key={t.id}
@ -390,15 +563,22 @@ export function ToastProvider({
'shadow-sm',
'ring-1 ring-black/5 dark:ring-white/5',
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),
].join(' ')}
style={swipeStyle}
onMouseEnter={() => pauseAutoCloseTimer(t.id)}
onMouseLeave={() => resumeAutoCloseTimer(t.id)}
onFocus={() => pauseAutoCloseTimer(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={() => {
if (swipe.dragging || Math.abs(swipe.dx) > 8) return
if (!t.onClick) return
t.onClick()
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
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 {
if (!r) return ''

View File

@ -1,3 +1,5 @@
// frontend\src\components\ui\notify.ts
'use client'
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 = {
state: 'queued' | 'running' | 'missing'
position?: number // 1..n (nur queued)
waiting?: number // Anzahl wartend
running?: number // Anzahl running
maxParallel?: number // cap(ffmpegSem)
position?: number
waiting?: number
running?: number
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
// entspricht meta.json
durationSeconds: number
fileSize: number
fileModUnix: number
videoWidth?: number
videoHeight?: number
fps?: number
resolution?: string
sourceUrl?: string
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 = {
id: string
@ -40,12 +177,11 @@ export type RecordJob = {
videoHeight?: number
fps?: number
meta?: VideoMeta
meta?: RecordJobMeta | string
phase?: string
progress?: number
// ✅ NEU: Postwork-Queue Status/Position
postWorkKey?: string
postWork?: PostWorkKeyStatus
@ -82,3 +218,169 @@ export type Model = {
updatedAt?: 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
}
}