Merge branch 'main' of https://git.rother-woelki.de/chris/nsfwapp
# Conflicts: # backend/recorder_settings.json # frontend/src/components/ui/FinishedDownloads.tsx
This commit is contained in:
commit
c5739f7d44
@ -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
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
BIN
backend/assets/img/mfc.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
591
backend/main.go
591
backend/main.go
@ -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
|
||||
|
||||
730
backend/meta.go
730
backend/meta.go
@ -1,4 +1,5 @@
|
||||
// backend/meta.go
|
||||
// backend\meta.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@ -25,28 +26,78 @@ type previewSpriteMeta struct {
|
||||
Count int `json:"count"` // Anzahl Frames im Sprite
|
||||
Cols int `json:"cols"` // Spalten im Tile
|
||||
Rows int `json:"rows"` // Zeilen im Tile
|
||||
StepSeconds float64 `json:"stepSeconds"` // Zeitabstand zwischen Frames (z.B. 5)
|
||||
StepSeconds float64 `json:"stepSeconds"` // Zeitabstand zwischen Frames
|
||||
CellWidth int `json:"cellWidth,omitempty"`
|
||||
CellHeight int `json:"cellHeight,omitempty"`
|
||||
}
|
||||
|
||||
type videoFileMeta struct {
|
||||
Size int64 `json:"size"`
|
||||
ModUnix int64 `json:"modUnix"`
|
||||
SourceURL string `json:"sourceUrl,omitempty"`
|
||||
}
|
||||
|
||||
type videoStreamMeta struct {
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
FPS float64 `json:"fps,omitempty"`
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
}
|
||||
|
||||
type videoMediaMeta struct {
|
||||
DurationSeconds float64 `json:"durationSeconds"`
|
||||
Video videoStreamMeta `json:"video"`
|
||||
}
|
||||
|
||||
type previewThumbMeta struct {
|
||||
Path string `json:"path,omitempty"`
|
||||
Exists bool `json:"exists,omitempty"`
|
||||
}
|
||||
|
||||
type previewMeta struct {
|
||||
Clips []previewClip `json:"clips,omitempty"`
|
||||
Thumb *previewThumbMeta `json:"thumb,omitempty"`
|
||||
Sprite *previewSpriteMeta `json:"sprite,omitempty"`
|
||||
}
|
||||
|
||||
type analysisMeta struct {
|
||||
AI *aiAnalysisMeta `json:"ai,omitempty"`
|
||||
}
|
||||
|
||||
type postworkHistoryEntry struct {
|
||||
Queue string `json:"queue"`
|
||||
Phase string `json:"phase"`
|
||||
State string `json:"state"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Position int `json:"position,omitempty"`
|
||||
Waiting int `json:"waiting,omitempty"`
|
||||
Running int `json:"running,omitempty"`
|
||||
MaxParallel int `json:"maxParallel,omitempty"`
|
||||
TS int64 `json:"ts"`
|
||||
}
|
||||
|
||||
type postworkCompletedMeta struct {
|
||||
Meta bool `json:"meta,omitempty"`
|
||||
Teaser bool `json:"teaser,omitempty"`
|
||||
Thumb bool `json:"thumb,omitempty"`
|
||||
Sprites bool `json:"sprites,omitempty"`
|
||||
Analyze bool `json:"analyze,omitempty"`
|
||||
}
|
||||
|
||||
type postworkMeta struct {
|
||||
Completed *postworkCompletedMeta `json:"completed,omitempty"`
|
||||
History []postworkHistoryEntry `json:"history,omitempty"`
|
||||
}
|
||||
|
||||
type videoMeta struct {
|
||||
Version int `json:"version"`
|
||||
DurationSeconds float64 `json:"durationSeconds"`
|
||||
FileSize int64 `json:"fileSize"`
|
||||
FileModUnix int64 `json:"fileModUnix"`
|
||||
|
||||
VideoWidth int `json:"videoWidth,omitempty"`
|
||||
VideoHeight int `json:"videoHeight,omitempty"`
|
||||
FPS float64 `json:"fps,omitempty"`
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
|
||||
SourceURL string `json:"sourceUrl,omitempty"`
|
||||
PreviewClips []previewClip `json:"previewClips,omitempty"`
|
||||
PreviewSprite *previewSpriteMeta `json:"previewSprite,omitempty"`
|
||||
AI *aiAnalysisMeta `json:"ai,omitempty"`
|
||||
|
||||
Version int `json:"version"`
|
||||
UpdatedAtUnix int64 `json:"updatedAtUnix"`
|
||||
|
||||
File videoFileMeta `json:"file"`
|
||||
Media videoMediaMeta `json:"media"`
|
||||
Preview previewMeta `json:"preview,omitempty"`
|
||||
Analysis analysisMeta `json:"analysis,omitempty"`
|
||||
Postwork postworkMeta `json:"postwork,omitempty"`
|
||||
}
|
||||
|
||||
type aiSegmentMeta struct {
|
||||
@ -66,54 +117,20 @@ type aiAnalysisMeta struct {
|
||||
AnalyzedAtUnix int64 `json:"analyzedAtUnix,omitempty"`
|
||||
}
|
||||
|
||||
// liest Meta (v2 ODER altes v1) und validiert gegen fi (Size/ModTime)
|
||||
func readVideoMeta(metaPath string, fi os.FileInfo) (dur float64, w int, h int, fps float64, ok bool) {
|
||||
b, err := os.ReadFile(metaPath)
|
||||
if err != nil || len(b) == 0 {
|
||||
m, ok := readVideoMetaIfValid(metaPath, fi)
|
||||
if !ok || m == nil {
|
||||
return 0, 0, 0, 0, false
|
||||
}
|
||||
|
||||
// 1) Neues Format (oder v1 mit gleichen Feldern)
|
||||
var m videoMeta
|
||||
if err := json.Unmarshal(b, &m); err == nil && (m.Version == 2 || m.Version == 1) {
|
||||
if m.FileSize != fi.Size() || m.FileModUnix != fi.ModTime().Unix() {
|
||||
return 0, 0, 0, 0, false
|
||||
}
|
||||
if m.DurationSeconds <= 0 {
|
||||
return 0, 0, 0, 0, false
|
||||
}
|
||||
return m.DurationSeconds, m.VideoWidth, m.VideoHeight, m.FPS, true
|
||||
}
|
||||
|
||||
// 2) Fallback: ganz altes v1-Format (nur Duration etc.)
|
||||
var m1 struct {
|
||||
Version int `json:"version"`
|
||||
DurationSeconds float64 `json:"durationSeconds"`
|
||||
FileSize int64 `json:"fileSize"`
|
||||
FileModUnix int64 `json:"fileModUnix"`
|
||||
UpdatedAtUnix int64 `json:"updatedAtUnix"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &m1); err != nil {
|
||||
return 0, 0, 0, 0, false
|
||||
}
|
||||
if m1.Version != 1 {
|
||||
return 0, 0, 0, 0, false
|
||||
}
|
||||
if m1.FileSize != fi.Size() || m1.FileModUnix != fi.ModTime().Unix() {
|
||||
return 0, 0, 0, 0, false
|
||||
}
|
||||
if m1.DurationSeconds <= 0 {
|
||||
return 0, 0, 0, 0, false
|
||||
}
|
||||
return m1.DurationSeconds, 0, 0, 0, true
|
||||
return m.Media.DurationSeconds, m.Media.Video.Width, m.Media.Video.Height, m.Media.Video.FPS, true
|
||||
}
|
||||
|
||||
func readVideoMetaDuration(metaPath string, fi os.FileInfo) (float64, bool) {
|
||||
m, ok := readVideoMetaIfValid(metaPath, fi)
|
||||
if !ok || m == nil || m.DurationSeconds <= 0 {
|
||||
if !ok || m == nil || m.Media.DurationSeconds <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return m.DurationSeconds, true
|
||||
return m.Media.DurationSeconds, true
|
||||
}
|
||||
|
||||
func metaJSONPathForAssetID(assetID string) (string, error) {
|
||||
@ -128,14 +145,12 @@ func metaJSONPathForAssetID(assetID string) (string, error) {
|
||||
}
|
||||
|
||||
func ensureVideoMetaForFile(ctx context.Context, fullPath string, fi os.FileInfo, sourceURL string) (*videoMeta, bool) {
|
||||
// assetID aus Dateiname
|
||||
stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath))
|
||||
assetID := stripHotPrefix(strings.TrimSpace(stem))
|
||||
if assetID == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// sanitize wie bei deinen generated Ordnern
|
||||
var err error
|
||||
assetID, err = sanitizeID(assetID)
|
||||
if err != nil || assetID == "" {
|
||||
@ -147,12 +162,10 @@ func ensureVideoMetaForFile(ctx context.Context, fullPath string, fi os.FileInfo
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// 1) valid meta vorhanden?
|
||||
if m, ok := readVideoMetaIfValid(metaPath, fi); ok {
|
||||
return m, true
|
||||
}
|
||||
|
||||
// 2) sonst neu erzeugen (mit Concurrency-Limit)
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
@ -166,38 +179,26 @@ func ensureVideoMetaForFile(ctx context.Context, fullPath string, fi os.FileInfo
|
||||
defer durSem.Release()
|
||||
}
|
||||
|
||||
// Dauer
|
||||
dur, derr := durationSecondsCached(cctx, fullPath)
|
||||
if derr != nil || dur <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Video props
|
||||
w, h, fps, perr := probeVideoProps(cctx, fullPath)
|
||||
if perr != nil {
|
||||
// width/height/fps dürfen 0 bleiben, duration ist aber trotzdem nützlich
|
||||
w, h, fps = 0, 0, 0
|
||||
}
|
||||
|
||||
// meta dir anlegen
|
||||
_ = os.MkdirAll(filepath.Dir(metaPath), 0o755)
|
||||
|
||||
m := &videoMeta{
|
||||
Version: 2,
|
||||
DurationSeconds: dur,
|
||||
FileSize: fi.Size(),
|
||||
FileModUnix: fi.ModTime().Unix(),
|
||||
VideoWidth: w,
|
||||
VideoHeight: h,
|
||||
FPS: fps,
|
||||
Resolution: formatResolution(w, h),
|
||||
SourceURL: strings.TrimSpace(sourceURL),
|
||||
UpdatedAtUnix: time.Now().Unix(),
|
||||
if err := writeVideoMeta(metaPath, fi, dur, w, h, fps, sourceURL); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
b, _ := json.MarshalIndent(m, "", " ")
|
||||
b = append(b, '\n')
|
||||
_ = atomicWriteFile(metaPath, b) // best effort
|
||||
m, ok := readVideoMetaLoose(metaPath)
|
||||
if !ok || m == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return m, true
|
||||
}
|
||||
@ -211,45 +212,41 @@ func attachMetaToJobBestEffort(ctx context.Context, job *RecordJob, fullPath str
|
||||
return
|
||||
}
|
||||
|
||||
// Stat
|
||||
fi, err := os.Stat(fullPath)
|
||||
if err != nil || fi == nil || fi.IsDir() {
|
||||
return
|
||||
}
|
||||
|
||||
// Größe immer mitgeben (macht Sort/Anzeige einfacher)
|
||||
if job.SizeBytes <= 0 {
|
||||
job.SizeBytes = fi.Size()
|
||||
}
|
||||
|
||||
// Meta.json lesen/erzeugen (best effort)
|
||||
m, ok := ensureVideoMetaForFileBestEffort(ctx, fullPath, job.SourceURL)
|
||||
if !ok || m == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Optional: komplettes Meta mitsenden
|
||||
job.Meta = m
|
||||
|
||||
// Und zusätzlich die "Top-Level" Felder befüllen (für Frontend bequem)
|
||||
if job.DurationSeconds <= 0 && m.DurationSeconds > 0 {
|
||||
job.DurationSeconds = m.DurationSeconds
|
||||
if strings.Contains(strings.TrimSpace(fullPath), "alice_kosmos_03_24_2026__03-48-34") {
|
||||
b, _ := json.MarshalIndent(m, "", " ")
|
||||
fmt.Println("ATTACH META SNAPSHOT:", string(b))
|
||||
}
|
||||
if job.VideoWidth <= 0 && m.VideoWidth > 0 {
|
||||
job.VideoWidth = m.VideoWidth
|
||||
|
||||
if job.DurationSeconds <= 0 && m.Media.DurationSeconds > 0 {
|
||||
job.DurationSeconds = m.Media.DurationSeconds
|
||||
}
|
||||
if job.VideoHeight <= 0 && m.VideoHeight > 0 {
|
||||
job.VideoHeight = m.VideoHeight
|
||||
if job.VideoWidth <= 0 && m.Media.Video.Width > 0 {
|
||||
job.VideoWidth = m.Media.Video.Width
|
||||
}
|
||||
if job.FPS <= 0 && m.FPS > 0 {
|
||||
job.FPS = m.FPS
|
||||
if job.VideoHeight <= 0 && m.Media.Video.Height > 0 {
|
||||
job.VideoHeight = m.Media.Video.Height
|
||||
}
|
||||
if job.FPS <= 0 && m.Media.Video.FPS > 0 {
|
||||
job.FPS = m.Media.Video.FPS
|
||||
}
|
||||
}
|
||||
|
||||
// ensureVideoMetaForFileBestEffort:
|
||||
// - versucht zuerst echtes Generieren (ffprobe/ffmpeg) via ensureVideoMetaForFile
|
||||
// - wenn das fehlschlägt, aber durationSecondsCacheOnly schon was weiß:
|
||||
// schreibt eine Duration-only meta.json, damit wir künftig "aus meta.json" lesen können.
|
||||
func ensureVideoMetaForFileBestEffort(ctx context.Context, fullPath string, sourceURL string) (*videoMeta, bool) {
|
||||
fullPath = strings.TrimSpace(fullPath)
|
||||
if fullPath == "" {
|
||||
@ -261,23 +258,21 @@ func ensureVideoMetaForFileBestEffort(ctx context.Context, fullPath string, sour
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// 1) Normaler Weg: meta erzeugen/lesen (ffprobe/ffmpeg)
|
||||
if m, ok := ensureVideoMetaForFile(ctx, fullPath, fi, sourceURL); ok && m != nil {
|
||||
return m, true
|
||||
}
|
||||
|
||||
// 2) Fallback: wenn wir Duration schon im RAM-Cache haben -> meta.json (Duration-only) persistieren
|
||||
dur := durationSecondsCacheOnly(fullPath, fi)
|
||||
if dur <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath))
|
||||
|
||||
assetID := stripHotPrefix(strings.TrimSpace(stem))
|
||||
if assetID == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
assetID, err = sanitizeID(assetID)
|
||||
if err != nil || assetID == "" {
|
||||
return nil, false
|
||||
@ -291,7 +286,6 @@ func ensureVideoMetaForFileBestEffort(ctx context.Context, fullPath string, sour
|
||||
_ = os.MkdirAll(filepath.Dir(metaPath), 0o755)
|
||||
_ = writeVideoMetaDuration(metaPath, fi, dur, sourceURL)
|
||||
|
||||
// nochmal lesen/validieren
|
||||
if m, ok := readVideoMetaIfValid(metaPath, fi); ok && m != nil {
|
||||
return m, true
|
||||
}
|
||||
@ -309,63 +303,162 @@ func readVideoMetaIfValid(metaPath string, fi os.FileInfo) (*videoMeta, bool) {
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if m.Version != 1 && m.Version != 2 {
|
||||
if m.Version != 3 {
|
||||
return nil, false
|
||||
}
|
||||
if m.FileSize != fi.Size() || m.FileModUnix != fi.ModTime().Unix() {
|
||||
if m.File.Size != fi.Size() || m.File.ModUnix != fi.ModTime().Unix() {
|
||||
return nil, false
|
||||
}
|
||||
if m.DurationSeconds <= 0 {
|
||||
if m.Media.DurationSeconds <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return &m, true
|
||||
}
|
||||
|
||||
func readVideoMetaLoose(metaPath string) (*videoMeta, bool) {
|
||||
b, err := os.ReadFile(metaPath)
|
||||
if err != nil || len(b) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var m videoMeta
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if m.Version == 0 {
|
||||
m.Version = 3
|
||||
}
|
||||
|
||||
return &m, true
|
||||
}
|
||||
|
||||
func mergePreviewSpriteMeta(existing, incoming *previewSpriteMeta) *previewSpriteMeta {
|
||||
if existing == nil && incoming == nil {
|
||||
return nil
|
||||
}
|
||||
if existing == nil {
|
||||
cp := *incoming
|
||||
return &cp
|
||||
}
|
||||
if incoming == nil {
|
||||
cp := *existing
|
||||
return &cp
|
||||
}
|
||||
|
||||
out := *incoming
|
||||
|
||||
if strings.TrimSpace(out.Path) == "" {
|
||||
out.Path = existing.Path
|
||||
}
|
||||
if out.Count <= 0 {
|
||||
out.Count = existing.Count
|
||||
}
|
||||
if out.Cols <= 0 {
|
||||
out.Cols = existing.Cols
|
||||
}
|
||||
if out.Rows <= 0 {
|
||||
out.Rows = existing.Rows
|
||||
}
|
||||
if out.StepSeconds <= 0 {
|
||||
out.StepSeconds = existing.StepSeconds
|
||||
}
|
||||
if out.CellWidth <= 0 {
|
||||
out.CellWidth = existing.CellWidth
|
||||
}
|
||||
if out.CellHeight <= 0 {
|
||||
out.CellHeight = existing.CellHeight
|
||||
}
|
||||
|
||||
return &out
|
||||
}
|
||||
|
||||
func hasAIAnalysisMeta(ai *aiAnalysisMeta) bool {
|
||||
if ai == nil {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(ai.Goal) != "" {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(ai.Mode) != "" {
|
||||
return true
|
||||
}
|
||||
if len(ai.Hits) > 0 {
|
||||
return true
|
||||
}
|
||||
if len(ai.Segments) > 0 {
|
||||
return true
|
||||
}
|
||||
if ai.AnalyzedAtUnix > 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readVideoMetaSourceURL(metaPath string, fi os.FileInfo) (string, bool) {
|
||||
m, ok := readVideoMetaIfValid(metaPath, fi)
|
||||
if !ok || m == nil {
|
||||
return "", false
|
||||
}
|
||||
u := strings.TrimSpace(m.SourceURL)
|
||||
u := strings.TrimSpace(m.File.SourceURL)
|
||||
if u == "" {
|
||||
return "", false
|
||||
}
|
||||
return u, true
|
||||
}
|
||||
|
||||
// Voll-Write (wenn du dur + props schon hast)
|
||||
func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64, w int, h int, fps float64, sourceURL string) error {
|
||||
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var existing *videoMeta
|
||||
if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil {
|
||||
if old, ok := readVideoMetaLoose(metaPath); ok && old != nil {
|
||||
existing = old
|
||||
}
|
||||
|
||||
m := videoMeta{
|
||||
Version: 2,
|
||||
DurationSeconds: dur,
|
||||
FileSize: fi.Size(),
|
||||
FileModUnix: fi.ModTime().Unix(),
|
||||
VideoWidth: w,
|
||||
VideoHeight: h,
|
||||
FPS: fps,
|
||||
Resolution: formatResolution(w, h),
|
||||
SourceURL: strings.TrimSpace(sourceURL),
|
||||
UpdatedAtUnix: time.Now().Unix(),
|
||||
|
||||
PreviewClips: nil,
|
||||
PreviewSprite: nil,
|
||||
AI: nil,
|
||||
Version: 3,
|
||||
UpdatedAtUnix: time.Now().Unix(),
|
||||
File: videoFileMeta{
|
||||
Size: fi.Size(),
|
||||
ModUnix: fi.ModTime().Unix(),
|
||||
SourceURL: strings.TrimSpace(sourceURL),
|
||||
},
|
||||
Media: videoMediaMeta{
|
||||
DurationSeconds: dur,
|
||||
Video: videoStreamMeta{
|
||||
Width: w,
|
||||
Height: h,
|
||||
FPS: fps,
|
||||
Resolution: formatResolution(w, h),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if existing != nil {
|
||||
m.PreviewClips = existing.PreviewClips
|
||||
m.PreviewSprite = existing.PreviewSprite
|
||||
m.AI = existing.AI
|
||||
m.Preview = existing.Preview
|
||||
m.Analysis = existing.Analysis
|
||||
m.Postwork = existing.Postwork
|
||||
|
||||
if m.File.SourceURL == "" {
|
||||
m.File.SourceURL = existing.File.SourceURL
|
||||
}
|
||||
if m.Media.Video.Width <= 0 {
|
||||
m.Media.Video.Width = existing.Media.Video.Width
|
||||
}
|
||||
if m.Media.Video.Height <= 0 {
|
||||
m.Media.Video.Height = existing.Media.Video.Height
|
||||
}
|
||||
if m.Media.Video.FPS <= 0 {
|
||||
m.Media.Video.FPS = existing.Media.Video.FPS
|
||||
}
|
||||
if m.Media.Video.Resolution == "" {
|
||||
m.Media.Video.Resolution = existing.Media.Video.Resolution
|
||||
}
|
||||
}
|
||||
|
||||
buf, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -378,33 +471,63 @@ func writeVideoMetaWithPreviewClips(metaPath string, fi os.FileInfo, dur float64
|
||||
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
|
||||
return nil
|
||||
}
|
||||
assetID := strings.TrimSpace(filepath.Base(filepath.Dir(metaPath)))
|
||||
|
||||
var existing *videoMeta
|
||||
if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil {
|
||||
if old, ok := readVideoMetaLoose(metaPath); ok && old != nil {
|
||||
existing = old
|
||||
}
|
||||
|
||||
m := videoMeta{
|
||||
Version: 2,
|
||||
DurationSeconds: dur,
|
||||
FileSize: fi.Size(),
|
||||
FileModUnix: fi.ModTime().Unix(),
|
||||
VideoWidth: w,
|
||||
VideoHeight: h,
|
||||
FPS: fps,
|
||||
Resolution: formatResolution(w, h),
|
||||
SourceURL: strings.TrimSpace(sourceURL),
|
||||
PreviewClips: clips,
|
||||
UpdatedAtUnix: time.Now().Unix(),
|
||||
Version: 3,
|
||||
UpdatedAtUnix: time.Now().Unix(),
|
||||
File: videoFileMeta{
|
||||
Size: fi.Size(),
|
||||
ModUnix: fi.ModTime().Unix(),
|
||||
SourceURL: strings.TrimSpace(sourceURL),
|
||||
},
|
||||
Media: videoMediaMeta{
|
||||
DurationSeconds: dur,
|
||||
Video: videoStreamMeta{
|
||||
Width: w,
|
||||
Height: h,
|
||||
FPS: fps,
|
||||
Resolution: formatResolution(w, h),
|
||||
},
|
||||
},
|
||||
Preview: previewMeta{
|
||||
Clips: clips,
|
||||
Thumb: buildPreviewThumbMeta(assetID),
|
||||
},
|
||||
}
|
||||
|
||||
if existing != nil {
|
||||
m.AI = existing.AI
|
||||
m.Analysis = existing.Analysis
|
||||
m.Postwork = existing.Postwork
|
||||
|
||||
if existing.Preview.Sprite != nil && m.Preview.Sprite == nil {
|
||||
m.Preview.Sprite = existing.Preview.Sprite
|
||||
}
|
||||
if m.Preview.Thumb == nil && existing.Preview.Thumb != nil {
|
||||
m.Preview.Thumb = existing.Preview.Thumb
|
||||
}
|
||||
if m.File.SourceURL == "" {
|
||||
m.File.SourceURL = existing.File.SourceURL
|
||||
}
|
||||
if m.Media.Video.Width <= 0 {
|
||||
m.Media.Video.Width = existing.Media.Video.Width
|
||||
}
|
||||
if m.Media.Video.Height <= 0 {
|
||||
m.Media.Video.Height = existing.Media.Video.Height
|
||||
}
|
||||
if m.Media.Video.FPS <= 0 {
|
||||
m.Media.Video.FPS = existing.Media.Video.FPS
|
||||
}
|
||||
if m.Media.Video.Resolution == "" {
|
||||
m.Media.Video.Resolution = existing.Media.Video.Resolution
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ vorhandenes Sprite (inkl. stepSeconds) nicht wegwerfen
|
||||
if existing != nil && existing.PreviewSprite != nil {
|
||||
m.PreviewSprite = existing.PreviewSprite
|
||||
}
|
||||
buf, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -427,35 +550,67 @@ func writeVideoMetaWithPreviewClipsAndSprite(
|
||||
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
|
||||
return nil
|
||||
}
|
||||
assetID := strings.TrimSpace(filepath.Base(filepath.Dir(metaPath)))
|
||||
|
||||
var existing *videoMeta
|
||||
if old, ok := readVideoMetaLoose(metaPath); ok && old != nil {
|
||||
existing = old
|
||||
}
|
||||
|
||||
m := videoMeta{
|
||||
Version: 2,
|
||||
DurationSeconds: dur,
|
||||
FileSize: fi.Size(),
|
||||
FileModUnix: fi.ModTime().Unix(),
|
||||
VideoWidth: w,
|
||||
VideoHeight: h,
|
||||
FPS: fps,
|
||||
Resolution: formatResolution(w, h),
|
||||
SourceURL: strings.TrimSpace(sourceURL),
|
||||
PreviewClips: clips,
|
||||
PreviewSprite: sprite,
|
||||
UpdatedAtUnix: time.Now().Unix(),
|
||||
Version: 3,
|
||||
UpdatedAtUnix: time.Now().Unix(),
|
||||
File: videoFileMeta{
|
||||
Size: fi.Size(),
|
||||
ModUnix: fi.ModTime().Unix(),
|
||||
SourceURL: strings.TrimSpace(sourceURL),
|
||||
},
|
||||
Media: videoMediaMeta{
|
||||
DurationSeconds: dur,
|
||||
Video: videoStreamMeta{
|
||||
Width: w,
|
||||
Height: h,
|
||||
FPS: fps,
|
||||
Resolution: formatResolution(w, h),
|
||||
},
|
||||
},
|
||||
Preview: previewMeta{
|
||||
Clips: clips,
|
||||
Thumb: buildPreviewThumbMeta(assetID),
|
||||
Sprite: sprite,
|
||||
},
|
||||
}
|
||||
|
||||
if sprite == nil {
|
||||
if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil && old.PreviewSprite != nil {
|
||||
m.PreviewSprite = old.PreviewSprite
|
||||
}
|
||||
}
|
||||
if len(clips) == 0 {
|
||||
if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil && len(old.PreviewClips) > 0 {
|
||||
m.PreviewClips = old.PreviewClips
|
||||
}
|
||||
}
|
||||
if existing != nil {
|
||||
m.Analysis = existing.Analysis
|
||||
m.Postwork = existing.Postwork
|
||||
|
||||
if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil && old.AI != nil {
|
||||
m.AI = old.AI
|
||||
m.Preview.Sprite = mergePreviewSpriteMeta(existing.Preview.Sprite, m.Preview.Sprite)
|
||||
|
||||
if m.Preview.Thumb == nil && existing.Preview.Thumb != nil {
|
||||
m.Preview.Thumb = existing.Preview.Thumb
|
||||
}
|
||||
if len(m.Preview.Clips) == 0 && len(existing.Preview.Clips) > 0 {
|
||||
m.Preview.Clips = existing.Preview.Clips
|
||||
}
|
||||
if m.File.SourceURL == "" {
|
||||
m.File.SourceURL = existing.File.SourceURL
|
||||
}
|
||||
if m.Media.Video.Width <= 0 {
|
||||
m.Media.Video.Width = existing.Media.Video.Width
|
||||
}
|
||||
if m.Media.Video.Height <= 0 {
|
||||
m.Media.Video.Height = existing.Media.Video.Height
|
||||
}
|
||||
if m.Media.Video.FPS <= 0 {
|
||||
m.Media.Video.FPS = existing.Media.Video.FPS
|
||||
}
|
||||
if m.Media.Video.Resolution == "" {
|
||||
m.Media.Video.Resolution = existing.Media.Video.Resolution
|
||||
}
|
||||
if m.Media.DurationSeconds <= 0 {
|
||||
m.Media.DurationSeconds = existing.Media.DurationSeconds
|
||||
}
|
||||
}
|
||||
|
||||
buf, err := json.Marshal(m)
|
||||
@ -466,7 +621,6 @@ func writeVideoMetaWithPreviewClipsAndSprite(
|
||||
return atomicWriteFile(metaPath, buf)
|
||||
}
|
||||
|
||||
// Duration-only Write (ohne props)
|
||||
func writeVideoMetaDuration(metaPath string, fi os.FileInfo, dur float64, sourceURL string) error {
|
||||
return writeVideoMeta(metaPath, fi, dur, 0, 0, 0, sourceURL)
|
||||
}
|
||||
@ -477,7 +631,6 @@ func generatedMetaFile(assetID string) (string, error) {
|
||||
return "", fmt.Errorf("empty assetID")
|
||||
}
|
||||
|
||||
// exakt wie beim Schreiben normalisieren
|
||||
id, err := sanitizeID(assetID)
|
||||
if err != nil || id == "" {
|
||||
return "", fmt.Errorf("invalid assetID: %w", err)
|
||||
@ -486,7 +639,6 @@ func generatedMetaFile(assetID string) (string, error) {
|
||||
return metaJSONPathForAssetID(id)
|
||||
}
|
||||
|
||||
// ✅ Neu: /generated/meta/<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
343
backend/performance.go
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
19
backend/recorder_settings.json
Normal file
19
backend/recorder_settings.json
Normal 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="
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
605
backend/tasks_regenerate_assets.go
Normal file
605
backend/tasks_regenerate_assets.go
Normal 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 := ®enerateAssetsJob{
|
||||
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",
|
||||
})
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@ -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>
|
||||
|
||||
71
frontend/src/components/ui/Checkbox.tsx
Normal file
71
frontend/src/components/ui/Checkbox.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@ -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 >>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ▶️ Progressbar: kräftiger + mehr Kontrast */}
|
||||
{showAnyProgress ? (
|
||||
<div
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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={() => {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
@ -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
@ -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 ''
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// frontend\src\components\ui\notify.ts
|
||||
|
||||
'use client'
|
||||
|
||||
import type { Toast } from './ToastProvider'
|
||||
|
||||
236
frontend/src/components/ui/previewSprite.ts
Normal file
236
frontend/src/components/ui/previewSprite.ts
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user