updated
This commit is contained in:
parent
19b473f8bd
commit
ff06d0d18b
@ -498,8 +498,8 @@ func analyzeVideoFromFrames(ctx context.Context, outPath, goal string) ([]analyz
|
||||
Time: t,
|
||||
Label: bestLabel,
|
||||
Score: bestScore,
|
||||
Start: math.Max(0, t-4),
|
||||
End: t + 4,
|
||||
Start: t,
|
||||
End: t,
|
||||
})
|
||||
}
|
||||
|
||||
@ -545,16 +545,12 @@ func analyzeSpriteCandidatesWithAI(
|
||||
continue
|
||||
}
|
||||
|
||||
span := inferredSpanSeconds(ps.StepSeconds, 8)
|
||||
start := math.Max(0, c.Time-(span/2))
|
||||
end := c.Time + (span / 2)
|
||||
|
||||
hits = append(hits, analyzeHit{
|
||||
Time: c.Time,
|
||||
Label: bestLabel,
|
||||
Score: bestScore,
|
||||
Start: start,
|
||||
End: end,
|
||||
Start: c.Time,
|
||||
End: c.Time,
|
||||
})
|
||||
}
|
||||
|
||||
@ -579,14 +575,14 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit {
|
||||
start := h.Start
|
||||
end := h.End
|
||||
|
||||
if start <= 0 && end <= 0 {
|
||||
if start < 0 && end < 0 {
|
||||
start = h.Time
|
||||
end = h.Time
|
||||
} else {
|
||||
if start <= 0 {
|
||||
if start < 0 {
|
||||
start = h.Time
|
||||
}
|
||||
if end <= 0 {
|
||||
if end < 0 {
|
||||
end = h.Time
|
||||
}
|
||||
}
|
||||
@ -660,18 +656,17 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme
|
||||
start := hit.Start
|
||||
end := hit.End
|
||||
|
||||
if start <= 0 && end <= 0 {
|
||||
if start < 0 && end < 0 {
|
||||
start = hit.Time
|
||||
end = hit.Time
|
||||
} else {
|
||||
if start <= 0 {
|
||||
if start < 0 {
|
||||
start = hit.Time
|
||||
}
|
||||
if end <= 0 {
|
||||
if end < 0 {
|
||||
end = hit.Time
|
||||
}
|
||||
}
|
||||
|
||||
if start > end {
|
||||
start, end = end, start
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
@ -324,87 +323,99 @@ func ensurePrimaryAssetsForVideoWithProgressCtx(
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string) error {
|
||||
func ensureMetaForVideoCtx(
|
||||
ctx context.Context,
|
||||
videoPath string,
|
||||
sourceURL string,
|
||||
) (bool, error) {
|
||||
videoPath = strings.TrimSpace(videoPath)
|
||||
if videoPath == "" {
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
|
||||
fi, err := os.Stat(videoPath)
|
||||
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
|
||||
id := assetIDFromVideoPath(videoPath)
|
||||
if id == "" {
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
|
||||
_, _, previewPath, spritePath, metaPath, perr := assetPathsForID(id)
|
||||
_, _, _, _, metaPath, perr := assetPathsForID(id)
|
||||
if perr != nil {
|
||||
return perr
|
||||
return false, perr
|
||||
}
|
||||
|
||||
meta, err := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return meta.ok, nil
|
||||
}
|
||||
|
||||
func ensureTeaserForVideoCtx(
|
||||
ctx context.Context,
|
||||
videoPath string,
|
||||
sourceURL string,
|
||||
) (bool, error) {
|
||||
videoPath = strings.TrimSpace(videoPath)
|
||||
if videoPath == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
fi, err := os.Stat(videoPath)
|
||||
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
id := assetIDFromVideoPath(videoPath)
|
||||
if id == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
_, _, previewPath, _, metaPath, perr := assetPathsForID(id)
|
||||
if perr != nil {
|
||||
return false, perr
|
||||
}
|
||||
|
||||
if pfi, err := os.Stat(previewPath); err == nil && pfi != nil && !pfi.IsDir() && pfi.Size() > 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
|
||||
|
||||
// Preview erzeugen, wenn noch nicht vorhanden
|
||||
previewExists := false
|
||||
if pfi, err := os.Stat(previewPath); err == nil && pfi != nil && !pfi.IsDir() && pfi.Size() > 0 {
|
||||
previewExists = true
|
||||
const (
|
||||
previewClipLenSec = 0.75
|
||||
previewMaxClips = 12
|
||||
)
|
||||
|
||||
genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := genSem.Acquire(genCtx); err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer genSem.Release()
|
||||
|
||||
if !previewExists {
|
||||
const (
|
||||
previewClipLenSec = 0.75
|
||||
previewMaxClips = 12
|
||||
)
|
||||
|
||||
genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := genSem.Acquire(genCtx); err == nil {
|
||||
func() {
|
||||
defer genSem.Release()
|
||||
|
||||
if err := generateTeaserClipsMP4WithProgress(
|
||||
genCtx,
|
||||
videoPath,
|
||||
previewPath,
|
||||
previewClipLenSec,
|
||||
previewMaxClips,
|
||||
nil,
|
||||
); err != nil {
|
||||
if isFFmpegInputInvalidError(err) {
|
||||
fmt.Printf("⚠️ preview clips skipped (invalid/incomplete input): %s\n", videoPath)
|
||||
return
|
||||
}
|
||||
fmt.Printf("⚠️ deferred preview failed for %s: %v\n", videoPath, err)
|
||||
return
|
||||
}
|
||||
}()
|
||||
if err := generateTeaserClipsMP4WithProgress(
|
||||
genCtx,
|
||||
videoPath,
|
||||
previewPath,
|
||||
previewClipLenSec,
|
||||
previewMaxClips,
|
||||
nil,
|
||||
); err != nil {
|
||||
if isFFmpegInputInvalidError(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
// vorhandene previewClips / spriteMeta aus bestehender meta.json übernehmen
|
||||
var computedPreviewClips []previewClip
|
||||
var spriteMeta *previewSpriteMeta
|
||||
|
||||
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil {
|
||||
if len(oldMeta.PreviewClips) > 0 {
|
||||
computedPreviewClips = oldMeta.PreviewClips
|
||||
}
|
||||
if oldMeta.PreviewSprite != nil {
|
||||
spriteMeta = oldMeta.PreviewSprite
|
||||
}
|
||||
}
|
||||
|
||||
// previewClips aus Dauer ableiten, falls noch nicht vorhanden
|
||||
if len(computedPreviewClips) == 0 && meta.durSec > 0 {
|
||||
const (
|
||||
previewClipLenSec = 0.75
|
||||
previewMaxClips = 12
|
||||
)
|
||||
|
||||
if meta.durSec > 0 {
|
||||
opts := TeaserPreviewOptions{
|
||||
Segments: previewMaxClips,
|
||||
SegmentDuration: previewClipLenSec,
|
||||
@ -422,69 +433,11 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string)
|
||||
computedPreviewClips = clips
|
||||
}
|
||||
|
||||
// Sprite erzeugen, wenn noch nicht vorhanden
|
||||
spriteExists := false
|
||||
if sfi, err := os.Stat(spritePath); err == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 {
|
||||
spriteExists = true
|
||||
var spriteMeta *previewSpriteMeta
|
||||
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.Preview.Sprite != nil {
|
||||
spriteMeta = oldMeta.Preview.Sprite
|
||||
}
|
||||
|
||||
if !spriteExists && meta.durSec > 0 {
|
||||
genCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := genSem.Acquire(genCtx); err == nil {
|
||||
func() {
|
||||
defer genSem.Release()
|
||||
|
||||
cols, rows, count, cellW, cellH := fixedPreviewSpriteLayout()
|
||||
stepSec := previewSpriteStepSeconds(meta.durSec)
|
||||
|
||||
if err := generatePreviewSpriteJPG(
|
||||
genCtx,
|
||||
videoPath,
|
||||
spritePath,
|
||||
cols,
|
||||
rows,
|
||||
stepSec,
|
||||
cellW,
|
||||
cellH,
|
||||
); err != nil {
|
||||
fmt.Printf("⚠️ preview sprite failed for %s: %v\n", videoPath)
|
||||
return
|
||||
}
|
||||
|
||||
spriteMeta = &previewSpriteMeta{
|
||||
Path: fmt.Sprintf("/api/preview-sprite/%s", id),
|
||||
Count: count,
|
||||
Cols: cols,
|
||||
Rows: rows,
|
||||
StepSeconds: stepSec,
|
||||
CellWidth: cellW,
|
||||
CellHeight: cellH,
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// Falls Sprite existiert, aber spriteMeta noch fehlt
|
||||
if spriteMeta == nil {
|
||||
if sfi, err := os.Stat(spritePath); err == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 && meta.durSec > 0 {
|
||||
cols, rows, count, cellW, cellH := fixedPreviewSpriteLayout()
|
||||
stepSec := previewSpriteStepSeconds(meta.durSec)
|
||||
|
||||
spriteMeta = &previewSpriteMeta{
|
||||
Path: fmt.Sprintf("/api/preview-sprite/%s", id),
|
||||
Count: count,
|
||||
Cols: cols,
|
||||
Rows: rows,
|
||||
StepSeconds: stepSec,
|
||||
CellWidth: cellW,
|
||||
CellHeight: cellH,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// meta.json mit previewClips + sprite ergänzen
|
||||
if meta.durSec > 0 {
|
||||
_ = writeVideoMetaWithPreviewClipsAndSprite(
|
||||
metaPath,
|
||||
@ -499,33 +452,198 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string)
|
||||
)
|
||||
}
|
||||
|
||||
// AI erst NACH Sprite
|
||||
if !hasAIResultsForOutput(videoPath) {
|
||||
if spriteMeta != nil {
|
||||
actx, cancel := context.WithTimeout(ctx, 45*time.Second)
|
||||
defer cancel()
|
||||
return true, nil
|
||||
}
|
||||
|
||||
durationSec, _ := durationSecondsForAnalyze(actx, videoPath)
|
||||
hits, aerr := analyzeVideoFromSprite(actx, videoPath, "nsfw")
|
||||
if aerr != nil {
|
||||
fmt.Println("⚠️ deferred analyze:", aerr)
|
||||
return nil
|
||||
}
|
||||
func ensureSpritesForVideoCtx(
|
||||
ctx context.Context,
|
||||
videoPath string,
|
||||
sourceURL string,
|
||||
) (bool, error) {
|
||||
videoPath = strings.TrimSpace(videoPath)
|
||||
if videoPath == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
segments := buildSegmentsFromAnalyzeHits(hits, durationSec)
|
||||
fi, err := os.Stat(videoPath)
|
||||
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
ai := &aiAnalysisMeta{
|
||||
Goal: "nsfw",
|
||||
Mode: "sprite",
|
||||
Hits: hits,
|
||||
Segments: segments,
|
||||
AnalyzedAtUnix: time.Now().Unix(),
|
||||
}
|
||||
id := assetIDFromVideoPath(videoPath)
|
||||
if id == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if werr := writeVideoAIForFile(actx, videoPath, sourceURL, ai); werr != nil {
|
||||
fmt.Println("⚠️ writeVideoAIForFile:", werr)
|
||||
}
|
||||
_, _, _, spritePath, metaPath, perr := assetPathsForID(id)
|
||||
if perr != nil {
|
||||
return false, perr
|
||||
}
|
||||
|
||||
if sfi, err := os.Stat(spritePath); err == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
|
||||
if !(meta.durSec > 0) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
genCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := genSem.Acquire(genCtx); err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer genSem.Release()
|
||||
|
||||
cols, rows, count, cellW, cellH := fixedPreviewSpriteLayout()
|
||||
stepSec := previewSpriteStepSeconds(meta.durSec)
|
||||
|
||||
if err := generatePreviewSpriteJPG(
|
||||
genCtx,
|
||||
videoPath,
|
||||
spritePath,
|
||||
cols,
|
||||
rows,
|
||||
stepSec,
|
||||
cellW,
|
||||
cellH,
|
||||
); err != nil {
|
||||
if sfi, statErr := os.Stat(spritePath); statErr == nil && sfi != nil && !sfi.IsDir() && sfi.Size() > 0 {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
var computedPreviewClips []previewClip
|
||||
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && len(oldMeta.Preview.Clips) > 0 {
|
||||
computedPreviewClips = oldMeta.Preview.Clips
|
||||
}
|
||||
|
||||
spriteMeta := &previewSpriteMeta{
|
||||
Path: fmt.Sprintf("/api/preview-sprite/%s", id),
|
||||
Count: count,
|
||||
Cols: cols,
|
||||
Rows: rows,
|
||||
StepSeconds: stepSec,
|
||||
CellWidth: cellW,
|
||||
CellHeight: cellH,
|
||||
}
|
||||
|
||||
_ = writeVideoMetaWithPreviewClipsAndSprite(
|
||||
metaPath,
|
||||
fi,
|
||||
meta.durSec,
|
||||
meta.vw,
|
||||
meta.vh,
|
||||
meta.fps,
|
||||
meta.sourceURL,
|
||||
computedPreviewClips,
|
||||
spriteMeta,
|
||||
)
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func ensureAnalyzeForVideoCtx(
|
||||
ctx context.Context,
|
||||
videoPath string,
|
||||
sourceURL string,
|
||||
goal string,
|
||||
) (bool, error) {
|
||||
videoPath = strings.TrimSpace(videoPath)
|
||||
if videoPath == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if hasAIResultsForOutput(videoPath) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
fi, err := os.Stat(videoPath)
|
||||
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
id := assetIDFromVideoPath(videoPath)
|
||||
if id == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
_, _, _, spritePath, metaPath, perr := assetPathsForID(id)
|
||||
if perr != nil {
|
||||
return false, perr
|
||||
}
|
||||
|
||||
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
|
||||
|
||||
if sfi, err := os.Stat(spritePath); err != nil || sfi == nil || sfi.IsDir() || sfi.Size() <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
goal = strings.ToLower(strings.TrimSpace(goal))
|
||||
if goal == "" {
|
||||
goal = "nsfw"
|
||||
}
|
||||
|
||||
actx, cancel := context.WithTimeout(ctx, 45*time.Second)
|
||||
defer cancel()
|
||||
|
||||
durationSec, _ := durationSecondsForAnalyze(actx, videoPath)
|
||||
hits, aerr := analyzeVideoFromSprite(actx, videoPath, goal)
|
||||
if aerr != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
segments := buildSegmentsFromAnalyzeHits(hits, durationSec)
|
||||
|
||||
ai := &aiAnalysisMeta{
|
||||
Goal: goal,
|
||||
Mode: "sprite",
|
||||
Hits: hits,
|
||||
Segments: segments,
|
||||
AnalyzedAtUnix: time.Now().Unix(),
|
||||
}
|
||||
|
||||
if werr := writeVideoAIForFile(actx, videoPath, meta.sourceURL, ai); werr != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string) error {
|
||||
videoPath = strings.TrimSpace(videoPath)
|
||||
if videoPath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
fi, err := os.Stat(videoPath)
|
||||
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
id := assetIDFromVideoPath(videoPath)
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := ensureTeaserForVideoCtx(ctx, videoPath, sourceURL); err != nil {
|
||||
return fmt.Errorf("deferred teaser failed for %s: %w", videoPath, err)
|
||||
}
|
||||
|
||||
if _, err := ensureSpritesForVideoCtx(ctx, videoPath, sourceURL); err != nil {
|
||||
return fmt.Errorf("deferred sprite failed for %s: %w", videoPath, err)
|
||||
}
|
||||
|
||||
truth := finishedPhaseTruthForID(id)
|
||||
if !truth.SpritesReady {
|
||||
return fmt.Errorf("preview-sprite.jpg fehlt nach deferred generation: %s", id)
|
||||
}
|
||||
|
||||
if _, err := ensureAnalyzeForVideoCtx(ctx, videoPath, sourceURL, "nsfw"); err != nil {
|
||||
return fmt.Errorf("deferred analyze failed for %s: %w", videoPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -700,7 +818,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
|
||||
// aber NICHT sofort returnen, damit meta.json
|
||||
// (previewClips / previewSprite) trotzdem sauber geschrieben wird.
|
||||
metaHasSprite := false
|
||||
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.PreviewSprite != nil {
|
||||
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.Preview.Sprite != nil {
|
||||
metaHasSprite = true
|
||||
}
|
||||
|
||||
@ -905,7 +1023,6 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("⚠️ preview sprite failed for %s: %v\n", videoPath)
|
||||
return
|
||||
}
|
||||
|
||||
@ -918,16 +1035,16 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
|
||||
// Falls wir in diesem Lauf keine neuen Clips berechnet haben:
|
||||
// alte aus gültigem meta.json übernehmen (best effort)
|
||||
if len(computedPreviewClips) == 0 {
|
||||
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && len(oldMeta.PreviewClips) > 0 {
|
||||
computedPreviewClips = oldMeta.PreviewClips
|
||||
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && len(oldMeta.Preview.Clips) > 0 {
|
||||
computedPreviewClips = oldMeta.Preview.Clips
|
||||
}
|
||||
}
|
||||
|
||||
// Falls wir in diesem Lauf kein neues spriteMeta gesetzt haben:
|
||||
// altes aus gültigem meta.json übernehmen
|
||||
if spriteMeta == nil {
|
||||
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.PreviewSprite != nil {
|
||||
spriteMeta = oldMeta.PreviewSprite
|
||||
if oldMeta, ok := readVideoMetaIfValid(metaPath, fi); ok && oldMeta != nil && oldMeta.Preview.Sprite != nil {
|
||||
spriteMeta = oldMeta.Preview.Sprite
|
||||
}
|
||||
}
|
||||
|
||||
@ -964,19 +1081,7 @@ func hasAIResultsForOutput(outPath string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(metaPath)
|
||||
if err != nil || len(b) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
var m map[string]any
|
||||
dec := json.NewDecoder(strings.NewReader(string(b)))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
aiMap, ok := m["ai"].(map[string]any)
|
||||
aiMap, ok := readVideoMetaAI(metaPath)
|
||||
if !ok || aiMap == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
@ -57,17 +57,19 @@ type previewSpriteMetaResp struct {
|
||||
StepSeconds float64 `json:"stepSeconds,omitempty"`
|
||||
}
|
||||
|
||||
type finishedPhaseTruth struct {
|
||||
MetaReady bool `json:"metaReady"`
|
||||
TeaserReady bool `json:"teaserReady"`
|
||||
ThumbReady bool `json:"thumbReady"`
|
||||
SpritesReady bool `json:"spritesReady"`
|
||||
AnalyzeReady bool `json:"analyzeReady"`
|
||||
}
|
||||
|
||||
type doneMetaFileResp struct {
|
||||
File string `json:"file"`
|
||||
MetaExists bool `json:"metaExists"`
|
||||
DurationSeconds float64 `json:"durationSeconds,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
FPS float64 `json:"fps,omitempty"`
|
||||
SourceURL string `json:"sourceUrl,omitempty"`
|
||||
PreviewSprite previewSpriteMetaResp `json:"previewSprite"`
|
||||
AI any `json:"ai,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
File string `json:"file"`
|
||||
MetaExists bool `json:"metaExists"`
|
||||
Meta *videoMeta `json:"meta,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type doneMetaResp struct {
|
||||
@ -168,6 +170,29 @@ type regenerateAssetsRequest struct {
|
||||
File string `json:"file"`
|
||||
}
|
||||
|
||||
func publishFinishedPostworkPhase(
|
||||
file string,
|
||||
assetID string,
|
||||
queue string,
|
||||
phase string,
|
||||
state string,
|
||||
label string,
|
||||
) {
|
||||
ev := finishedPostworkEvent{
|
||||
Type: "finished_postwork",
|
||||
File: file,
|
||||
AssetID: assetID,
|
||||
Queue: queue,
|
||||
State: state,
|
||||
Phase: phase,
|
||||
Label: label,
|
||||
TS: time.Now().UnixMilli(),
|
||||
}
|
||||
if b, err := json.Marshal(ev); err == nil {
|
||||
publishSSE("finishedPostwork", b)
|
||||
}
|
||||
}
|
||||
|
||||
func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
@ -219,20 +244,14 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
|
||||
// 1) Vorher evtl. laufende enrich-Jobs abbrechen
|
||||
cancelDeferredEnrichForFile(file)
|
||||
|
||||
// ✅ alten Analyze-Status aktiv löschen
|
||||
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "")
|
||||
|
||||
// 2) SOFORT running an UI senden
|
||||
startEv := finishedPostworkEvent{
|
||||
Type: "finished_postwork",
|
||||
File: file,
|
||||
AssetID: id,
|
||||
Queue: "enrich",
|
||||
State: "running",
|
||||
Phase: "assets",
|
||||
Label: "Wird verarbeitet",
|
||||
TS: time.Now().UnixMilli(),
|
||||
}
|
||||
if b, err := json.Marshal(startEv); err == nil {
|
||||
publishSSE("finishedPostwork", b)
|
||||
}
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "meta", "running", "Meta")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "running", "Teaser")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "running", "Vorschaubild")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "running", "Sprites")
|
||||
|
||||
notifyDoneChanged()
|
||||
|
||||
@ -246,10 +265,63 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
assetWatchDone := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(300 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
sentMetaDone := false
|
||||
sentTeaserDone := false
|
||||
sentThumbDone := false
|
||||
sentSpritesDone := false
|
||||
|
||||
checkAndEmit := func() {
|
||||
truth := finishedPhaseTruthForID(id)
|
||||
|
||||
if truth.MetaReady && !sentMetaDone {
|
||||
sentMetaDone = true
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "meta", "done", "Meta")
|
||||
}
|
||||
if truth.TeaserReady && !sentTeaserDone {
|
||||
sentTeaserDone = true
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "done", "Teaser")
|
||||
}
|
||||
if truth.ThumbReady && !sentThumbDone {
|
||||
sentThumbDone = true
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "done", "Vorschaubild")
|
||||
}
|
||||
if truth.SpritesReady && !sentSpritesDone {
|
||||
sentSpritesDone = true
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites")
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-assetWatchDone:
|
||||
checkAndEmit()
|
||||
return
|
||||
case <-ticker.C:
|
||||
checkAndEmit()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 4) Regeneration
|
||||
_, errAssets := ensureAssetsForVideoWithProgressCtx(context.Background(), videoPath, "", nil)
|
||||
close(assetWatchDone)
|
||||
|
||||
if errAssets == nil {
|
||||
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "KI-Analyse")
|
||||
}
|
||||
|
||||
errAI := ensureDeferredAssetsAndAI(context.Background(), videoPath, "")
|
||||
|
||||
if errAssets == nil && errAI == nil {
|
||||
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "KI-Analyse")
|
||||
}
|
||||
|
||||
// 5) Ergebnis zurück an UI
|
||||
if errAssets != nil || errAI != nil {
|
||||
errMsg := ""
|
||||
@ -259,6 +331,8 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
|
||||
errMsg = errAI.Error()
|
||||
}
|
||||
|
||||
setRegenerateAssetsTaskError(file, errMsg)
|
||||
|
||||
errEv := finishedPostworkEvent{
|
||||
Type: "finished_postwork",
|
||||
File: file,
|
||||
@ -273,25 +347,23 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
|
||||
publishSSE("finishedPostwork", b)
|
||||
}
|
||||
|
||||
notifyDoneChanged()
|
||||
|
||||
time.AfterFunc(5*time.Second, func() {
|
||||
clearRegenerateAssetsTaskState()
|
||||
})
|
||||
|
||||
fmt.Println("[regenerate-assets] failed:", file, errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
doneEv := finishedPostworkEvent{
|
||||
Type: "finished_postwork",
|
||||
File: file,
|
||||
AssetID: id,
|
||||
Queue: "enrich",
|
||||
State: "done",
|
||||
Phase: "assets",
|
||||
Label: "Fertig",
|
||||
TS: time.Now().UnixMilli(),
|
||||
}
|
||||
if b, err := json.Marshal(doneEv); err == nil {
|
||||
publishSSE("finishedPostwork", b)
|
||||
}
|
||||
setRegenerateAssetsTaskDone(file, "Fertig")
|
||||
|
||||
notifyDoneChanged()
|
||||
|
||||
time.AfterFunc(3*time.Second, func() {
|
||||
clearRegenerateAssetsTaskState()
|
||||
})
|
||||
}(videoPath, file)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@ -623,7 +695,12 @@ func readPreviewSpriteMetaFromMetaFile(metaPath string) (previewSpriteMetaFileIn
|
||||
return out, false
|
||||
}
|
||||
|
||||
ps, ok := m["previewSprite"].(map[string]any)
|
||||
previewMap, ok := m["preview"].(map[string]any)
|
||||
if !ok || previewMap == nil {
|
||||
return out, false
|
||||
}
|
||||
|
||||
ps, ok := previewMap["sprite"].(map[string]any)
|
||||
if !ok || ps == nil {
|
||||
return out, false
|
||||
}
|
||||
@ -752,14 +829,11 @@ func previewSpriteTruthForID(id string) previewSpriteMetaResp {
|
||||
return out
|
||||
}
|
||||
|
||||
metaPath, err := generatedMetaFile(id)
|
||||
if err != nil || strings.TrimSpace(metaPath) == "" {
|
||||
_, _, _, spriteFile, metaPath, err := assetPathsForID(id)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
|
||||
genDir := filepath.Dir(metaPath)
|
||||
spriteFile := filepath.Join(genDir, "preview-sprite.jpg")
|
||||
|
||||
fi, err := os.Stat(spriteFile)
|
||||
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||
return out
|
||||
@ -786,11 +860,154 @@ func previewSpriteTruthForID(id string) previewSpriteMetaResp {
|
||||
return out
|
||||
}
|
||||
|
||||
func numberFromAny(v any) (float64, bool) {
|
||||
switch x := v.(type) {
|
||||
case float32:
|
||||
return float64(x), true
|
||||
case float64:
|
||||
return x, true
|
||||
case int:
|
||||
return float64(x), true
|
||||
case int8:
|
||||
return float64(x), true
|
||||
case int16:
|
||||
return float64(x), true
|
||||
case int32:
|
||||
return float64(x), true
|
||||
case int64:
|
||||
return float64(x), true
|
||||
case uint:
|
||||
return float64(x), true
|
||||
case uint8:
|
||||
return float64(x), true
|
||||
case uint16:
|
||||
return float64(x), true
|
||||
case uint32:
|
||||
return float64(x), true
|
||||
case uint64:
|
||||
return float64(x), true
|
||||
case json.Number:
|
||||
if f, err := x.Float64(); err == nil {
|
||||
return f, true
|
||||
}
|
||||
case string:
|
||||
s := strings.TrimSpace(x)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return f, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func finishedPhaseTruthForID(id string) finishedPhaseTruth {
|
||||
var out finishedPhaseTruth
|
||||
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || strings.Contains(id, "/") || strings.Contains(id, "\\") {
|
||||
return out
|
||||
}
|
||||
|
||||
_, thumbPath, previewPath, spritePath, metaPath, err := assetPathsForID(id)
|
||||
if err != nil || strings.TrimSpace(metaPath) == "" {
|
||||
return out
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(metaPath)
|
||||
if err == nil && len(b) > 0 {
|
||||
var m map[string]any
|
||||
dec := json.NewDecoder(strings.NewReader(string(b)))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&m); err == nil {
|
||||
media, _ := m["media"].(map[string]any)
|
||||
if media != nil {
|
||||
if d, ok := numberFromAny(media["durationSeconds"]); ok && d > 0 {
|
||||
video, _ := media["video"].(map[string]any)
|
||||
if video != nil {
|
||||
if w, wok := numberFromAny(video["width"]); wok && w > 0 {
|
||||
if h, hok := numberFromAny(video["height"]); hok && h > 0 {
|
||||
out.MetaReady = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
preview, _ := m["preview"].(map[string]any)
|
||||
if preview != nil {
|
||||
if clips, ok := preview["clips"].([]any); ok && len(clips) > 0 {
|
||||
out.TeaserReady = true
|
||||
}
|
||||
|
||||
if thumb, ok := preview["thumb"].(map[string]any); ok && thumb != nil {
|
||||
if exists, ok := thumb["exists"].(bool); ok && exists {
|
||||
out.ThumbReady = true
|
||||
}
|
||||
if p, ok := thumb["path"].(string); ok && strings.TrimSpace(p) != "" {
|
||||
out.ThumbReady = true
|
||||
}
|
||||
}
|
||||
|
||||
if sprite, ok := preview["sprite"].(map[string]any); ok && sprite != nil {
|
||||
if p, ok := sprite["path"].(string); ok && strings.TrimSpace(p) != "" {
|
||||
out.SpritesReady = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
analysis, _ := m["analysis"].(map[string]any)
|
||||
if analysis != nil {
|
||||
if ai, ok := analysis["ai"].(map[string]any); ok && ai != nil && len(ai) > 0 {
|
||||
out.AnalyzeReady = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !out.ThumbReady {
|
||||
if fi, err := os.Stat(thumbPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
|
||||
out.ThumbReady = true
|
||||
}
|
||||
}
|
||||
|
||||
if !out.SpritesReady {
|
||||
if fi, err := os.Stat(spritePath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
|
||||
out.SpritesReady = true
|
||||
}
|
||||
}
|
||||
|
||||
if !out.TeaserReady {
|
||||
if fi, err := os.Stat(previewPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
|
||||
out.TeaserReady = true
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func applyPreviewSpriteTruthToDoneMetaResp(id string, resp *doneMetaFileResp) {
|
||||
if resp == nil {
|
||||
return
|
||||
}
|
||||
resp.PreviewSprite = previewSpriteTruthForID(id)
|
||||
|
||||
ps := previewSpriteTruthForID(id)
|
||||
if !ps.Exists {
|
||||
return
|
||||
}
|
||||
|
||||
if resp.Meta == nil {
|
||||
resp.Meta = &videoMeta{}
|
||||
}
|
||||
|
||||
resp.Meta.Preview.Sprite = &previewSpriteMeta{
|
||||
Path: ps.Path,
|
||||
Count: ps.Count,
|
||||
Cols: ps.Cols,
|
||||
Rows: ps.Rows,
|
||||
StepSeconds: ps.StepSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
// robust meta setter into RecordJob.Meta (any/string/[]byte/typed map)
|
||||
@ -940,9 +1157,6 @@ func applyPreviewSpriteTruthToRecordJobMeta(j *RecordJob) {
|
||||
meta = map[string]any{}
|
||||
}
|
||||
|
||||
delete(meta, "previewScrubberPath")
|
||||
delete(meta, "previewScrubberCount")
|
||||
|
||||
psMap := map[string]any{"exists": ps.Exists}
|
||||
if ps.Exists {
|
||||
psMap["path"] = ps.Path
|
||||
@ -959,8 +1173,81 @@ func applyPreviewSpriteTruthToRecordJobMeta(j *RecordJob) {
|
||||
psMap["stepSeconds"] = ps.StepSeconds
|
||||
}
|
||||
}
|
||||
meta["previewSprite"] = psMap
|
||||
|
||||
previewMap, _ := meta["preview"].(map[string]any)
|
||||
if previewMap == nil {
|
||||
previewMap = map[string]any{}
|
||||
}
|
||||
previewMap["sprite"] = psMap
|
||||
meta["preview"] = previewMap
|
||||
|
||||
setStructFieldJSONMap(fv, meta)
|
||||
}
|
||||
|
||||
func applyFinishedPhaseTruthToRecordJobMeta(j *RecordJob) {
|
||||
if j == nil {
|
||||
return
|
||||
}
|
||||
|
||||
outPath := strings.TrimSpace(j.Output)
|
||||
if outPath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
base := filepath.Base(outPath)
|
||||
id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base)))
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
|
||||
truth := finishedPhaseTruthForID(id)
|
||||
|
||||
rv := reflect.ValueOf(j)
|
||||
if rv.Kind() != reflect.Pointer || rv.IsNil() {
|
||||
return
|
||||
}
|
||||
sv := rv.Elem()
|
||||
if !sv.IsValid() || sv.Kind() != reflect.Struct {
|
||||
return
|
||||
}
|
||||
|
||||
fv := sv.FieldByName("Meta")
|
||||
if !fv.IsValid() || !fv.CanSet() {
|
||||
return
|
||||
}
|
||||
|
||||
var raw any
|
||||
switch fv.Kind() {
|
||||
case reflect.Interface:
|
||||
if fv.IsNil() {
|
||||
raw = nil
|
||||
} else {
|
||||
raw = fv.Interface()
|
||||
}
|
||||
default:
|
||||
raw = fv.Interface()
|
||||
}
|
||||
|
||||
meta := metaMapFromAny(raw)
|
||||
if meta == nil {
|
||||
meta = map[string]any{}
|
||||
}
|
||||
|
||||
postworkMap, _ := meta["postwork"].(map[string]any)
|
||||
if postworkMap == nil {
|
||||
postworkMap = map[string]any{}
|
||||
}
|
||||
|
||||
postworkMap["completed"] = map[string]any{
|
||||
"meta": truth.MetaReady,
|
||||
"teaser": truth.TeaserReady,
|
||||
"thumb": truth.ThumbReady,
|
||||
"sprites": truth.SpritesReady,
|
||||
"analyze": truth.AnalyzeReady,
|
||||
}
|
||||
|
||||
meta["postwork"] = postworkMap
|
||||
setStructFieldJSONMap(fv, meta)
|
||||
}
|
||||
|
||||
@ -1699,35 +1986,31 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
|
||||
resp := doneMetaFileResp{File: filepath.Base(outPath)}
|
||||
|
||||
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(outPath), filepath.Ext(outPath)))
|
||||
applyPreviewSpriteTruthToDoneMetaResp(id, &resp)
|
||||
|
||||
if strings.TrimSpace(id) != "" {
|
||||
if mp, merr := generatedMetaFile(id); merr == nil && strings.TrimSpace(mp) != "" {
|
||||
if mfi, serr := os.Stat(mp); serr == nil && mfi != nil && !mfi.IsDir() && mfi.Size() > 0 {
|
||||
resp.MetaExists = true
|
||||
|
||||
if dur, w2, h2, fps2, ok := readVideoMeta(mp, fi); ok {
|
||||
resp.DurationSeconds = dur
|
||||
resp.Width = w2
|
||||
resp.Height = h2
|
||||
resp.FPS = fps2
|
||||
}
|
||||
if u, ok := readVideoMetaSourceURL(mp, fi); ok {
|
||||
resp.SourceURL = u
|
||||
}
|
||||
|
||||
if ai, ok := readVideoMetaAI(mp); ok {
|
||||
resp.AI = ai
|
||||
if meta, ok := readVideoMetaIfValid(mp, fi); ok && meta != nil {
|
||||
resp.MetaExists = true
|
||||
resp.Meta = meta
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if resp.DurationSeconds <= 0 {
|
||||
// Sprite-Wahrheit aus Dateisystem/meta ggf. ergänzen/überschreiben
|
||||
applyPreviewSpriteTruthToDoneMetaResp(id, &resp)
|
||||
|
||||
if resp.Meta == nil {
|
||||
resp.Meta = &videoMeta{}
|
||||
}
|
||||
|
||||
// Fallback Duration, falls Meta fehlt oder unvollständig
|
||||
if resp.Meta.Media.DurationSeconds <= 0 {
|
||||
pctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
if d, derr := durationSecondsCached(pctx, outPath); derr == nil && d > 0 {
|
||||
resp.DurationSeconds = d
|
||||
resp.Meta.Media.DurationSeconds = d
|
||||
}
|
||||
}
|
||||
|
||||
@ -2153,6 +2436,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
applyPreviewSpriteTruthToRecordJobMeta(&c)
|
||||
applyFinishedPhaseTruthToRecordJobMeta(&c)
|
||||
out = append(out, &c)
|
||||
}
|
||||
|
||||
|
||||
@ -412,10 +412,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
err = errors.New("unsupported provider")
|
||||
}
|
||||
|
||||
if err != nil && shouldLogRecordError(err, provider, req) {
|
||||
fmt.Println("❌ [record]", provider, job.SourceURL, "->", err)
|
||||
}
|
||||
|
||||
end := time.Now()
|
||||
|
||||
target := JobFinished
|
||||
|
||||
@ -3,7 +3,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@ -29,6 +28,9 @@ type AssetsTaskState struct {
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CurrentFile string `json:"currentFile,omitempty"`
|
||||
CurrentQueue string `json:"currentQueue,omitempty"`
|
||||
CurrentPhase string `json:"currentPhase,omitempty"`
|
||||
CurrentLabel string `json:"currentLabel,omitempty"`
|
||||
}
|
||||
|
||||
var assetsTaskMu sync.Mutex
|
||||
@ -91,11 +93,14 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
|
||||
FinishedAt: nil,
|
||||
Error: "",
|
||||
CurrentFile: "",
|
||||
CurrentQueue: "",
|
||||
CurrentPhase: "",
|
||||
CurrentLabel: "",
|
||||
}
|
||||
st := assetsTaskState
|
||||
assetsTaskMu.Unlock()
|
||||
|
||||
// ✅ SSE: Start pushen
|
||||
// SSE: Start pushen
|
||||
notifyAssetsChanged()
|
||||
|
||||
go runGenerateMissingAssets(ctx)
|
||||
@ -134,7 +139,7 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func publishAssetsTaskFinishedPostworkState(fileName string, state string, label string) {
|
||||
func publishAssetsTaskPhase(fileName string, queue string, phase string, state string, label string) {
|
||||
fileName = strings.TrimSpace(fileName)
|
||||
if fileName == "" {
|
||||
return
|
||||
@ -143,52 +148,31 @@ func publishAssetsTaskFinishedPostworkState(fileName string, state string, label
|
||||
base := strings.TrimSuffix(fileName, filepath.Ext(fileName))
|
||||
assetID := stripHotPrefix(base)
|
||||
|
||||
ev := finishedPostworkEvent{
|
||||
Type: "finished_postwork",
|
||||
File: fileName,
|
||||
AssetID: assetID,
|
||||
Queue: "enrich",
|
||||
State: state, // "queued" | "running" | "done" | "error" | "missing"
|
||||
Phase: "assets",
|
||||
Label: label,
|
||||
TS: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
b, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Für FinishedDownloads reicht der globale Kanal.
|
||||
publishSSE("finishedPostwork", b)
|
||||
publishFinishedPostworkPhase(
|
||||
fileName,
|
||||
assetID,
|
||||
queue,
|
||||
phase,
|
||||
state,
|
||||
label,
|
||||
)
|
||||
}
|
||||
|
||||
func clearAssetsTaskFinishedPostworkState(fileName string) {
|
||||
fileName = strings.TrimSpace(fileName)
|
||||
if fileName == "" {
|
||||
return
|
||||
func clearAssetsTaskFinishedPostworkStates(fileName string) {
|
||||
defs := []struct {
|
||||
queue string
|
||||
phase string
|
||||
}{
|
||||
{queue: "postwork", phase: "meta"},
|
||||
{queue: "postwork", phase: "thumb"},
|
||||
{queue: "postwork", phase: "teaser"},
|
||||
{queue: "postwork", phase: "sprites"},
|
||||
{queue: "enrich", phase: "analyze"},
|
||||
}
|
||||
|
||||
base := strings.TrimSuffix(fileName, filepath.Ext(fileName))
|
||||
assetID := stripHotPrefix(base)
|
||||
|
||||
ev := finishedPostworkEvent{
|
||||
Type: "finished_postwork",
|
||||
File: fileName,
|
||||
AssetID: assetID,
|
||||
Queue: "enrich",
|
||||
State: "missing",
|
||||
Phase: "assets",
|
||||
Label: "",
|
||||
TS: time.Now().UnixMilli(),
|
||||
for _, def := range defs {
|
||||
publishAssetsTaskPhase(fileName, def.queue, def.phase, "missing", "")
|
||||
}
|
||||
|
||||
b, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
publishSSE("finishedPostwork", b)
|
||||
}
|
||||
|
||||
func runGenerateMissingAssets(ctx context.Context) {
|
||||
@ -206,14 +190,15 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
st.Running = false
|
||||
st.FinishedAt = &now
|
||||
st.CurrentFile = ""
|
||||
st.CurrentQueue = ""
|
||||
st.CurrentPhase = ""
|
||||
st.CurrentLabel = ""
|
||||
|
||||
if err == nil {
|
||||
// Erfolg: Error leeren
|
||||
st.Error = ""
|
||||
return
|
||||
}
|
||||
|
||||
// stabiler Text für UI
|
||||
if errors.Is(err, context.Canceled) {
|
||||
st.Error = "abgebrochen"
|
||||
} else {
|
||||
@ -234,77 +219,48 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
path string
|
||||
}
|
||||
|
||||
// .trash niemals verarbeiten
|
||||
isTrashPath := func(full string) bool {
|
||||
p := strings.ToLower(strings.ReplaceAll(full, "\\", "/"))
|
||||
return strings.Contains(p, "/.trash/") || strings.HasSuffix(p, "/.trash")
|
||||
}
|
||||
indexedItems, sortedIdx := buildDoneIndex(doneAbs)
|
||||
|
||||
// gleiche Grundreihenfolge wie FinishedDownloads-Default:
|
||||
// includeKeep = true, sort = completed_desc
|
||||
order := sortedIdx["1|completed_desc"]
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
items := make([]item, 0, 512)
|
||||
items := make([]item, 0, len(order))
|
||||
|
||||
addIfVideo := func(full string) {
|
||||
if isTrashPath(full) {
|
||||
return
|
||||
for _, idx := range order {
|
||||
if idx < 0 || idx >= len(indexedItems) {
|
||||
continue
|
||||
}
|
||||
|
||||
name := filepath.Base(full)
|
||||
low := strings.ToLower(name)
|
||||
if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") {
|
||||
return
|
||||
j := indexedItems[idx].job
|
||||
if j == nil {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
if ext != ".mp4" && ext != ".ts" {
|
||||
return
|
||||
|
||||
full := strings.TrimSpace(j.Output)
|
||||
if full == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := seen[full]; ok {
|
||||
return
|
||||
continue
|
||||
}
|
||||
seen[full] = struct{}{}
|
||||
items = append(items, item{name: name, path: full})
|
||||
|
||||
items = append(items, item{
|
||||
name: filepath.Base(full),
|
||||
path: full,
|
||||
})
|
||||
}
|
||||
|
||||
scanOneLevel := func(dir string) {
|
||||
ents, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range ents {
|
||||
if e.IsDir() && strings.EqualFold(e.Name(), ".trash") {
|
||||
continue
|
||||
}
|
||||
|
||||
full := filepath.Join(dir, e.Name())
|
||||
if e.IsDir() {
|
||||
sub, err := os.ReadDir(full)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, se := range sub {
|
||||
if se.IsDir() {
|
||||
continue
|
||||
}
|
||||
addIfVideo(filepath.Join(full, se.Name()))
|
||||
}
|
||||
continue
|
||||
}
|
||||
addIfVideo(full)
|
||||
}
|
||||
}
|
||||
|
||||
// done + done/<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 +270,16 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ aktuellen Dateinamen für UI setzen
|
||||
// aktuellen Dateinamen für UI setzen
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
st.CurrentFile = it.name
|
||||
})
|
||||
|
||||
publishAssetsTaskFinishedPostworkState(
|
||||
it.name,
|
||||
"running",
|
||||
"Erstelle Teaser / Vorschau / Thumbnails…",
|
||||
)
|
||||
|
||||
// ID aus Dateiname
|
||||
base := strings.TrimSuffix(it.name, filepath.Ext(it.name))
|
||||
id := stripHotPrefix(base)
|
||||
if strings.TrimSpace(id) == "" {
|
||||
clearAssetsTaskFinishedPostworkState(it.name)
|
||||
clearAssetsTaskFinishedPostworkStates(it.name)
|
||||
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
st.Done = i + 1
|
||||
@ -340,7 +290,7 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
// Datei-Info (validieren)
|
||||
vfi, verr := os.Stat(it.path)
|
||||
if verr != nil || vfi.IsDir() || vfi.Size() <= 0 {
|
||||
clearAssetsTaskFinishedPostworkState(it.name)
|
||||
clearAssetsTaskFinishedPostworkStates(it.name)
|
||||
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
st.Done = i + 1
|
||||
@ -348,17 +298,46 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Vorher-Wahrheit ermitteln: was fehlt wirklich?
|
||||
beforeTruth := finishedPhaseTruthForID(id)
|
||||
|
||||
needMeta := !beforeTruth.MetaReady
|
||||
needThumb := !beforeTruth.ThumbReady
|
||||
needTeaser := !beforeTruth.TeaserReady
|
||||
needSprites := !beforeTruth.SpritesReady
|
||||
needAnalyze := !beforeTruth.AnalyzeReady
|
||||
|
||||
// Wenn gar nichts fehlt -> alte evtl. Live-States wegräumen und skippen
|
||||
if !needMeta && !needThumb && !needTeaser && !needSprites && !needAnalyze {
|
||||
clearAssetsTaskFinishedPostworkStates(it.name)
|
||||
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
st.Skipped++
|
||||
st.Done = i + 1
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Pfade einmalig über zentralen Helper
|
||||
_, _, _, _, metaPath, perr := assetPathsForID(id)
|
||||
if perr != nil {
|
||||
publishAssetsTaskFinishedPostworkState(
|
||||
it.name,
|
||||
"error",
|
||||
"Assets fehlgeschlagen",
|
||||
)
|
||||
if needMeta {
|
||||
publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen")
|
||||
}
|
||||
if needThumb {
|
||||
publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen")
|
||||
}
|
||||
if needTeaser {
|
||||
publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen")
|
||||
}
|
||||
if needSprites {
|
||||
publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen")
|
||||
}
|
||||
if needAnalyze {
|
||||
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen")
|
||||
}
|
||||
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
// UI bekommt stabilen Hinweis, aber Task läuft weiter
|
||||
st.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)"
|
||||
st.Done = i + 1
|
||||
})
|
||||
@ -372,43 +351,133 @@ func runGenerateMissingAssets(ctx context.Context) {
|
||||
sourceURL = u
|
||||
}
|
||||
|
||||
// Generate/Ensure (einheitliche Core-Funktion)
|
||||
res, e := ensureAssetsForVideoWithProgressCtx(ctx, it.path, sourceURL, nil)
|
||||
if e != nil {
|
||||
publishAssetsTaskFinishedPostworkState(
|
||||
it.name,
|
||||
"error",
|
||||
"Assets fehlgeschlagen",
|
||||
)
|
||||
finishWithErr(e)
|
||||
return
|
||||
}
|
||||
thumbGenerated := false
|
||||
previewGenerated := false
|
||||
|
||||
if _, aerr := prepareVideoForSplit(ctx, it.path, sourceURL, "nsfw"); aerr != nil {
|
||||
// ----------------
|
||||
// META
|
||||
// ----------------
|
||||
if needMeta {
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
st.Error = "mindestens ein Eintrag konnte nicht vollständig analysiert werden (siehe Logs)"
|
||||
st.CurrentFile = it.name
|
||||
st.CurrentQueue = "postwork"
|
||||
st.CurrentPhase = "meta"
|
||||
st.CurrentLabel = "Meta"
|
||||
})
|
||||
//fmt.Println("⚠️ tasks generate assets analyze:", aerr)
|
||||
|
||||
publishAssetsTaskPhase(it.name, "postwork", "meta", "running", "Meta")
|
||||
|
||||
okMeta, merr := ensureMetaForVideoCtx(ctx, it.path, sourceURL)
|
||||
if merr != nil || !okMeta {
|
||||
publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen")
|
||||
} else {
|
||||
publishAssetsTaskPhase(it.name, "postwork", "meta", "done", "Meta")
|
||||
}
|
||||
}
|
||||
|
||||
if res.Skipped {
|
||||
clearAssetsTaskFinishedPostworkState(it.name)
|
||||
} else {
|
||||
publishAssetsTaskFinishedPostworkState(
|
||||
it.name,
|
||||
"done",
|
||||
"Teaser / Vorschau / Thumbnails fertig",
|
||||
)
|
||||
// ----------------
|
||||
// THUMB
|
||||
// ----------------
|
||||
if needThumb {
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
st.CurrentFile = it.name
|
||||
st.CurrentQueue = "postwork"
|
||||
st.CurrentPhase = "thumb"
|
||||
st.CurrentLabel = "Vorschaubild"
|
||||
})
|
||||
|
||||
publishAssetsTaskPhase(it.name, "postwork", "thumb", "running", "Vorschaubild")
|
||||
|
||||
resThumb, terr := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, it.path, sourceURL, nil)
|
||||
if terr != nil {
|
||||
publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen")
|
||||
} else if finishedPhaseTruthForID(id).ThumbReady {
|
||||
if resThumb.ThumbGenerated {
|
||||
thumbGenerated = true
|
||||
}
|
||||
publishAssetsTaskPhase(it.name, "postwork", "thumb", "done", "Vorschaubild")
|
||||
} else {
|
||||
publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen")
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------
|
||||
// TEASER
|
||||
// ----------------
|
||||
if needTeaser {
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
st.CurrentFile = it.name
|
||||
st.CurrentQueue = "postwork"
|
||||
st.CurrentPhase = "teaser"
|
||||
st.CurrentLabel = "Teaser"
|
||||
})
|
||||
|
||||
publishAssetsTaskPhase(it.name, "postwork", "teaser", "running", "Teaser")
|
||||
|
||||
genTeaser, terr := ensureTeaserForVideoCtx(ctx, it.path, sourceURL)
|
||||
if terr != nil {
|
||||
publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen")
|
||||
} else if finishedPhaseTruthForID(id).TeaserReady {
|
||||
if genTeaser {
|
||||
previewGenerated = true
|
||||
}
|
||||
publishAssetsTaskPhase(it.name, "postwork", "teaser", "done", "Teaser")
|
||||
} else {
|
||||
publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen")
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------
|
||||
// SPRITES
|
||||
// ----------------
|
||||
if needSprites {
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
st.CurrentFile = it.name
|
||||
st.CurrentQueue = "postwork"
|
||||
st.CurrentPhase = "sprites"
|
||||
st.CurrentLabel = "Sprites"
|
||||
})
|
||||
|
||||
publishAssetsTaskPhase(it.name, "postwork", "sprites", "running", "Sprites")
|
||||
|
||||
_, serr := ensureSpritesForVideoCtx(ctx, it.path, sourceURL)
|
||||
if serr != nil {
|
||||
publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen")
|
||||
} else if finishedPhaseTruthForID(id).SpritesReady {
|
||||
publishAssetsTaskPhase(it.name, "postwork", "sprites", "done", "Sprites")
|
||||
} else {
|
||||
publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen")
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------
|
||||
// ANALYZE
|
||||
// ----------------
|
||||
if needAnalyze {
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
st.CurrentFile = it.name
|
||||
st.CurrentQueue = "enrich"
|
||||
st.CurrentPhase = "analyze"
|
||||
st.CurrentLabel = "KI-Analyse"
|
||||
})
|
||||
|
||||
publishAssetsTaskPhase(it.name, "enrich", "analyze", "running", "KI-Analyse")
|
||||
|
||||
_, aerr := ensureAnalyzeForVideoCtx(ctx, it.path, sourceURL, "nsfw")
|
||||
if aerr != nil {
|
||||
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen")
|
||||
} else if finishedPhaseTruthForID(id).AnalyzeReady {
|
||||
publishAssetsTaskPhase(it.name, "enrich", "analyze", "done", "KI-Analyse")
|
||||
} else {
|
||||
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen")
|
||||
}
|
||||
}
|
||||
|
||||
updateAssetsState(func(st *AssetsTaskState) {
|
||||
if res.Skipped {
|
||||
st.Skipped++
|
||||
}
|
||||
if res.ThumbGenerated {
|
||||
if thumbGenerated {
|
||||
st.GeneratedThumbs++
|
||||
}
|
||||
if res.PreviewGenerated {
|
||||
if previewGenerated {
|
||||
st.GeneratedPreviews++
|
||||
}
|
||||
st.Done = i + 1
|
||||
|
||||
@ -14,6 +14,12 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type cleanupCandidate struct {
|
||||
path string
|
||||
name string
|
||||
size int64
|
||||
}
|
||||
|
||||
type cleanupResp struct {
|
||||
// Small downloads cleanup
|
||||
ScannedFiles int `json:"scannedFiles"`
|
||||
@ -103,8 +109,9 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
|
||||
return ext == ".mp4" || ext == ".ts"
|
||||
}
|
||||
|
||||
// scan: doneAbs + 1-level subdirs, "keep" wird übersprungen
|
||||
scanDir := func(dir string, allowSubdirs bool) {
|
||||
candidates := make([]cleanupCandidate, 0, 1024)
|
||||
|
||||
collectCandidates := func(dir string, allowSubdirs bool) {
|
||||
ents, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
@ -125,10 +132,12 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, se := range sub {
|
||||
if se.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := se.Name()
|
||||
if !isCandidate(name) {
|
||||
resp.SkippedFiles++
|
||||
@ -142,31 +151,15 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
|
||||
continue
|
||||
}
|
||||
|
||||
resp.ScannedFiles++
|
||||
|
||||
if fi.Size() < threshold {
|
||||
base := strings.TrimSuffix(filepath.Base(p), filepath.Ext(p))
|
||||
id := stripHotPrefix(base)
|
||||
|
||||
if derr := removeWithRetry(p); derr == nil || os.IsNotExist(derr) {
|
||||
resp.DeletedFiles++
|
||||
resp.DeletedBytes += fi.Size()
|
||||
|
||||
// generated + legacy cleanup (best effort)
|
||||
if strings.TrimSpace(id) != "" {
|
||||
removeGeneratedForID(id)
|
||||
}
|
||||
|
||||
purgeDurationCacheForPath(p)
|
||||
} else {
|
||||
resp.ErrorCount++
|
||||
}
|
||||
}
|
||||
candidates = append(candidates, cleanupCandidate{
|
||||
path: p,
|
||||
name: name,
|
||||
size: fi.Size(),
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// root-level file
|
||||
name := e.Name()
|
||||
if !isCandidate(name) {
|
||||
resp.SkippedFiles++
|
||||
@ -179,29 +172,43 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
|
||||
continue
|
||||
}
|
||||
|
||||
resp.ScannedFiles++
|
||||
|
||||
if fi.Size() < threshold {
|
||||
base := strings.TrimSuffix(filepath.Base(full), filepath.Ext(full))
|
||||
id := stripHotPrefix(base)
|
||||
|
||||
if derr := removeWithRetry(full); derr == nil || os.IsNotExist(derr) {
|
||||
resp.DeletedFiles++
|
||||
resp.DeletedBytes += fi.Size()
|
||||
|
||||
if strings.TrimSpace(id) != "" {
|
||||
removeGeneratedForID(id)
|
||||
}
|
||||
|
||||
purgeDurationCacheForPath(full)
|
||||
} else {
|
||||
resp.ErrorCount++
|
||||
}
|
||||
}
|
||||
candidates = append(candidates, cleanupCandidate{
|
||||
path: full,
|
||||
name: name,
|
||||
size: fi.Size(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
scanDir(doneAbs, true)
|
||||
collectCandidates(doneAbs, true)
|
||||
|
||||
resp.ScannedFiles = len(candidates)
|
||||
|
||||
setCleanupTaskProgress(0, resp.ScannedFiles, "", "Prüfe Dateien…")
|
||||
|
||||
for i, c := range candidates {
|
||||
setCleanupTaskProgress(i+1, resp.ScannedFiles, c.name, fmt.Sprintf("Prüfe %s", c.name))
|
||||
|
||||
if c.size >= threshold {
|
||||
continue
|
||||
}
|
||||
|
||||
base := strings.TrimSuffix(filepath.Base(c.path), filepath.Ext(c.path))
|
||||
id := stripHotPrefix(base)
|
||||
|
||||
if derr := removeWithRetry(c.path); derr == nil || os.IsNotExist(derr) {
|
||||
resp.DeletedFiles++
|
||||
resp.DeletedBytes += c.size
|
||||
|
||||
if strings.TrimSpace(id) != "" {
|
||||
removeGeneratedForID(id)
|
||||
}
|
||||
|
||||
purgeDurationCacheForPath(c.path)
|
||||
} else {
|
||||
resp.ErrorCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var generatedGCRunning int32
|
||||
|
||||
@ -7,12 +7,17 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CleanupTaskState struct {
|
||||
Running bool `json:"running"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Running bool `json:"running"`
|
||||
Done int `json:"done"`
|
||||
Total int `json:"total"`
|
||||
Text string `json:"text,omitempty"`
|
||||
CurrentFile string `json:"currentFile,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
FinishedAt *string `json:"finishedAt,omitempty"`
|
||||
}
|
||||
|
||||
type RegenerateAssetsTaskState struct {
|
||||
@ -37,6 +42,11 @@ var (
|
||||
regenerateAssetsTaskState RegenerateAssetsTaskState
|
||||
)
|
||||
|
||||
func cleanupNowISO() *string {
|
||||
s := time.Now().Format(time.RFC3339)
|
||||
return &s
|
||||
}
|
||||
|
||||
func snapshotRegenerateAssetsTaskState() RegenerateAssetsTaskState {
|
||||
regenerateAssetsTaskMu.Lock()
|
||||
st := regenerateAssetsTaskState
|
||||
@ -77,6 +87,12 @@ func setRegenerateAssetsTaskError(file, errText string) {
|
||||
regenerateAssetsTaskMu.Unlock()
|
||||
}
|
||||
|
||||
func clearRegenerateAssetsTaskState() {
|
||||
regenerateAssetsTaskMu.Lock()
|
||||
regenerateAssetsTaskState = RegenerateAssetsTaskState{}
|
||||
regenerateAssetsTaskMu.Unlock()
|
||||
}
|
||||
|
||||
func snapshotCleanupTaskState() CleanupTaskState {
|
||||
cleanupTaskMu.Lock()
|
||||
st := cleanupTaskState
|
||||
@ -87,30 +103,50 @@ func snapshotCleanupTaskState() CleanupTaskState {
|
||||
func setCleanupTaskRunning(text string) {
|
||||
cleanupTaskMu.Lock()
|
||||
cleanupTaskState = CleanupTaskState{
|
||||
Running: true,
|
||||
Text: strings.TrimSpace(text),
|
||||
Error: "",
|
||||
Running: true,
|
||||
Done: 0,
|
||||
Total: 0,
|
||||
Text: strings.TrimSpace(text),
|
||||
CurrentFile: "",
|
||||
Error: "",
|
||||
FinishedAt: nil,
|
||||
}
|
||||
cleanupTaskMu.Unlock()
|
||||
}
|
||||
|
||||
func setCleanupTaskProgress(done, total int, currentFile, text string) {
|
||||
cleanupTaskMu.Lock()
|
||||
cleanupTaskState.Running = true
|
||||
cleanupTaskState.Done = done
|
||||
cleanupTaskState.Total = total
|
||||
cleanupTaskState.CurrentFile = strings.TrimSpace(currentFile)
|
||||
cleanupTaskState.Text = strings.TrimSpace(text)
|
||||
cleanupTaskState.Error = ""
|
||||
cleanupTaskState.FinishedAt = nil
|
||||
cleanupTaskMu.Unlock()
|
||||
}
|
||||
|
||||
func setCleanupTaskDone(text string) {
|
||||
cleanupTaskMu.Lock()
|
||||
cleanupTaskState = CleanupTaskState{
|
||||
Running: false,
|
||||
Text: strings.TrimSpace(text),
|
||||
Error: "",
|
||||
cleanupTaskState.Running = false
|
||||
cleanupTaskState.Text = strings.TrimSpace(text)
|
||||
cleanupTaskState.CurrentFile = ""
|
||||
cleanupTaskState.Error = ""
|
||||
cleanupTaskState.FinishedAt = cleanupNowISO()
|
||||
|
||||
if cleanupTaskState.Total > 0 {
|
||||
cleanupTaskState.Done = cleanupTaskState.Total
|
||||
}
|
||||
|
||||
cleanupTaskMu.Unlock()
|
||||
}
|
||||
|
||||
func setCleanupTaskError(errText string) {
|
||||
cleanupTaskMu.Lock()
|
||||
cleanupTaskState = CleanupTaskState{
|
||||
Running: false,
|
||||
Text: "",
|
||||
Error: strings.TrimSpace(errText),
|
||||
}
|
||||
cleanupTaskState.Running = false
|
||||
cleanupTaskState.Error = strings.TrimSpace(errText)
|
||||
cleanupTaskState.CurrentFile = ""
|
||||
cleanupTaskState.FinishedAt = cleanupNowISO()
|
||||
cleanupTaskMu.Unlock()
|
||||
}
|
||||
|
||||
|
||||
@ -69,7 +69,7 @@ type RecorderSettingsState = {
|
||||
}
|
||||
|
||||
type JobEvent = {
|
||||
type?: 'job_upsert' | 'job_remove' | 'finished_postwork'
|
||||
type?: 'job_upsert' | 'job_remove'
|
||||
model?: string
|
||||
jobId?: string
|
||||
status?: string
|
||||
@ -604,17 +604,33 @@ export default function App() {
|
||||
|
||||
const loadSettingsTaskState = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/tasks/generate-assets', { cache: 'no-store' as any })
|
||||
if (!res.ok) {
|
||||
if (!cancelled) setSettingsTaskRunning(false)
|
||||
return
|
||||
const [genRes, statusRes] = await Promise.all([
|
||||
fetch('/api/tasks/generate-assets', { cache: 'no-store' as any }),
|
||||
fetch('/api/tasks/status', { cache: 'no-store' as any }),
|
||||
])
|
||||
|
||||
let generateAssetsRunning = false
|
||||
let regenerateAssetsRunning = false
|
||||
let cleanupRunning = false
|
||||
|
||||
if (genRes.ok) {
|
||||
const genData = await genRes.json().catch(() => null)
|
||||
generateAssetsRunning = Boolean(genData?.running)
|
||||
}
|
||||
|
||||
if (statusRes.ok) {
|
||||
const statusData = await statusRes.json().catch(() => null)
|
||||
regenerateAssetsRunning = Boolean(statusData?.regenerateAssets?.running)
|
||||
cleanupRunning = Boolean(statusData?.cleanup?.running)
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => null)
|
||||
if (cancelled) return
|
||||
|
||||
const running = Boolean(data?.running)
|
||||
setSettingsTaskRunning(running)
|
||||
setSettingsTaskRunning(
|
||||
generateAssetsRunning ||
|
||||
regenerateAssetsRunning ||
|
||||
cleanupRunning
|
||||
)
|
||||
} catch {
|
||||
if (!cancelled) setSettingsTaskRunning(false)
|
||||
}
|
||||
@ -1250,56 +1266,6 @@ export default function App() {
|
||||
const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent
|
||||
const modelKey = String(msg?.model ?? '').trim().toLowerCase()
|
||||
|
||||
// ✅ finished_postwork direkt an FinishedDownloads weiterreichen
|
||||
if (msg?.type === 'finished_postwork') {
|
||||
const file = String(msg?.file ?? '').trim()
|
||||
if (file) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('finished-downloads:postwork', {
|
||||
detail: {
|
||||
file,
|
||||
assetId: String(msg?.assetId ?? '').trim() || undefined,
|
||||
queue:
|
||||
msg?.queue === 'enrich'
|
||||
? 'enrich'
|
||||
: 'postwork',
|
||||
state:
|
||||
msg?.state === 'queued' ||
|
||||
msg?.state === 'running' ||
|
||||
msg?.state === 'done' ||
|
||||
msg?.state === 'error' ||
|
||||
msg?.state === 'missing'
|
||||
? msg.state
|
||||
: 'missing',
|
||||
phase: String(msg?.phase ?? '').trim() || undefined,
|
||||
label: String(msg?.label ?? '').trim() || undefined,
|
||||
position:
|
||||
typeof msg?.position === 'number' && Number.isFinite(msg.position)
|
||||
? msg.position
|
||||
: undefined,
|
||||
waiting:
|
||||
typeof msg?.waiting === 'number' && Number.isFinite(msg.waiting)
|
||||
? msg.waiting
|
||||
: undefined,
|
||||
running:
|
||||
typeof msg?.running === 'number' && Number.isFinite(msg.running)
|
||||
? msg.running
|
||||
: undefined,
|
||||
maxParallel:
|
||||
typeof msg?.maxParallel === 'number' && Number.isFinite(msg.maxParallel)
|
||||
? msg.maxParallel
|
||||
: undefined,
|
||||
ts:
|
||||
typeof msg?.ts === 'number' && Number.isFinite(msg.ts)
|
||||
? msg.ts
|
||||
: Date.now(),
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (msg?.type === 'job_upsert') {
|
||||
if (modelKey) {
|
||||
const roomStatus = String(msg?.roomStatus ?? '').trim().toLowerCase()
|
||||
@ -2453,34 +2419,6 @@ export default function App() {
|
||||
}, document.hidden ? 60000 : 5000)
|
||||
}
|
||||
|
||||
const lastDoneFireRef = { t: 0 }
|
||||
let doneCoalesceTimer: number | null = null
|
||||
|
||||
const requestDoneRefresh = () => {
|
||||
const now = Date.now()
|
||||
const since = now - lastDoneFireRef.t
|
||||
|
||||
if (since < 800) {
|
||||
if (doneCoalesceTimer != null) return
|
||||
doneCoalesceTimer = window.setTimeout(() => {
|
||||
doneCoalesceTimer = null
|
||||
lastDoneFireRef.t = Date.now()
|
||||
|
||||
void loadDoneCount()
|
||||
if (selectedTabRef.current === 'finished') {
|
||||
requestFinishedReload('doneChanged coalesced')
|
||||
}
|
||||
}, 900)
|
||||
return
|
||||
}
|
||||
|
||||
lastDoneFireRef.t = now
|
||||
void loadDoneCount()
|
||||
if (selectedTabRef.current === 'finished') {
|
||||
requestFinishedReload('doneChanged')
|
||||
}
|
||||
}
|
||||
|
||||
const applyAutostartState = (data: unknown) => {
|
||||
const d = (data ?? {}) as any
|
||||
setAutostartState({
|
||||
@ -2490,11 +2428,64 @@ export default function App() {
|
||||
})
|
||||
}
|
||||
|
||||
// Initialdaten
|
||||
const onDoneChanged = () => {
|
||||
void loadDoneCount()
|
||||
}
|
||||
|
||||
const onAutostart = (ev: MessageEvent) => {
|
||||
try {
|
||||
applyAutostartState(JSON.parse(String(ev.data ?? 'null')))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const onFinishedPostwork = (ev: MessageEvent) => {
|
||||
try {
|
||||
const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent
|
||||
const file = String(msg?.file ?? '').trim()
|
||||
if (!file) return
|
||||
|
||||
const state =
|
||||
msg?.state === 'queued' ||
|
||||
msg?.state === 'running' ||
|
||||
msg?.state === 'done' ||
|
||||
msg?.state === 'error' ||
|
||||
msg?.state === 'missing'
|
||||
? msg.state
|
||||
: 'missing'
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('finished-downloads:postwork', {
|
||||
detail: {
|
||||
file,
|
||||
assetId: String(msg?.assetId ?? '').trim() || undefined,
|
||||
queue: msg?.queue === 'enrich' ? 'enrich' : 'postwork',
|
||||
state,
|
||||
phase: String(msg?.phase ?? '').trim() || undefined,
|
||||
label: String(msg?.label ?? '').trim() || undefined,
|
||||
position: typeof msg?.position === 'number' && Number.isFinite(msg.position) ? msg.position : undefined,
|
||||
waiting: typeof msg?.waiting === 'number' && Number.isFinite(msg.waiting) ? msg.waiting : undefined,
|
||||
running: typeof msg?.running === 'number' && Number.isFinite(msg.running) ? msg.running : undefined,
|
||||
maxParallel: typeof msg?.maxParallel === 'number' && Number.isFinite(msg.maxParallel) ? msg.maxParallel : undefined,
|
||||
ts: typeof msg?.ts === 'number' && Number.isFinite(msg.ts) ? msg.ts : Date.now(),
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// ✅ Wenn Assets fertig / fehlerhaft / entfernt sind, Preview-/Sprite-Cache neu ziehen
|
||||
if (state === 'done' || state === 'error' || state === 'missing') {
|
||||
bumpAssets()
|
||||
}
|
||||
|
||||
// ✅ Optional: Finished-Ansicht sichtbar? Dann Liste leicht anstoßen
|
||||
if (selectedTabRef.current === 'finished') {
|
||||
requestFinishedReload(`finishedPostwork:${state}`)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
void loadJobs()
|
||||
void loadDoneCount()
|
||||
|
||||
// Optional initialer Fallback für autostart, bis erstes SSE kommt
|
||||
void apiJSON<AutostartState>('/api/autostart/state', { cache: 'no-store' as any })
|
||||
.then((s) => {
|
||||
setAutostartState({
|
||||
@ -2517,102 +2508,10 @@ export default function App() {
|
||||
startFallbackPoll()
|
||||
}
|
||||
|
||||
const onDoneChanged = () => {
|
||||
requestDoneRefresh()
|
||||
}
|
||||
|
||||
const onAutostart = (ev: MessageEvent) => {
|
||||
try {
|
||||
applyAutostartState(JSON.parse(String(ev.data ?? 'null')))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const onFinishedPostwork = (ev: MessageEvent) => {
|
||||
try {
|
||||
const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent
|
||||
const file = String(msg?.file ?? '').trim()
|
||||
if (!file) return
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('finished-downloads:postwork', {
|
||||
detail: {
|
||||
file,
|
||||
assetId: String(msg?.assetId ?? '').trim() || undefined,
|
||||
queue:
|
||||
msg?.queue === 'enrich'
|
||||
? 'enrich'
|
||||
: 'postwork',
|
||||
state:
|
||||
msg?.state === 'queued' ||
|
||||
msg?.state === 'running' ||
|
||||
msg?.state === 'done' ||
|
||||
msg?.state === 'error' ||
|
||||
msg?.state === 'missing'
|
||||
? msg.state
|
||||
: 'missing',
|
||||
phase: String(msg?.phase ?? '').trim() || undefined,
|
||||
label: String(msg?.label ?? '').trim() || undefined,
|
||||
position:
|
||||
typeof msg?.position === 'number' && Number.isFinite(msg.position)
|
||||
? msg.position
|
||||
: undefined,
|
||||
waiting:
|
||||
typeof msg?.waiting === 'number' && Number.isFinite(msg.waiting)
|
||||
? msg.waiting
|
||||
: undefined,
|
||||
running:
|
||||
typeof msg?.running === 'number' && Number.isFinite(msg.running)
|
||||
? msg.running
|
||||
: undefined,
|
||||
maxParallel:
|
||||
typeof msg?.maxParallel === 'number' && Number.isFinite(msg.maxParallel)
|
||||
? msg.maxParallel
|
||||
: undefined,
|
||||
ts:
|
||||
typeof msg?.ts === 'number' && Number.isFinite(msg.ts)
|
||||
? msg.ts
|
||||
: Date.now(),
|
||||
},
|
||||
})
|
||||
)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
es.addEventListener('doneChanged', onDoneChanged as any)
|
||||
es.addEventListener('autostart', onAutostart as any)
|
||||
es.addEventListener('finishedPostwork', onFinishedPostwork as any)
|
||||
|
||||
// initial nur Listener für wirklich relevante Jobs / Queue setzen
|
||||
|
||||
// 1) sichtbare Downloads + sichtbare Nacharbeiten
|
||||
for (const j of jobsRef.current) {
|
||||
if (!isVisibleDownloadJobForSSE(j) && !isVisiblePostworkJobForSSE(j)) continue
|
||||
|
||||
const key = modelEventKeyFromJob(j)
|
||||
if (key) ensureModelEventListener(key)
|
||||
}
|
||||
|
||||
// 2) sichtbare Finished-Jobs der aktuellen Seite
|
||||
for (const j of doneJobs) {
|
||||
const key = modelEventKeyFromDoneJob(j)
|
||||
if (key) ensureModelEventListener(key)
|
||||
}
|
||||
|
||||
// 3) sichtbare Wartenden-Einträge
|
||||
for (const p of pendingWatchedRooms) {
|
||||
const k = String(p?.modelKey ?? '').trim().toLowerCase()
|
||||
if (k) ensureModelEventListener(k)
|
||||
}
|
||||
|
||||
// 4) watched Models ebenfalls abonnieren
|
||||
for (const key of watchedModelKeysLower) {
|
||||
if (key) ensureModelEventListener(key)
|
||||
}
|
||||
|
||||
const onVis = () => {
|
||||
if (document.hidden) return
|
||||
void loadJobs()
|
||||
@ -2626,11 +2525,6 @@ export default function App() {
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVis)
|
||||
|
||||
if (doneCoalesceTimer != null) {
|
||||
window.clearTimeout(doneCoalesceTimer)
|
||||
}
|
||||
|
||||
stopFallbackPoll()
|
||||
|
||||
if (es) {
|
||||
@ -2650,18 +2544,8 @@ export default function App() {
|
||||
|
||||
eventSourceRef.current = null
|
||||
modelEventNamesRef.current = new Set()
|
||||
es = null
|
||||
}
|
||||
}, [
|
||||
authed,
|
||||
loadJobs,
|
||||
loadDoneCount,
|
||||
requestFinishedReload,
|
||||
ensureModelEventListener,
|
||||
doneJobs,
|
||||
pendingWatchedRooms,
|
||||
watchedModelKeysLower,
|
||||
])
|
||||
}, [authed, loadJobs, loadDoneCount, requestFinishedReload])
|
||||
|
||||
useEffect(() => {
|
||||
const desired = new Set<string>()
|
||||
@ -3648,6 +3532,7 @@ export default function App() {
|
||||
blurPreviews={Boolean(recSettings.blurPreviews)}
|
||||
teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
|
||||
teaserAudio={Boolean(recSettings.teaserAudio)}
|
||||
pauseTeasers={Boolean(detailsModelKey) || splitModalOpen}
|
||||
assetNonce={assetNonce}
|
||||
sortMode={doneSort}
|
||||
onSortModeChange={(m) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -3,7 +3,12 @@
|
||||
|
||||
import * as React from 'react'
|
||||
import Card from './Card'
|
||||
import type { RecordJob } from '../../types'
|
||||
import type {
|
||||
RecordJob,
|
||||
FinishedPostworkSummary,
|
||||
GalleryModelFlags,
|
||||
InlinePlayState
|
||||
} from '../../types'
|
||||
import FinishedVideoPreview from './FinishedVideoPreview'
|
||||
import SwipeCard, { type SwipeCardHandle } from './SwipeCard'
|
||||
import RecordJobActions from './RecordJobActions'
|
||||
@ -17,8 +22,13 @@ import {
|
||||
shouldEnableTeaserAudio,
|
||||
} from './teaserPlayback'
|
||||
import Checkbox from './Checkbox'
|
||||
|
||||
type InlinePlayState = { key: string; nonce: number } | null
|
||||
import { SpeakerWaveIcon } from '@heroicons/react/24/solid'
|
||||
import {
|
||||
buildSpriteFrameStyle,
|
||||
parseJobMeta,
|
||||
readPreviewSpriteInfo,
|
||||
scrubProgressRatioFromIndex,
|
||||
} from './previewSprite'
|
||||
|
||||
type Props = {
|
||||
rows: RecordJob[]
|
||||
@ -70,15 +80,7 @@ type Props = {
|
||||
|
||||
releasePlayingFile: (file: string, opts?: { close?: boolean }) => Promise<void>
|
||||
|
||||
modelsByKey: Record<string, {
|
||||
favorite?: boolean
|
||||
liked?: boolean | null
|
||||
watching?: boolean | null
|
||||
tags?: string
|
||||
// ✅ wie GalleryView: optionale Scrubber-Meta-Fallbacks
|
||||
previewScrubberPath?: string
|
||||
previewScrubberCount?: number
|
||||
}>
|
||||
modelsByKey: Record<string, GalleryModelFlags>
|
||||
activeTagSet: Set<string>
|
||||
onToggleTagFilter: (tag: string) => void
|
||||
onToggleHot?: (job: RecordJob) => void | Promise<void>
|
||||
@ -88,35 +90,17 @@ type Props = {
|
||||
onSplit?: (job: RecordJob) => void | Promise<void>
|
||||
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
||||
|
||||
postworkByFile: Record<string, {
|
||||
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
|
||||
}>
|
||||
postworkByFile: Record<string, FinishedPostworkSummary>
|
||||
getPostworkSummaryForOutput: (
|
||||
output: string | undefined,
|
||||
summaries: Record<string, FinishedPostworkSummary>
|
||||
) => FinishedPostworkSummary | undefined
|
||||
|
||||
renderPostworkBadge?: (badge?: {
|
||||
file: string
|
||||
assetId?: string
|
||||
queue: 'postwork' | 'enrich'
|
||||
state: 'queued' | 'running' | 'done' | 'error' | 'missing'
|
||||
phase?: string
|
||||
label?: string
|
||||
position?: number
|
||||
waiting?: number
|
||||
running?: number
|
||||
maxParallel?: number
|
||||
ts: number
|
||||
}) => React.ReactNode
|
||||
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode
|
||||
|
||||
forcePreviewMuted?: boolean
|
||||
mobileTeaserAudioUnlocked?: boolean
|
||||
onUnlockMobileTeaserAudio?: () => void
|
||||
}
|
||||
|
||||
const parseTags = (raw?: string): string[] => {
|
||||
@ -138,58 +122,6 @@ const parseTags = (raw?: string): string[] => {
|
||||
return out
|
||||
}
|
||||
|
||||
function firstNonEmptyString(...values: unknown[]): string | undefined {
|
||||
for (const v of values) {
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim()
|
||||
if (s) return s
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function normalizeDurationSeconds(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
||||
// ms -> s Heuristik wie in GalleryView / FinishedVideoPreview
|
||||
return value > 24 * 60 * 60 ? value / 1000 : value
|
||||
}
|
||||
|
||||
function clamp(n: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, n))
|
||||
}
|
||||
|
||||
const DEFAULT_SPRITE_STEP_SECONDS = 5
|
||||
|
||||
function chooseSpriteGrid(count: number): [number, number] {
|
||||
if (count <= 1) return [1, 1]
|
||||
|
||||
const targetRatio = 16 / 9
|
||||
let bestCols = 1
|
||||
let bestRows = count
|
||||
let bestWaste = Number.POSITIVE_INFINITY
|
||||
let bestRatioScore = Number.POSITIVE_INFINITY
|
||||
|
||||
for (let c = 1; c <= count; c++) {
|
||||
const r = Math.max(1, Math.ceil(count / c))
|
||||
const waste = c * r - count
|
||||
const ratio = c / r
|
||||
const ratioScore = Math.abs(ratio - targetRatio)
|
||||
|
||||
if (
|
||||
waste < bestWaste ||
|
||||
(waste === bestWaste && ratioScore < bestRatioScore) ||
|
||||
(waste === bestWaste && ratioScore === bestRatioScore && r < bestRows)
|
||||
) {
|
||||
bestWaste = waste
|
||||
bestRatioScore = ratioScore
|
||||
bestCols = c
|
||||
bestRows = r
|
||||
}
|
||||
}
|
||||
|
||||
return [bestCols, bestRows]
|
||||
}
|
||||
|
||||
function CardBlurWrapper({
|
||||
blurred,
|
||||
animateUnblurOnMount,
|
||||
@ -420,88 +352,49 @@ export default function FinishedDownloadsCardsView({
|
||||
onSplit,
|
||||
onAddToDownloads,
|
||||
postworkByFile,
|
||||
getPostworkSummaryForOutput,
|
||||
renderPostworkBadge,
|
||||
forcePreviewMuted,
|
||||
mobileTeaserAudioUnlocked,
|
||||
onUnlockMobileTeaserAudio,
|
||||
}: Props) {
|
||||
|
||||
const hasAnySelection = selectedKeys.size > 0
|
||||
const footerTapRef = React.useRef<Record<string, boolean>>({})
|
||||
|
||||
const teaserPlayFnsRef = React.useRef<Record<string, (() => void) | null>>({})
|
||||
|
||||
const [speedHoldKey, setSpeedHoldKey] = React.useState<string | null>(null)
|
||||
|
||||
const registerTeaserPlayForKey = React.useCallback(
|
||||
(key: string) => (fn: (() => void) | null) => {
|
||||
if (fn) teaserPlayFnsRef.current[key] = fn
|
||||
else delete teaserPlayFnsRef.current[key]
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const parseMeta = React.useCallback((j: RecordJob): any | null => {
|
||||
const raw: any = (j as any)?.meta
|
||||
if (!raw) return null
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return raw
|
||||
return parseJobMeta((j as any)?.meta)
|
||||
}, [])
|
||||
|
||||
const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => {
|
||||
const meta = parseMeta(j)
|
||||
|
||||
const w =
|
||||
(typeof meta?.videoWidth === 'number' && Number.isFinite(meta.videoWidth) ? meta.videoWidth : 0) ||
|
||||
(typeof j.videoWidth === 'number' && Number.isFinite(j.videoWidth) ? j.videoWidth : 0)
|
||||
typeof meta?.file?.video?.width === 'number' && Number.isFinite(meta.file.video.width)
|
||||
? meta.file.video.width
|
||||
: 0
|
||||
|
||||
const h =
|
||||
(typeof meta?.videoHeight === 'number' && Number.isFinite(meta.videoHeight) ? meta.videoHeight : 0) ||
|
||||
(typeof j.videoHeight === 'number' && Number.isFinite(j.videoHeight) ? j.videoHeight : 0)
|
||||
typeof meta?.file?.video?.height === 'number' && Number.isFinite(meta.file.video.height)
|
||||
? meta.file.video.height
|
||||
: 0
|
||||
|
||||
if (w > 0 && h > 0) return { w, h }
|
||||
return null
|
||||
}, [parseMeta])
|
||||
|
||||
const previewScrubberInfoOf = React.useCallback(
|
||||
(j: RecordJob): { count: number; stepSeconds: number } | null => {
|
||||
const meta = parseMeta(j)
|
||||
|
||||
let ps: any =
|
||||
meta?.previewSprite ??
|
||||
meta?.preview?.sprite ??
|
||||
meta?.sprite ??
|
||||
(j as any)?.previewSprite ??
|
||||
(j as any)?.preview?.sprite ??
|
||||
null
|
||||
|
||||
if (typeof ps === 'string') {
|
||||
try {
|
||||
ps = JSON.parse(ps)
|
||||
} catch {
|
||||
ps = null
|
||||
}
|
||||
}
|
||||
|
||||
const countRaw = Number(ps?.count ?? ps?.frames ?? ps?.imageCount)
|
||||
const stepRaw = Number(ps?.stepSeconds ?? ps?.step ?? ps?.intervalSeconds)
|
||||
|
||||
if (Number.isFinite(countRaw) && countRaw >= 2) {
|
||||
return {
|
||||
count: Math.max(2, Math.floor(countRaw)),
|
||||
stepSeconds: Number.isFinite(stepRaw) && stepRaw > 0 ? stepRaw : 5,
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: aus Dauer ableiten
|
||||
const k = keyFor(j)
|
||||
const dur =
|
||||
durations[k] ??
|
||||
(typeof (j as any)?.durationSeconds === 'number' ? (j as any).durationSeconds : undefined)
|
||||
|
||||
if (typeof dur === 'number' && Number.isFinite(dur) && dur > 1) {
|
||||
const stepSeconds = 5
|
||||
const count = Math.max(2, Math.min(240, Math.floor(dur / stepSeconds) + 1))
|
||||
return { count, stepSeconds }
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
[parseMeta, keyFor, durations]
|
||||
)
|
||||
|
||||
const [scrubActiveByKey, setScrubActiveByKey] = React.useState<Record<string, number | undefined>>({})
|
||||
const [scrubHoveringByKey, setScrubHoveringByKey] = React.useState<Record<string, boolean | undefined>>({})
|
||||
|
||||
@ -566,13 +459,16 @@ export default function FinishedDownloadsCardsView({
|
||||
disabled: Boolean(opts?.forceStill),
|
||||
})
|
||||
|
||||
const isMobileTopCard = Boolean(isSmall && opts?.mobileStackTopOnlyVideo)
|
||||
|
||||
const previewMuted = forcePreviewMuted
|
||||
? true
|
||||
: (
|
||||
isSmall && opts?.mobileStackTopOnlyVideo
|
||||
? true
|
||||
? teaserAudioEnabledForKey !== k
|
||||
: !allowSound
|
||||
)
|
||||
|
||||
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
|
||||
const forceLoadStill = Boolean(opts?.forceLoadStill)
|
||||
|
||||
@ -595,133 +491,33 @@ export default function FinishedDownloadsCardsView({
|
||||
|
||||
const model = modelNameFromOutput(j.output)
|
||||
const fileRaw = baseName(j.output || '')
|
||||
const livePostwork =
|
||||
postworkByFile[fileRaw] ??
|
||||
postworkByFile[stripHotPrefix(fileRaw)]
|
||||
const previewId = stripHotPrefix(fileRaw.replace(/\.[^.]+$/, '')).trim()
|
||||
const livePostwork = getPostworkSummaryForOutput(j.output, postworkByFile)
|
||||
const modelKey = lower(model)
|
||||
const flags = modelsByKey[modelKey]
|
||||
|
||||
const meta = parseMeta(j)
|
||||
const spriteInfo = previewScrubberInfoOf(j)
|
||||
const scrubActiveIndex = scrubActiveByKey[k]
|
||||
const scrubHovering = scrubHoveringByKey[k] === true
|
||||
|
||||
// ✅ Sprite-Quelle wie in GalleryView (1 Request, danach nur CSS background-position)
|
||||
const spritePathRaw = firstNonEmptyString(
|
||||
meta?.previewSprite?.path,
|
||||
(meta as any)?.previewSpritePath,
|
||||
flags?.previewScrubberPath,
|
||||
previewId ? `/api/preview-sprite/${encodeURIComponent(previewId)}` : undefined
|
||||
)
|
||||
const spritePath = spritePathRaw ? spritePathRaw.replace(/\/+$/, '') : undefined
|
||||
const sprite = readPreviewSpriteInfo((j as any)?.meta, {
|
||||
fallbackPath: flags?.previewScrubberPath,
|
||||
fallbackCount: flags?.previewScrubberCount,
|
||||
versionFallback: assetNonce ?? 0,
|
||||
})
|
||||
|
||||
const spriteStepSecondsRaw =
|
||||
meta?.previewSprite?.stepSeconds ?? (meta as any)?.previewSpriteStepSeconds
|
||||
const spriteUrl = sprite.url
|
||||
const hasScrubberUi = sprite.hasScrubberUi
|
||||
const hasSpriteScrubber = sprite.hasSpriteScrubber
|
||||
const scrubberCount = hasScrubberUi ? sprite.count : 0
|
||||
const scrubberStepSeconds = hasScrubberUi ? sprite.stepSeconds : 0
|
||||
|
||||
const spriteStepSeconds =
|
||||
typeof spriteStepSecondsRaw === 'number' &&
|
||||
Number.isFinite(spriteStepSecondsRaw) &&
|
||||
spriteStepSecondsRaw > 0
|
||||
? spriteStepSecondsRaw
|
||||
: (spriteInfo?.stepSeconds && spriteInfo.stepSeconds > 0
|
||||
? spriteInfo.stepSeconds
|
||||
: DEFAULT_SPRITE_STEP_SECONDS)
|
||||
const scrubProgressRatio = scrubProgressRatioFromIndex(scrubActiveIndex, scrubberCount)
|
||||
|
||||
// Dauer fallback (für count-Inferenz)
|
||||
const durationForSprite =
|
||||
normalizeDurationSeconds(meta?.durationSeconds) ??
|
||||
normalizeDurationSeconds((j as any)?.durationSeconds) ??
|
||||
normalizeDurationSeconds(durations[k])
|
||||
|
||||
const inferredSpriteCountFromDuration =
|
||||
typeof durationForSprite === 'number' && durationForSprite > 0
|
||||
? Math.max(1, Math.min(200, Math.floor(durationForSprite / spriteStepSeconds) + 1))
|
||||
: undefined
|
||||
|
||||
const spriteCountRaw =
|
||||
meta?.previewSprite?.count ??
|
||||
(meta as any)?.previewSpriteCount ??
|
||||
flags?.previewScrubberCount ??
|
||||
inferredSpriteCountFromDuration
|
||||
|
||||
const spriteColsRaw = meta?.previewSprite?.cols ?? (meta as any)?.previewSpriteCols
|
||||
const spriteRowsRaw = meta?.previewSprite?.rows ?? (meta as any)?.previewSpriteRows
|
||||
|
||||
const spriteCount =
|
||||
typeof spriteCountRaw === 'number' && Number.isFinite(spriteCountRaw)
|
||||
? Math.max(0, Math.floor(spriteCountRaw))
|
||||
: 0
|
||||
|
||||
const [inferredCols, inferredRows] =
|
||||
spriteCount > 1 ? chooseSpriteGrid(spriteCount) : [0, 0]
|
||||
|
||||
// ✅ explizite Werte robust lesen (auch wenn mal String kommt)
|
||||
const explicitSpriteCols = Number(spriteColsRaw)
|
||||
const explicitSpriteRows = Number(spriteRowsRaw)
|
||||
|
||||
// ✅ Nur übernehmen, wenn plausibel.
|
||||
// Verhindert den Fall cols=1/rows=1 bei count>1 (zeigt sonst ganze Sprite-Map)
|
||||
const explicitGridLooksValid =
|
||||
Number.isFinite(explicitSpriteCols) &&
|
||||
Number.isFinite(explicitSpriteRows) &&
|
||||
explicitSpriteCols > 0 &&
|
||||
explicitSpriteRows > 0 &&
|
||||
!(spriteCount > 1 && explicitSpriteCols === 1 && explicitSpriteRows === 1) &&
|
||||
(spriteCount <= 1 || explicitSpriteCols * explicitSpriteRows >= spriteCount)
|
||||
|
||||
const spriteCols = explicitGridLooksValid ? Math.floor(explicitSpriteCols) : inferredCols
|
||||
const spriteRows = explicitGridLooksValid ? Math.floor(explicitSpriteRows) : inferredRows
|
||||
|
||||
const spriteVersion =
|
||||
(typeof meta?.updatedAtUnix === 'number' && Number.isFinite(meta.updatedAtUnix)
|
||||
? meta.updatedAtUnix
|
||||
: undefined) ??
|
||||
(typeof (meta as any)?.fileModUnix === 'number' && Number.isFinite((meta as any).fileModUnix)
|
||||
? (meta as any).fileModUnix
|
||||
: undefined) ??
|
||||
(assetNonce ?? 0)
|
||||
|
||||
const spriteUrl =
|
||||
spritePath && spriteVersion
|
||||
? `${spritePath}?v=${encodeURIComponent(String(spriteVersion))}`
|
||||
: spritePath || undefined
|
||||
|
||||
const hasScrubberUi =
|
||||
Boolean(spriteUrl) &&
|
||||
spriteCount > 1
|
||||
|
||||
const hasSpriteScrubber =
|
||||
hasScrubberUi &&
|
||||
spriteCols > 0 &&
|
||||
spriteRows > 0
|
||||
|
||||
const scrubberCount = hasScrubberUi ? spriteCount : 0
|
||||
const scrubberStepSeconds = hasScrubberUi ? spriteStepSeconds : 0
|
||||
|
||||
const scrubProgressRatio =
|
||||
typeof scrubActiveIndex === 'number' && scrubberCount > 1
|
||||
? clamp(scrubActiveIndex / (scrubberCount - 1), 0, 1)
|
||||
: undefined
|
||||
|
||||
const spriteFrameStyle: React.CSSProperties | undefined =
|
||||
hasSpriteScrubber && typeof scrubActiveIndex === 'number'
|
||||
? (() => {
|
||||
const idx = clamp(scrubActiveIndex, 0, Math.max(0, spriteCount - 1))
|
||||
const col = idx % spriteCols
|
||||
const row = Math.floor(idx / spriteCols)
|
||||
|
||||
const posX = spriteCols <= 1 ? 0 : (col / (spriteCols - 1)) * 100
|
||||
const posY = spriteRows <= 1 ? 0 : (row / (spriteRows - 1)) * 100
|
||||
|
||||
return {
|
||||
backgroundImage: `url("${spriteUrl}")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: `${spriteCols * 100}% ${spriteRows * 100}%`,
|
||||
backgroundPosition: `${posX}% ${posY}%`,
|
||||
}
|
||||
})()
|
||||
: undefined
|
||||
const spriteFrameStyle = buildSpriteFrameStyle({
|
||||
spriteUrl,
|
||||
spriteCols: sprite.cols,
|
||||
spriteRows: sprite.rows,
|
||||
spriteCount: sprite.count,
|
||||
activeIndex: scrubActiveIndex,
|
||||
})
|
||||
|
||||
const isHot = isHotName(fileRaw)
|
||||
const isFav = Boolean(flags?.favorite)
|
||||
@ -747,9 +543,17 @@ export default function FinishedDownloadsCardsView({
|
||||
allowTeaserAnimation ? 'anim' : 'still',
|
||||
].join('::')
|
||||
|
||||
const showMobileAudioButton =
|
||||
Boolean(onUnlockMobileTeaserAudio) &&
|
||||
isMobileTopCard &&
|
||||
!inlineActive &&
|
||||
!opts?.forceStill &&
|
||||
allowTeaserAnimation &&
|
||||
audioButtonDismissedForKey !== k
|
||||
|
||||
// ✅ Shell an Gallery angelehnt
|
||||
const shellCls = [
|
||||
'group relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
|
||||
'group relative rounded-lg overflow-visible outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
|
||||
'bg-white dark:bg-gray-900/40',
|
||||
'transition-all duration-200',
|
||||
!isSmall && 'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
|
||||
@ -901,7 +705,7 @@ export default function FinishedDownloadsCardsView({
|
||||
getFileName={(p) => stripHotPrefix(baseName(p))}
|
||||
className="h-full w-full"
|
||||
variant="fill"
|
||||
durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
|
||||
durationSeconds={durations[k] ?? (j as any)?.meta?.file?.durationSeconds}
|
||||
onDuration={handleDuration}
|
||||
showPopover={false}
|
||||
blur={inlineActive ? false : Boolean(blurPreviews)}
|
||||
@ -934,8 +738,34 @@ export default function FinishedDownloadsCardsView({
|
||||
? mobileTopActivationNonce
|
||||
: 0
|
||||
}
|
||||
registerTeaserPlay={registerTeaserPlayForKey(k)}
|
||||
holdSpeedActive={speedHoldKey === k}
|
||||
/>
|
||||
|
||||
{showMobileAudioButton ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-2 right-2 z-[35] inline-flex items-center gap-1.5 rounded-full bg-black/70 px-2.5 py-1.5 text-[11px] font-medium text-white backdrop-blur hover:bg-black/80"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
setAudioButtonDismissedForKey(k)
|
||||
setTeaserAudioEnabledForKey(k)
|
||||
onUnlockMobileTeaserAudio?.()
|
||||
|
||||
teaserPlayFnsRef.current[k]?.()
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
aria-label="Teaser mit Ton abspielen"
|
||||
title="Ton einschalten"
|
||||
>
|
||||
<SpeakerWaveIcon className="size-3.5 shrink-0" />
|
||||
<span>Ton aktivieren</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{/* ✅ Sprite einmal vorladen, damit der erste Scrub-Move sofort sichtbar ist */}
|
||||
{hasSpriteScrubber && spriteUrl ? (
|
||||
<img
|
||||
@ -1010,7 +840,7 @@ export default function FinishedDownloadsCardsView({
|
||||
|
||||
{/* Footer / Meta (wie Gallery strukturiert) */}
|
||||
<div
|
||||
className="relative min-h-[112px] overflow-hidden px-4 py-3 border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900"
|
||||
className="relative min-h-[112px] overflow-visible px-4 py-3 border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900"
|
||||
onPointerDownCapture={() => {
|
||||
footerTapRef.current[k] = true
|
||||
}}
|
||||
@ -1028,7 +858,7 @@ export default function FinishedDownloadsCardsView({
|
||||
</div>
|
||||
|
||||
{renderPostworkBadge && livePostwork ? (
|
||||
<div className="shrink-0">
|
||||
<div className="relative z-20 shrink-0">
|
||||
{renderPostworkBadge(livePostwork)}
|
||||
</div>
|
||||
) : null}
|
||||
@ -1105,6 +935,8 @@ export default function FinishedDownloadsCardsView({
|
||||
isHot,
|
||||
inlineDomId,
|
||||
cardInner,
|
||||
inlineActive,
|
||||
allowTeaserAnimation,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1127,6 +959,21 @@ export default function FinishedDownloadsCardsView({
|
||||
|
||||
const [mobileTopTapEnabled, setMobileTopTapEnabled] = React.useState(true)
|
||||
const mobileTopTapEnableTimerRef = React.useRef<number | null>(null)
|
||||
const [audioButtonDismissedForKey, setAudioButtonDismissedForKey] = React.useState<string | null>(null)
|
||||
const [teaserAudioEnabledForKey, setTeaserAudioEnabledForKey] = React.useState<string | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isSmall) return
|
||||
if (!mobileTopKey) return
|
||||
if (teaserAudioEnabledForKey !== mobileTopKey) return
|
||||
if (!mobileTeaserAudioUnlocked) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
teaserPlayFnsRef.current[mobileTopKey]?.()
|
||||
})
|
||||
})
|
||||
}, [isSmall, mobileTopKey, teaserAudioEnabledForKey, mobileTeaserAudioUnlocked])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (mobileTopTeaserEnableTimerRef.current != null) {
|
||||
@ -1139,6 +986,9 @@ export default function FinishedDownloadsCardsView({
|
||||
mobileTopTapEnableTimerRef.current = null
|
||||
}
|
||||
|
||||
setAudioButtonDismissedForKey(null)
|
||||
setTeaserAudioEnabledForKey(null)
|
||||
|
||||
if (!isSmall || !mobileTopKey) {
|
||||
setMobileTopTeaserEnabled(true)
|
||||
setMobileTopTapEnabled(true)
|
||||
@ -1301,7 +1151,7 @@ export default function FinishedDownloadsCardsView({
|
||||
{/* Oberste Karte im Flow -> bestimmt die echte Höhe */}
|
||||
{topRow ? (() => {
|
||||
const j = topRow
|
||||
const { k, busy, isHot, cardInner, inlineDomId } = renderCardItem(j, {
|
||||
const { k, busy, isHot, cardInner, inlineDomId, inlineActive, allowTeaserAnimation } = renderCardItem(j, {
|
||||
forceLoadStill: false,
|
||||
forceStill: !mobileTopTeaserEnabled,
|
||||
mobileStackTopOnlyVideo: true,
|
||||
@ -1327,6 +1177,8 @@ export default function FinishedDownloadsCardsView({
|
||||
ignoreFromBottomPx={110}
|
||||
doubleTapMs={360}
|
||||
doubleTapMaxMovePx={48}
|
||||
pressDelayMs={220}
|
||||
enablePressHold={!inlineActive && mobileTopTeaserEnabled && allowTeaserAnimation && !busy}
|
||||
onDoubleTap={async () => {
|
||||
if (isHot) return
|
||||
await onToggleHot?.(j)
|
||||
@ -1360,6 +1212,13 @@ export default function FinishedDownloadsCardsView({
|
||||
setMobileTopTeaserEnabled(false)
|
||||
return await keepVideo(j)
|
||||
}}
|
||||
onPressStart={(info) => {
|
||||
if (info.pointerType === 'mouse' && info.button !== 0) return
|
||||
setSpeedHoldKey(k)
|
||||
}}
|
||||
onPressEnd={() => {
|
||||
setSpeedHoldKey((prev) => (prev === k ? null : prev))
|
||||
}}
|
||||
>
|
||||
{cardInner}
|
||||
</SwipeCard>
|
||||
|
||||
@ -2,7 +2,11 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import type { RecordJob } from '../../types'
|
||||
import type {
|
||||
RecordJob,
|
||||
GalleryModelFlags,
|
||||
FinishedPostworkSummary,
|
||||
} from '../../types'
|
||||
import FinishedVideoPreview from './FinishedVideoPreview'
|
||||
import RecordJobActions from './RecordJobActions'
|
||||
import TagOverflowRow from './TagOverflowRow'
|
||||
@ -16,35 +20,13 @@ import {
|
||||
shouldObserveTeasers,
|
||||
} from './teaserPlayback'
|
||||
import Checkbox from './Checkbox'
|
||||
|
||||
type ModelFlags = {
|
||||
favorite?: boolean
|
||||
liked?: boolean | null
|
||||
watching?: boolean | null
|
||||
tags?: string
|
||||
|
||||
// ✅ Optional für Model-Hover-Preview
|
||||
image?: string
|
||||
imageUrl?: string | null
|
||||
|
||||
// ✅ Optional für stashapp-artigen Preview-Scrubber
|
||||
previewScrubberPath?: string
|
||||
previewScrubberCount?: number
|
||||
}
|
||||
|
||||
type FinishedPostworkBadge = {
|
||||
file: string
|
||||
assetId?: string
|
||||
queue: 'postwork' | 'enrich'
|
||||
state: 'queued' | 'running' | 'done' | 'error' | 'missing'
|
||||
phase?: string
|
||||
label?: string
|
||||
position?: number
|
||||
waiting?: number
|
||||
running?: number
|
||||
maxParallel?: number
|
||||
ts: number
|
||||
}
|
||||
import {
|
||||
buildSpriteFrameStyle,
|
||||
parseJobMeta,
|
||||
firstNonEmptyString,
|
||||
readPreviewSpriteInfo,
|
||||
scrubProgressRatioFromIndex,
|
||||
} from './previewSprite'
|
||||
|
||||
type Props = {
|
||||
rows: RecordJob[]
|
||||
@ -54,9 +36,13 @@ type Props = {
|
||||
isLoading?: boolean
|
||||
blurPreviews?: boolean
|
||||
durations: Record<string, number>
|
||||
postworkByFile: Record<string, FinishedPostworkBadge>
|
||||
postworkByFile: Record<string, FinishedPostworkSummary>
|
||||
getPostworkSummaryForOutput: (
|
||||
output: string | undefined,
|
||||
summaries: Record<string, FinishedPostworkSummary>
|
||||
) => FinishedPostworkSummary | undefined
|
||||
teaserState: FinishedDownloadsTeaserState
|
||||
renderPostworkBadge?: (badge?: FinishedPostworkBadge) => React.ReactNode
|
||||
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode
|
||||
|
||||
handleDuration: (job: RecordJob, seconds: number) => void
|
||||
|
||||
@ -83,7 +69,7 @@ type Props = {
|
||||
|
||||
lower: (s: string) => string
|
||||
|
||||
modelsByKey: Record<string, ModelFlags>
|
||||
modelsByKey: Record<string, GalleryModelFlags>
|
||||
activeTagSet: Set<string>
|
||||
onHoverPreviewKeyChange?: (key: string | null) => void
|
||||
onToggleTagFilter: (tag: string) => void
|
||||
@ -97,77 +83,19 @@ type Props = {
|
||||
forcePreviewMuted?: boolean
|
||||
}
|
||||
|
||||
function firstNonEmptyString(...values: unknown[]): string | undefined {
|
||||
for (const v of values) {
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim()
|
||||
if (s) return s
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function parseJobMeta(metaRaw: unknown): any | null {
|
||||
if (!metaRaw) return null
|
||||
if (typeof metaRaw === 'string') {
|
||||
try {
|
||||
return JSON.parse(metaRaw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (typeof metaRaw === 'object') return metaRaw
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeDurationSeconds(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
||||
// ms -> s Heuristik wie in FinishedVideoPreview
|
||||
return value > 24 * 60 * 60 ? value / 1000 : value
|
||||
}
|
||||
|
||||
function clamp(n: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, n))
|
||||
}
|
||||
|
||||
const DEFAULT_SPRITE_STEP_SECONDS = 5
|
||||
|
||||
function chooseSpriteGrid(count: number): [number, number] {
|
||||
if (count <= 1) return [1, 1]
|
||||
|
||||
const targetRatio = 16 / 9
|
||||
let bestCols = 1
|
||||
let bestRows = count
|
||||
let bestWaste = Number.POSITIVE_INFINITY
|
||||
let bestRatioScore = Number.POSITIVE_INFINITY
|
||||
|
||||
for (let c = 1; c <= count; c++) {
|
||||
const r = Math.max(1, Math.ceil(count / c))
|
||||
const waste = c * r - count
|
||||
const ratio = c / r
|
||||
const ratioScore = Math.abs(ratio - targetRatio)
|
||||
|
||||
if (
|
||||
waste < bestWaste ||
|
||||
(waste === bestWaste && ratioScore < bestRatioScore) ||
|
||||
(waste === bestWaste && ratioScore === bestRatioScore && r < bestRows)
|
||||
) {
|
||||
bestWaste = waste
|
||||
bestRatioScore = ratioScore
|
||||
bestCols = c
|
||||
bestRows = r
|
||||
}
|
||||
}
|
||||
|
||||
return [bestCols, bestRows]
|
||||
}
|
||||
|
||||
export default function FinishedDownloadsGalleryView({
|
||||
rows,
|
||||
isLoading,
|
||||
blurPreviews,
|
||||
durations,
|
||||
postworkByFile,
|
||||
getPostworkSummaryForOutput,
|
||||
teaserState,
|
||||
renderPostworkBadge,
|
||||
selectedKeys,
|
||||
@ -242,22 +170,27 @@ export default function FinishedDownloadsGalleryView({
|
||||
|
||||
// ✅ Auflösung als {w,h} aus meta.json bevorzugen
|
||||
const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => {
|
||||
const w =
|
||||
(typeof (j as any)?.meta?.videoWidth === 'number' && Number.isFinite((j as any).meta.videoWidth)
|
||||
? (j as any).meta.videoWidth
|
||||
: 0) ||
|
||||
(typeof (j as any)?.videoWidth === 'number' && Number.isFinite((j as any).videoWidth)
|
||||
? (j as any).videoWidth
|
||||
: 0)
|
||||
const h =
|
||||
(typeof (j as any)?.meta?.videoHeight === 'number' && Number.isFinite((j as any).meta.videoHeight)
|
||||
? (j as any).meta.videoHeight
|
||||
: 0) ||
|
||||
(typeof (j as any)?.videoHeight === 'number' && Number.isFinite((j as any).videoHeight)
|
||||
? (j as any).videoHeight
|
||||
: 0)
|
||||
const meta = parseJobMeta((j as any)?.meta)
|
||||
|
||||
const w =
|
||||
meta?.media?.video?.width ??
|
||||
meta?.file?.video?.width
|
||||
|
||||
const h =
|
||||
meta?.media?.video?.height ??
|
||||
meta?.file?.video?.height
|
||||
|
||||
if (
|
||||
typeof w === 'number' &&
|
||||
Number.isFinite(w) &&
|
||||
w > 0 &&
|
||||
typeof h === 'number' &&
|
||||
Number.isFinite(h) &&
|
||||
h > 0
|
||||
) {
|
||||
return { w, h }
|
||||
}
|
||||
|
||||
if (w > 0 && h > 0) return { w, h }
|
||||
return null
|
||||
}, [])
|
||||
|
||||
@ -317,15 +250,8 @@ export default function FinishedDownloadsGalleryView({
|
||||
const isHot = isHotName(fileRaw)
|
||||
const file = stripHotPrefix(fileRaw)
|
||||
|
||||
const postworkBadge =
|
||||
postworkByFile[fileRaw] ??
|
||||
postworkByFile[stripHotPrefix(fileRaw)]
|
||||
const showPostworkBadge =
|
||||
postworkBadge &&
|
||||
(postworkBadge.state === 'queued' ||
|
||||
postworkBadge.state === 'running' ||
|
||||
postworkBadge.state === 'done' ||
|
||||
postworkBadge.state === 'error')
|
||||
const postworkBadge = getPostworkSummaryForOutput(j.output, postworkByFile)
|
||||
const showPostworkBadge = !!postworkBadge
|
||||
|
||||
const dur = runtimeOf(j)
|
||||
const size = formatBytes(sizeBytesOf(j))
|
||||
@ -346,10 +272,6 @@ export default function FinishedDownloadsGalleryView({
|
||||
(j as any)?.meta?.modelImageUrl
|
||||
)
|
||||
|
||||
// Preview-ID wie in FinishedVideoPreview ableiten (HOT entfernen + ext entfernen)
|
||||
const fileForPreviewId = stripHotPrefix(baseName(j.output || ''))
|
||||
const previewId = fileForPreviewId.replace(/\.[^.]+$/, '').trim()
|
||||
|
||||
// meta robust lesen (Objekt oder JSON-String)
|
||||
const meta = parseJobMeta((j as any)?.meta)
|
||||
|
||||
@ -357,119 +279,30 @@ export default function FinishedDownloadsGalleryView({
|
||||
// ✅ STASHAPP-LIKE: Sprite-Preview (1 Bild + CSS background-position)
|
||||
// Erwartet z.B. meta.previewSprite = { path, count, cols, rows, stepSeconds }
|
||||
// ------------------------------------------------------------
|
||||
const spritePathRaw = firstNonEmptyString(
|
||||
meta?.previewSprite?.path,
|
||||
(meta as any)?.previewSpritePath,
|
||||
flags?.previewScrubberPath,
|
||||
previewId ? `/api/preview-sprite/${encodeURIComponent(previewId)}` : undefined
|
||||
)
|
||||
const spritePath = spritePathRaw ? spritePathRaw.replace(/\/+$/, '') : undefined
|
||||
const sprite = readPreviewSpriteInfo((j as any)?.meta, {
|
||||
fallbackPath: flags?.previewScrubberPath,
|
||||
fallbackCount: flags?.previewScrubberCount,
|
||||
})
|
||||
|
||||
const spriteStepSecondsRaw =
|
||||
meta?.previewSprite?.stepSeconds ?? (meta as any)?.previewSpriteStepSeconds
|
||||
|
||||
const spriteStepSeconds =
|
||||
typeof spriteStepSecondsRaw === 'number' &&
|
||||
Number.isFinite(spriteStepSecondsRaw) &&
|
||||
spriteStepSecondsRaw > 0
|
||||
? spriteStepSecondsRaw
|
||||
: DEFAULT_SPRITE_STEP_SECONDS
|
||||
|
||||
// ✅ Fallback-Dauer (meta -> job -> durations cache)
|
||||
const durationForSprite =
|
||||
normalizeDurationSeconds(meta?.durationSeconds) ??
|
||||
normalizeDurationSeconds((j as any)?.durationSeconds) ??
|
||||
normalizeDurationSeconds(durations[k])
|
||||
|
||||
// ✅ Count aus Meta/Flags ODER aus Dauer ableiten
|
||||
const inferredSpriteCountFromDuration =
|
||||
typeof durationForSprite === 'number' && durationForSprite > 0
|
||||
? Math.max(1, Math.min(200, Math.floor(durationForSprite / spriteStepSeconds) + 1))
|
||||
: undefined
|
||||
|
||||
const spriteCountRaw =
|
||||
meta?.previewSprite?.count ??
|
||||
(meta as any)?.previewSpriteCount ??
|
||||
flags?.previewScrubberCount ??
|
||||
inferredSpriteCountFromDuration
|
||||
|
||||
const spriteColsRaw = meta?.previewSprite?.cols ?? (meta as any)?.previewSpriteCols
|
||||
const spriteRowsRaw = meta?.previewSprite?.rows ?? (meta as any)?.previewSpriteRows
|
||||
|
||||
const spriteCount =
|
||||
typeof spriteCountRaw === 'number' && Number.isFinite(spriteCountRaw)
|
||||
? Math.max(0, Math.floor(spriteCountRaw))
|
||||
: 0
|
||||
|
||||
// ✅ Wenn cols/rows fehlen, aus Count ableiten (wie Backend)
|
||||
const [inferredCols, inferredRows] =
|
||||
spriteCount > 1 ? chooseSpriteGrid(spriteCount) : [0, 0]
|
||||
|
||||
const spriteCols =
|
||||
typeof spriteColsRaw === 'number' && Number.isFinite(spriteColsRaw)
|
||||
? Math.max(0, Math.floor(spriteColsRaw))
|
||||
: inferredCols
|
||||
|
||||
const spriteRows =
|
||||
typeof spriteRowsRaw === 'number' && Number.isFinite(spriteRowsRaw)
|
||||
? Math.max(0, Math.floor(spriteRowsRaw))
|
||||
: inferredRows
|
||||
|
||||
// Optionaler Cache-Buster (wenn du sowas in meta hast)
|
||||
const spriteVersion =
|
||||
(typeof meta?.updatedAtUnix === 'number' && Number.isFinite(meta.updatedAtUnix)
|
||||
? meta.updatedAtUnix
|
||||
: undefined) ??
|
||||
(typeof (meta as any)?.fileModUnix === 'number' && Number.isFinite((meta as any).fileModUnix)
|
||||
? (meta as any).fileModUnix
|
||||
: undefined) ??
|
||||
0
|
||||
|
||||
const spriteUrl =
|
||||
spritePath && spriteVersion
|
||||
? `${spritePath}?v=${encodeURIComponent(String(spriteVersion))}`
|
||||
: spritePath || undefined
|
||||
|
||||
const hasScrubberUi =
|
||||
Boolean(spriteUrl) &&
|
||||
spriteCount > 1
|
||||
|
||||
const hasSpriteScrubber =
|
||||
hasScrubberUi &&
|
||||
spriteCols > 0 &&
|
||||
spriteRows > 0
|
||||
|
||||
// Finales Scrubber-Setup
|
||||
const scrubberCount = hasScrubberUi ? spriteCount : 0
|
||||
const scrubberStepSeconds = hasScrubberUi ? spriteStepSeconds : 0
|
||||
const spriteUrl = sprite.url
|
||||
const hasScrubberUi = sprite.hasScrubberUi
|
||||
const hasSpriteScrubber = sprite.hasSpriteScrubber
|
||||
const scrubberCount = sprite.count
|
||||
const scrubberStepSeconds = sprite.stepSeconds
|
||||
const hasScrubber = hasScrubberUi
|
||||
|
||||
const activeScrubIndex = scrubIndexByKey[k]
|
||||
|
||||
const scrubProgressRatio =
|
||||
typeof activeScrubIndex === 'number' && scrubberCount > 1
|
||||
? clamp(activeScrubIndex / (scrubberCount - 1), 0, 1)
|
||||
: undefined
|
||||
const scrubProgressRatio = scrubProgressRatioFromIndex(activeScrubIndex, scrubberCount)
|
||||
|
||||
// Sprite-Overlay-Frame (kein Request pro Move)
|
||||
const spriteFrameStyle: React.CSSProperties | undefined =
|
||||
hasSpriteScrubber && typeof activeScrubIndex === 'number'
|
||||
? (() => {
|
||||
const idx = clamp(activeScrubIndex, 0, Math.max(0, spriteCount - 1))
|
||||
const col = idx % spriteCols
|
||||
const row = Math.floor(idx / spriteCols)
|
||||
|
||||
const posX = spriteCols <= 1 ? 0 : (col / (spriteCols - 1)) * 100
|
||||
const posY = spriteRows <= 1 ? 0 : (row / (spriteRows - 1)) * 100
|
||||
|
||||
return {
|
||||
backgroundImage: `url("${spriteUrl}")`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: `${spriteCols * 100}% ${spriteRows * 100}%`,
|
||||
backgroundPosition: `${posX}% ${posY}%`,
|
||||
}
|
||||
})()
|
||||
: undefined
|
||||
const spriteFrameStyle = buildSpriteFrameStyle({
|
||||
spriteUrl,
|
||||
spriteCols: sprite.cols,
|
||||
spriteRows: sprite.rows,
|
||||
spriteCount: sprite.count,
|
||||
activeIndex: activeScrubIndex,
|
||||
})
|
||||
|
||||
const showModelPreviewInThumb = hoveredModelPreviewKey === k && Boolean(modelImageSrc)
|
||||
const showScrubberSpriteInThumb = !showModelPreviewInThumb && Boolean(spriteFrameStyle)
|
||||
@ -477,6 +310,11 @@ export default function FinishedDownloadsGalleryView({
|
||||
const hideTeaserUnderOverlay =
|
||||
showModelPreviewInThumb || showScrubberSpriteInThumb
|
||||
|
||||
const previewDurationSeconds =
|
||||
durations[k] ??
|
||||
normalizeDurationSeconds(meta?.media?.durationSeconds) ??
|
||||
normalizeDurationSeconds(meta?.file?.durationSeconds)
|
||||
|
||||
return (
|
||||
<div key={k} className="relative">
|
||||
<div
|
||||
@ -509,24 +347,7 @@ export default function FinishedDownloadsGalleryView({
|
||||
>
|
||||
<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>
|
||||
<div
|
||||
className={[
|
||||
'relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
|
||||
'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]',
|
||||
@ -543,231 +364,231 @@ export default function FinishedDownloadsGalleryView({
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{/* 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={durations[k] ?? (j as any)?.durationSeconds}
|
||||
onDuration={handleDuration}
|
||||
variant="fill"
|
||||
showPopover={false}
|
||||
blur={blurPreviews}
|
||||
animated={shouldAnimateTeaser({
|
||||
state: teaserState,
|
||||
itemKey: k,
|
||||
disabled: hideTeaserUnderOverlay,
|
||||
})}
|
||||
animatedMode="teaser"
|
||||
animatedTrigger="always"
|
||||
muted={previewMuted}
|
||||
popoverMuted={previewMuted}
|
||||
scrubProgressRatio={scrubProgressRatio}
|
||||
preferScrubProgress={typeof activeScrubIndex === 'number'}
|
||||
forceActive={isPreviewActive}
|
||||
teaserPreloadEnabled
|
||||
/>
|
||||
</div>
|
||||
<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}
|
||||
|
||||
{/* ✅ 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}
|
||||
{/* 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>
|
||||
) : null}
|
||||
|
||||
{/* ✅ Modelbild-Preview Overlay (hover auf Modelname) */}
|
||||
{showModelPreviewInThumb && modelImageSrc ? (
|
||||
<div className="absolute inset-0 z-[6]">
|
||||
{/* ✅ Sprite vorladen (einmal), damit erster Scrub-Move sofort sichtbar ist */}
|
||||
{hasSpriteScrubber && spriteUrl ? (
|
||||
<img
|
||||
src={modelImageSrc}
|
||||
alt={model ? `${model} preview` : 'Model preview'}
|
||||
className="h-full w-full object-cover"
|
||||
draggable={false}
|
||||
src={spriteUrl}
|
||||
alt=""
|
||||
className="hidden"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<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
|
||||
) : null}
|
||||
|
||||
{/* ✅ 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>
|
||||
</div>
|
||||
) : null}
|
||||
) : 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}
|
||||
{/* ✅ 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>
|
||||
{/* Meta-Overlay im Video: unten rechts */}
|
||||
<div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
|
||||
<div
|
||||
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
|
||||
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size]
|
||||
.filter(Boolean)
|
||||
.join(' • ')}
|
||||
>
|
||||
<span>{dur}</span>
|
||||
{resLabel ? <span aria-hidden="true">•</span> : null}
|
||||
{resLabel ? <span>{resLabel}</span> : null}
|
||||
<span aria-hidden="true">•</span>
|
||||
<span>{size}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer / Meta */}
|
||||
<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">
|
||||
<span
|
||||
className={[
|
||||
'block min-w-0 flex-1 truncate text-sm font-semibold text-gray-900 dark:text-white',
|
||||
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>
|
||||
{/* 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>
|
||||
|
||||
{showPostworkBadge && renderPostworkBadge ? (
|
||||
<div className="shrink-0">
|
||||
{renderPostworkBadge(postworkBadge)}
|
||||
{showPostworkBadge && renderPostworkBadge ? (
|
||||
<div className="relative z-20 shrink-0">
|
||||
{renderPostworkBadge(postworkBadge)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<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}
|
||||
<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}
|
||||
|
||||
<span
|
||||
className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400"
|
||||
title={stripHotPrefix(file) || '—'}
|
||||
<span
|
||||
className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400"
|
||||
title={stripHotPrefix(file) || '—'}
|
||||
>
|
||||
{stripHotPrefix(file) || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions (wie CardView: im Footer statt im Video) */}
|
||||
<div
|
||||
className="mt-2 shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{stripHotPrefix(file) || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||||
<RecordJobActions
|
||||
job={jobForDetails(j)}
|
||||
variant="table"
|
||||
busy={busy}
|
||||
collapseToMenu
|
||||
compact={true}
|
||||
isHot={isHot}
|
||||
isFavorite={isFav}
|
||||
isLiked={isLiked}
|
||||
isWatching={isWatching}
|
||||
onToggleWatch={onToggleWatch}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleLike={onToggleLike}
|
||||
onToggleHot={onToggleHot}
|
||||
onKeep={keepVideo}
|
||||
onDelete={deleteVideo}
|
||||
onSplit={onSplit}
|
||||
onAddToDownloads={onAddToDownloads}
|
||||
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
|
||||
className="w-full gap-1.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions (wie CardView: im Footer statt im Video) */}
|
||||
<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"
|
||||
{/* 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>
|
||||
)
|
||||
})}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isLoading && rows.length === 0 ? (
|
||||
|
||||
@ -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'
|
||||
@ -16,30 +21,7 @@ import {
|
||||
shouldEnableTeaserAudio,
|
||||
} from './teaserPlayback'
|
||||
import Checkbox from './Checkbox'
|
||||
|
||||
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 { parseJobMeta } from './previewSprite'
|
||||
|
||||
type Props = {
|
||||
rows: RecordJob[]
|
||||
@ -58,9 +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?: FinishedPostworkBadge) => React.ReactNode
|
||||
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode
|
||||
|
||||
// teaser/preview
|
||||
canHover: boolean
|
||||
@ -77,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
|
||||
|
||||
@ -116,6 +102,7 @@ export default function FinishedDownloadsTableView({
|
||||
resolutions,
|
||||
durations,
|
||||
postworkByFile,
|
||||
getPostworkSummaryForOutput,
|
||||
jobForDetails,
|
||||
renderPostworkBadge,
|
||||
|
||||
@ -156,7 +143,7 @@ export default function FinishedDownloadsTableView({
|
||||
|
||||
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)
|
||||
@ -191,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(
|
||||
@ -329,7 +307,7 @@ export default function FinishedDownloadsTableView({
|
||||
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}
|
||||
@ -382,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">
|
||||
@ -587,7 +559,7 @@ export default function FinishedDownloadsTableView({
|
||||
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 ?? '')
|
||||
|
||||
@ -83,6 +83,7 @@ export type FinishedVideoPreviewProps = {
|
||||
forceActive?: boolean
|
||||
activationNonce?: number
|
||||
registerTeaserPlay?: (fn: (() => void) | null) => void
|
||||
holdSpeedActive?: boolean
|
||||
}
|
||||
|
||||
function baseName(path: string) {
|
||||
@ -131,6 +132,7 @@ export default function FinishedVideoPreview({
|
||||
forceActive= false,
|
||||
activationNonce = 0,
|
||||
registerTeaserPlay,
|
||||
holdSpeedActive = false,
|
||||
}: FinishedVideoPreviewProps) {
|
||||
const file = baseName(job.output || '') || getFileName(job.output || '')
|
||||
const blurCls = blur ? 'blur-md' : ''
|
||||
@ -390,32 +392,65 @@ 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 playTeaserFromGesture = useCallback(() => {
|
||||
const setTeaserPlaybackRate = useCallback((rate: number) => {
|
||||
const el = teaserMp4Ref.current
|
||||
if (!el) return
|
||||
|
||||
applyInlineVideoPolicy(el, { muted })
|
||||
try {
|
||||
el.playbackRate = rate
|
||||
el.defaultPlaybackRate = rate
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
const playTeaserFromGesture = useCallback((withSound = true) => {
|
||||
const el = teaserMp4Ref.current
|
||||
if (!el) return false
|
||||
|
||||
const nextMuted = !withSound
|
||||
|
||||
applyInlineVideoPolicy(el, { muted: nextMuted })
|
||||
|
||||
try {
|
||||
el.muted = muted
|
||||
el.defaultMuted = muted
|
||||
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(() => {})
|
||||
}
|
||||
}, [muted])
|
||||
|
||||
return true
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!registerTeaserPlay) return
|
||||
registerTeaserPlay(playTeaserFromGesture)
|
||||
return () => registerTeaserPlay(null)
|
||||
}, [registerTeaserPlay, playTeaserFromGesture])
|
||||
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)
|
||||
@ -557,6 +592,7 @@ export default function FinishedVideoPreview({
|
||||
// ✅ neue Datei/Asset -> Callback-Dedupe zurücksetzen
|
||||
emittedDurationRef.current = ''
|
||||
emittedResolutionRef.current = ''
|
||||
teaserSoundForcedRef.current = false
|
||||
setMetaLoaded(false)
|
||||
}, [previewId, assetNonce, noGenerateTeaser])
|
||||
|
||||
@ -698,6 +734,13 @@ export default function FinishedVideoPreview({
|
||||
(animatedMode === 'clips' && hasDuration)
|
||||
)
|
||||
|
||||
const canHoldSpeedTeaser =
|
||||
teaserActive &&
|
||||
animatedMode === 'teaser' &&
|
||||
!showingInlineVideo
|
||||
|
||||
const teaserSpeedHold = holdSpeedActive && canHoldSpeedTeaser
|
||||
|
||||
const progressTotalSeconds =
|
||||
hasDuration && typeof effectiveDurationSec === 'number' ? effectiveDurationSec : undefined
|
||||
|
||||
@ -967,6 +1010,10 @@ export default function FinishedVideoPreview({
|
||||
|
||||
const active = teaserActive && animatedMode === 'teaser'
|
||||
|
||||
if (!active) {
|
||||
setTeaserPlaybackRate(1)
|
||||
}
|
||||
|
||||
const clearRetryTimer = () => {
|
||||
if (teaserPlayRetryTimerRef.current != null) {
|
||||
window.clearTimeout(teaserPlayRetryTimerRef.current)
|
||||
@ -992,14 +1039,18 @@ export default function FinishedVideoPreview({
|
||||
return
|
||||
}
|
||||
|
||||
applyInlineVideoPolicy(el, { muted })
|
||||
const effectiveMuted = teaserSoundForcedRef.current ? false : muted
|
||||
|
||||
applyInlineVideoPolicy(el, { muted: effectiveMuted })
|
||||
|
||||
try {
|
||||
el.muted = muted
|
||||
el.defaultMuted = muted
|
||||
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) {
|
||||
@ -1008,7 +1059,6 @@ export default function FinishedVideoPreview({
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
const p = el.play?.()
|
||||
if (p && typeof (p as any).catch === 'function') {
|
||||
;(p as Promise<void>).catch(() => {})
|
||||
@ -1135,11 +1185,13 @@ export default function FinishedVideoPreview({
|
||||
return
|
||||
}
|
||||
|
||||
applyInlineVideoPolicy(el, { muted })
|
||||
const effectiveMuted = teaserSoundForcedRef.current ? false : muted
|
||||
|
||||
applyInlineVideoPolicy(el, { muted: effectiveMuted })
|
||||
|
||||
try {
|
||||
el.muted = muted
|
||||
el.defaultMuted = muted
|
||||
el.muted = effectiveMuted
|
||||
el.defaultMuted = effectiveMuted
|
||||
el.playsInline = true
|
||||
el.loop = true
|
||||
el.autoplay = true
|
||||
@ -1301,9 +1353,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}
|
||||
@ -1318,7 +1383,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)}
|
||||
/>
|
||||
) : (
|
||||
@ -1387,7 +1461,7 @@ export default function FinishedVideoPreview({
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
muted={muted}
|
||||
muted={teaserSoundForcedRef.current ? false : muted}
|
||||
playsInline
|
||||
autoPlay
|
||||
loop
|
||||
@ -1418,6 +1492,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
|
||||
|
||||
@ -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}
|
||||
@ -2060,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)
|
||||
@ -2212,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>
|
||||
|
||||
@ -2401,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)
|
||||
@ -2574,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}
|
||||
|
||||
@ -58,9 +58,14 @@ 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>
|
||||
|
||||
@ -1788,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={() => {
|
||||
|
||||
@ -75,6 +75,7 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState<number>(0)
|
||||
const saveSuccessTimerRef = useRef<number | null>(null)
|
||||
const cleanupDismissUntilRef = useRef(0)
|
||||
const [cleaning, setCleaning] = useState(false)
|
||||
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
|
||||
const [msg, setMsg] = useState<string | null>(null)
|
||||
@ -138,7 +139,7 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
|
||||
status: 'idle',
|
||||
title: 'Aufräumen',
|
||||
text: '',
|
||||
cancellable: false,
|
||||
cancellable: true,
|
||||
fading: false,
|
||||
})
|
||||
|
||||
@ -147,7 +148,7 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
|
||||
status: 'idle',
|
||||
title: 'Assets neu generieren',
|
||||
text: '',
|
||||
cancellable: false,
|
||||
cancellable: true,
|
||||
fading: false,
|
||||
})
|
||||
|
||||
@ -234,6 +235,7 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
|
||||
title: 'Assets neu generieren',
|
||||
text: String(regen.text || regen.file || ''),
|
||||
err: undefined,
|
||||
cancellable: true,
|
||||
fading: false,
|
||||
}))
|
||||
} else if (regen?.error) {
|
||||
@ -243,6 +245,7 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
|
||||
title: 'Assets neu generieren',
|
||||
text: String(regen.file || ''),
|
||||
err: String(regen.error),
|
||||
cancellable: false,
|
||||
fading: false,
|
||||
}))
|
||||
} else if (regen?.text || regen?.file) {
|
||||
@ -252,6 +255,7 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
|
||||
title: 'Assets neu generieren',
|
||||
text: String(regen.text || regen.file || ''),
|
||||
err: undefined,
|
||||
cancellable: false,
|
||||
fading: false,
|
||||
}))
|
||||
} else {
|
||||
@ -261,6 +265,64 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
|
||||
title: 'Assets neu generieren',
|
||||
text: '',
|
||||
err: undefined,
|
||||
cancellable: true,
|
||||
fading: false,
|
||||
}))
|
||||
}
|
||||
|
||||
const cleanup = data.cleanup
|
||||
const cleanupSuppressed = Date.now() < cleanupDismissUntilRef.current
|
||||
|
||||
if (cleanup?.running) {
|
||||
setCleaning(true)
|
||||
setCleanupTask((t) => ({
|
||||
...t,
|
||||
status: 'running',
|
||||
title: 'Aufräumen',
|
||||
text: String(cleanup.currentFile || cleanup.text || 'Räume auf…'),
|
||||
done: Number(cleanup.done ?? 0),
|
||||
total: Number(cleanup.total ?? 0),
|
||||
err: undefined,
|
||||
cancellable: false,
|
||||
fading: false,
|
||||
}))
|
||||
} else if (!cleanupSuppressed && cleanup?.error) {
|
||||
setCleaning(false)
|
||||
setCleanupTask((t) => ({
|
||||
...t,
|
||||
status: 'error',
|
||||
title: 'Aufräumen',
|
||||
text: String(cleanup.text || 'Fehler beim Aufräumen.'),
|
||||
done: Number(cleanup.done ?? 0),
|
||||
total: Number(cleanup.total ?? 0),
|
||||
err: String(cleanup.error),
|
||||
cancellable: false,
|
||||
fading: false,
|
||||
}))
|
||||
} else if (!cleanupSuppressed && (cleanup?.finishedAt || cleanup?.text)) {
|
||||
setCleaning(false)
|
||||
setCleanupTask((t) => ({
|
||||
...t,
|
||||
status: 'done',
|
||||
title: 'Aufräumen',
|
||||
text: String(cleanup.text || 'Aufräumen abgeschlossen.'),
|
||||
done: Number(cleanup.done ?? 0),
|
||||
total: Number(cleanup.total ?? 0),
|
||||
err: undefined,
|
||||
cancellable: false,
|
||||
fading: false,
|
||||
}))
|
||||
} else {
|
||||
setCleaning(false)
|
||||
setCleanupTask((t) => ({
|
||||
...t,
|
||||
status: 'idle',
|
||||
title: 'Aufräumen',
|
||||
text: '',
|
||||
done: 0,
|
||||
total: 0,
|
||||
err: undefined,
|
||||
cancellable: true,
|
||||
fading: false,
|
||||
}))
|
||||
}
|
||||
@ -523,7 +585,7 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
|
||||
text: 'Räume auf…',
|
||||
err: undefined,
|
||||
done: 0,
|
||||
total: 1,
|
||||
total: 0,
|
||||
fading: false,
|
||||
}))
|
||||
|
||||
@ -550,12 +612,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}`,
|
||||
}))
|
||||
|
||||
cleanupDismissUntilRef.current = Date.now() + 4500
|
||||
fadeOutTask(setCleanupTask) // ✅ nach und nach ausfaden
|
||||
} catch (e: any) {
|
||||
const msg = e?.message ?? String(e)
|
||||
@ -594,6 +657,30 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelRegenerateAssetsTask() {
|
||||
// UI sofort aktualisieren
|
||||
setRegenerateAssetsTask((t) => ({
|
||||
...t,
|
||||
status: 'cancelled',
|
||||
title: 'Assets neu generieren',
|
||||
text: 'Abgebrochen.',
|
||||
err: undefined,
|
||||
fading: false,
|
||||
}))
|
||||
|
||||
try {
|
||||
// Vorschlag: gleicher Endpoint wie Start, aber DELETE zum Abbrechen
|
||||
await fetch('/api/record/regenerate-assets', {
|
||||
method: 'DELETE',
|
||||
cache: 'no-store' as any,
|
||||
})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
fadeOutTask(setRegenerateAssetsTask)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
header={
|
||||
@ -626,9 +713,17 @@ export default function RecorderSettings({ onAssetsGenerated, onTaskRunningChang
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<TaskList
|
||||
tasks={[assetsTask, regenerateAssetsTask, cleanupTask]} // ✅ cleanupTask ist “am Ende”
|
||||
tasks={[assetsTask, regenerateAssetsTask, cleanupTask]}
|
||||
onCancel={(id: string) => {
|
||||
if (id === 'generate-assets') cancelAssetsTask()
|
||||
if (id === 'generate-assets') {
|
||||
void cancelAssetsTask()
|
||||
return
|
||||
}
|
||||
|
||||
if (id === 'regenerate-assets') {
|
||||
void cancelRegenerateAssetsTask()
|
||||
return
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@ -6,13 +6,6 @@ import * as React from 'react'
|
||||
import { FireIcon as FireSolidIcon } from '@heroicons/react/24/solid'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
const DEBUG_SWIPE = true
|
||||
|
||||
function swipeDbg(...args: any[]) {
|
||||
if (!DEBUG_SWIPE) return
|
||||
console.log('[SwipeCard]', ...args)
|
||||
}
|
||||
|
||||
function cn(...parts: Array<string | false | null | undefined>) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
@ -63,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 = {
|
||||
@ -116,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
|
||||
) {
|
||||
@ -123,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)
|
||||
@ -167,6 +170,22 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clearPressTimer = React.useCallback(() => {
|
||||
if (pressTimerRef.current != null) {
|
||||
window.clearTimeout(pressTimerRef.current)
|
||||
pressTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const endLongPress = React.useCallback(() => {
|
||||
clearPressTimer()
|
||||
|
||||
if (!longPressActiveRef.current) return
|
||||
|
||||
longPressActiveRef.current = false
|
||||
onPressEnd?.()
|
||||
}, [clearPressTimer, onPressEnd])
|
||||
|
||||
const flushDx = React.useCallback((nextDx: number) => {
|
||||
dxRef.current = nextDx
|
||||
if (rafRef.current != null) return
|
||||
@ -177,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
|
||||
@ -196,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) => {
|
||||
@ -205,8 +227,6 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
rafRef.current = null
|
||||
}
|
||||
|
||||
swipeDbg('commit start', { dir, runAction })
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.style.touchAction = 'pan-y'
|
||||
}
|
||||
@ -226,8 +246,6 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
// Wichtig: erst rausanimieren lassen, dann Aktion starten
|
||||
await animPromise
|
||||
|
||||
swipeDbg('commit after anim', { dir })
|
||||
|
||||
let ok: boolean | void = true
|
||||
if (runAction) {
|
||||
ok = await Promise.resolve(
|
||||
@ -235,8 +253,6 @@ swipeDbg('commit after anim', { dir })
|
||||
).catch(() => false)
|
||||
}
|
||||
|
||||
swipeDbg('commit action result', { dir, ok })
|
||||
|
||||
if (ok === false) {
|
||||
setAnimMs(snapMs)
|
||||
setArmedDir(null)
|
||||
@ -385,6 +401,13 @@ swipeDbg('commit action result', { dir, ok })
|
||||
[hotTargetSelector]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
clearTapTimer()
|
||||
clearPressTimer()
|
||||
}
|
||||
}, [clearTapTimer, clearPressTimer])
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@ -427,12 +450,6 @@ swipeDbg('commit action result', { dir, ok })
|
||||
onPointerDown={(e) => {
|
||||
if (!enabled || disabled) return
|
||||
|
||||
swipeDbg('pointerdown', {
|
||||
pointerId: e.pointerId,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
})
|
||||
|
||||
const target = e.target as HTMLElement | null
|
||||
|
||||
let tapIgnored = Boolean(tapIgnoreSelector && target?.closest?.(tapIgnoreSelector))
|
||||
@ -481,6 +498,30 @@ swipeDbg('commit action result', { dir, ok })
|
||||
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)
|
||||
@ -505,6 +546,7 @@ swipeDbg('commit action result', { dir, ok })
|
||||
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
|
||||
@ -515,6 +557,7 @@ swipeDbg('commit action result', { dir, ok })
|
||||
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
|
||||
@ -530,6 +573,7 @@ swipeDbg('commit action result', { dir, ok })
|
||||
if (pointer.current.axisLocked !== 'x') return
|
||||
|
||||
if (!pointer.current.dragging) {
|
||||
clearPressTimer()
|
||||
pointer.current.dragging = true
|
||||
;(e.currentTarget as HTMLElement).style.touchAction = 'none'
|
||||
setAnimMs(0)
|
||||
@ -577,6 +621,9 @@ swipeDbg('commit action result', { dir, ok })
|
||||
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
|
||||
@ -592,6 +639,12 @@ swipeDbg('commit action result', { dir, ok })
|
||||
|
||||
;(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
|
||||
@ -665,9 +718,6 @@ swipeDbg('commit action result', { dir, ok })
|
||||
|
||||
dxRef.current = 0
|
||||
|
||||
swipeDbg('commit-left')
|
||||
swipeDbg('commit-right')
|
||||
|
||||
if (finalDx > threshold || (fastRight && abs >= threshold * 0.5)) {
|
||||
void commit('right', true)
|
||||
return
|
||||
@ -682,6 +732,8 @@ swipeDbg('commit action result', { dir, ok })
|
||||
}}
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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