updated everything

This commit is contained in:
Linrador 2026-04-27 14:40:16 +02:00
parent 779eeddf52
commit 14412eab4c
78 changed files with 8121 additions and 2479 deletions

View File

@ -18,6 +18,7 @@ import (
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"syscall"
"time" "time"
) )
@ -268,6 +269,10 @@ func extractVideoFrameAt(ctx context.Context, outPath string, atSec float64) (im
"-y", "-y",
tmpPath, tmpPath,
) )
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
if out, err := cmd.CombinedOutput(); err != nil { if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("ffmpeg fehlgeschlagen: %v: %s", err, strings.TrimSpace(string(out))) return nil, fmt.Errorf("ffmpeg fehlgeschlagen: %v: %s", err, strings.TrimSpace(string(out)))

BIN
backend/assets/img/tray.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -60,13 +60,23 @@ func isFFmpegInputInvalidError(err error) bool {
// Asset "ID" = Dateiname ohne Endung (immer OHNE "HOT " Prefix) // Asset "ID" = Dateiname ohne Endung (immer OHNE "HOT " Prefix)
func assetIDFromVideoPath(videoPath string) string { func assetIDFromVideoPath(videoPath string) string {
base := filepath.Base(strings.TrimSpace(videoPath)) return canonicalAssetIDFromName(videoPath)
if base == "" { }
return ""
func postWorkSortForVideoPath(videoPath string) (bucket int, sortName string) {
clean := strings.TrimSpace(videoPath)
if clean == "" {
return 0, ""
} }
id := strings.TrimSuffix(base, filepath.Ext(base))
id = stripHotPrefix(id) p := strings.ToLower(filepath.ToSlash(clean))
return strings.TrimSpace(id) if strings.Contains(p, "/keep/") {
bucket = 1
} else {
bucket = 0
}
return bucket, strings.ToLower(filepath.Base(clean))
} }
// Liefert die standardisierten Pfade (preview.jpg / preview.mp4 / preview-sprite.jpg / meta.json) // Liefert die standardisierten Pfade (preview.jpg / preview.mp4 / preview-sprite.jpg / meta.json)
@ -93,6 +103,41 @@ func assetPathsForID(id string) (assetDir, thumbPath, previewPath, spritePath, m
return assetDir, thumbPath, previewPath, spritePath, metaPath, nil return assetDir, thumbPath, previewPath, spritePath, metaPath, nil
} }
func assetsTruthForVideo(videoPath string) finishedPhaseTruth {
videoPath = strings.TrimSpace(videoPath)
var truth finishedPhaseTruth
if videoPath == "" {
return truth
}
id := assetIDFromVideoPath(videoPath)
if id == "" {
return truth
}
// Basiszustand aus bestehender Logik lesen
truth = finishedPhaseTruthForID(id)
_, thumbPath, previewPath, spritePath, metaPath, err := assetPathsForID(id)
if err != nil {
return truth
}
// Meta: nur ready, wenn Inhalt OK UND Datei wirklich vorhanden
truth.MetaReady = truth.MetaReady && fileExistsNonEmpty(metaPath)
// Dateibasierte Assets immer an echter Datei festmachen
truth.ThumbReady = fileExistsNonEmpty(thumbPath)
truth.TeaserReady = fileExistsNonEmpty(previewPath)
truth.SpritesReady = fileExistsNonEmpty(spritePath)
// Analyse weiterhin inhaltlich prüfen
truth.AnalyzeReady = hasAIResultsForOutput(videoPath)
return truth
}
type ensuredMeta struct { type ensuredMeta struct {
durSec float64 durSec float64
vw, vh int vw, vh int
@ -814,20 +859,20 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string)
return fmt.Errorf("deferred sprite failed for %s: %w", videoPath, err) return fmt.Errorf("deferred sprite failed for %s: %w", videoPath, err)
} }
spriteTruth := regeneratePhaseTruthForVideo(videoPath, id) spriteTruth := assetsTruthForVideo(videoPath)
if !spriteTruth.SpritesReady { if !spriteTruth.SpritesReady {
return fmt.Errorf("preview-sprite.jpg fehlt nach deferred generation: %s", id) return fmt.Errorf("preview-sprite.jpg fehlt nach deferred generation: %s", id)
} }
// 2) KI-Analyse // 2) Analyse
analyzeReady, err := ensureAnalyzeForVideoCtx(ctx, videoPath, sourceURL, "nsfw") analyzeReady, err := ensureAnalyzeForVideoCtx(ctx, videoPath, sourceURL, "nsfw")
if err != nil { if err != nil {
return fmt.Errorf("deferred analyze failed for %s: %w", videoPath, err) return fmt.Errorf("deferred analyze failed for %s: %w", videoPath, err)
} }
analyzeTruth := regeneratePhaseTruthForVideo(videoPath, id) analyzeTruth := assetsTruthForVideo(videoPath)
if !analyzeReady && !analyzeTruth.AnalyzeReady { if !analyzeReady && !analyzeTruth.AnalyzeReady {
return fmt.Errorf("KI-Analyse fehlt nach deferred analyze: %s", id) return fmt.Errorf("Analyse fehlt nach deferred analyze: %s", id)
} }
return nil return nil
@ -845,7 +890,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string
return false return false
} }
truth := finishedPhaseTruthForID(assetID) truth := assetsTruthForVideo(outPath)
needMeta := !truth.MetaReady needMeta := !truth.MetaReady
needThumb := !truth.ThumbReady needThumb := !truth.ThumbReady
@ -872,13 +917,20 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "queued", "Sprites warten", nil) publishDeferredPhase(fileName, assetID, "postwork", "sprites", "queued", "Sprites warten", nil)
} }
if needAnalyze { if needAnalyze {
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "queued", "KI-Analyse wartet", nil) publishDeferredPhase(fileName, assetID, "enrich", "analyze", "queued", "Analyse wartet", nil)
} }
sortBucket, sortName := postWorkSortForVideoPath(outPath)
ok := enrichQ.Enqueue(PostWorkTask{ ok := enrichQ.Enqueue(PostWorkTask{
Key: "enrich:" + assetID, Key: "enrich:" + assetID,
Added: time.Now(), Added: time.Now(),
SortBucket: sortBucket,
SortName: sortName,
Run: func(ctx context.Context) error { Run: func(ctx context.Context) error {
if err := waitUntilEnrichMayRun(ctx); err != nil {
return err
}
return runDeferredEnrichPipeline(ctx, outPath, sourceURL) return runDeferredEnrichPipeline(ctx, outPath, sourceURL)
}, },
}) })
@ -903,7 +955,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "error", "Sprites konnten nicht eingeplant werden", enqueueErr) publishDeferredPhase(fileName, assetID, "postwork", "sprites", "error", "Sprites konnten nicht eingeplant werden", enqueueErr)
} }
if needAnalyze { if needAnalyze {
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "KI-Analyse konnte nicht eingeplant werden", enqueueErr) publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "Analyse konnte nicht eingeplant werden", enqueueErr)
} }
return false return false

View File

@ -10,6 +10,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"syscall"
) )
const ( const (
@ -29,9 +30,21 @@ func previewSpriteStepSeconds(durationSec float64) float64 {
return 5 return 5
} }
step := durationSec / float64(previewSpriteFrameCount) // 80 Zellen = 79 Abstände.
if step < 0.5 { // Wir ziehen ein kleines Stück vom Ende ab, damit ffmpeg
step = 0.5 // nicht "hinter" dem letzten gültigen Frame sampelt.
usableDur := durationSec - 0.10
if usableDur <= 0 {
usableDur = durationSec
}
step := usableDur / float64(previewSpriteFrameCount-1)
// Nicht künstlich auf 0.5s anheben:
// genau das erzeugt bei kürzeren Videos zu wenige Frames
// und damit schwarze Rest-Zellen im Sprite.
if step < 0.05 {
step = 0.05
} }
return step return step
@ -97,7 +110,7 @@ func generatePreviewSpriteJPG(
// robustere Filterkette // robustere Filterkette
vf := fmt.Sprintf( vf := fmt.Sprintf(
"fps=1/%f,"+ "fps=fps=1/%f:start_time=0:round=up,"+
"scale=%d:%d:force_original_aspect_ratio=decrease:flags=bilinear,"+ "scale=%d:%d:force_original_aspect_ratio=decrease:flags=bilinear,"+
"pad=%d:%d:(ow-iw)/2:(oh-ih)/2:black,"+ "pad=%d:%d:(ow-iw)/2:(oh-ih)/2:black,"+
"setsar=1,"+ "setsar=1,"+
@ -127,6 +140,10 @@ func generatePreviewSpriteJPG(
) )
cmd := exec.CommandContext(ctx, ffmpegPath, args...) cmd := exec.CommandContext(ctx, ffmpegPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var stderr bytes.Buffer var stderr bytes.Buffer
cmd.Stderr = &stderr cmd.Stderr = &stderr

View File

@ -78,7 +78,7 @@ type chaturbateCache struct {
} }
var ( var (
cbHTTP = &http.Client{Timeout: 30 * time.Second} cbHTTP = &http.Client{Timeout: 12 * time.Second}
cbMu sync.RWMutex cbMu sync.RWMutex
cb chaturbateCache cb chaturbateCache
@ -89,6 +89,9 @@ var (
var ( var (
cbRefreshMu sync.Mutex cbRefreshMu sync.Mutex
cbRefreshInFlight bool cbRefreshInFlight bool
cbStoreSyncMu sync.Mutex
cbStoreSyncInFlight bool
) )
func normalizeList(in []string) []string { func normalizeList(in []string) []string {
@ -511,20 +514,39 @@ func refreshChaturbateSnapshotNow(ctx context.Context) (time.Time, error) {
fetchedAtNow := cbApplySnapshot(rooms) fetchedAtNow := cbApplySnapshot(rooms)
if cbModelStore != nil { if cbModelStore != nil {
copiedRooms := append([]ChaturbateRoom(nil), rooms...) triggerChaturbateStoreSync(cbModelStore, rooms, fetchedAtNow)
syncChaturbateRoomStateIntoModelStore(
cbModelStore,
copiedRooms,
fetchedAtNow,
)
_ = cbModelStore.SyncChaturbateOnlineForKnownModels(copiedRooms, fetchedAtNow)
} }
return fetchedAtNow, nil return fetchedAtNow, nil
} }
func triggerChaturbateStoreSync(store *ModelStore, rooms []ChaturbateRoom, fetchedAt time.Time) {
if store == nil {
return
}
roomsCopy := append([]ChaturbateRoom(nil), rooms...)
cbStoreSyncMu.Lock()
if cbStoreSyncInFlight {
cbStoreSyncMu.Unlock()
return
}
cbStoreSyncInFlight = true
cbStoreSyncMu.Unlock()
go func() {
defer func() {
cbStoreSyncMu.Lock()
cbStoreSyncInFlight = false
cbStoreSyncMu.Unlock()
}()
syncChaturbateRoomStateIntoModelStore(store, roomsCopy, fetchedAt)
_ = store.SyncChaturbateOnlineForKnownModels(roomsCopy, fetchedAt)
}()
}
// startChaturbateOnlinePoller pollt die API alle paar Sekunden, // startChaturbateOnlinePoller pollt die API alle paar Sekunden,
// aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist. // aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist.
func startChaturbateOnlinePoller(store *ModelStore) { func startChaturbateOnlinePoller(store *ModelStore) {
@ -541,82 +563,101 @@ func startChaturbateOnlinePoller(store *ModelStore) {
var tagsMu sync.Mutex var tagsMu sync.Mutex
var tagsLast time.Time var tagsLast time.Time
refreshOnce := func() { refreshOnce := func(trigger string) {
if !getSettings().UseChaturbateAPI { if !getSettings().UseChaturbateAPI {
return return
} }
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) cbRefreshMu.Lock()
fetchedAtNow, err := refreshChaturbateSnapshotNow(ctx) if cbRefreshInFlight {
cancel() cbRefreshMu.Unlock()
if err != nil {
cbMu.RLock()
msg := cb.LastErr
prevFetchedAt := cb.FetchedAt
prevCount := len(cb.Rooms)
cbMu.RUnlock()
if msg != lastLoggedErr {
if prevFetchedAt.IsZero() {
fmt.Println("❌ [chaturbate] online rooms fetch failed (no snapshot yet):", msg)
} else {
fmt.Printf(
"⚠️ [chaturbate] refresh failed, keeping snapshot from %s (%d rooms): %s\n",
prevFetchedAt.Format(time.RFC3339),
prevCount,
msg,
)
}
lastLoggedErr = msg
}
return return
} }
cbRefreshInFlight = true
cbRefreshMu.Unlock()
cbMu.RLock() go func() {
roomsCopy := append([]ChaturbateRoom(nil), cb.Rooms...) defer func() {
roomsCount := len(cb.Rooms) cbRefreshMu.Lock()
cbMu.RUnlock() cbRefreshInFlight = false
cbRefreshMu.Unlock()
}()
// Tags nur selten und asynchron ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
if cbModelStore != nil && len(roomsCopy) > 0 { fetchedAtNow, err := refreshChaturbateSnapshotNow(ctx)
shouldFill := false cancel()
tagsMu.Lock() if err != nil {
if tagsLast.IsZero() || time.Since(tagsLast) >= tagsFillEvery { cbMu.RLock()
tagsLast = time.Now() msg := cb.LastErr
shouldFill = true prevFetchedAt := cb.FetchedAt
prevCount := len(cb.Rooms)
lastAttempt := cb.LastAttempt
cbMu.RUnlock()
// wiederholt loggen, nicht nur bei geändertem Fehlertext
if msg != lastLoggedErr || time.Since(lastAttempt) > 30*time.Second {
if prevFetchedAt.IsZero() {
fmt.Println("❌ [chaturbate] online rooms fetch failed (no snapshot yet):", msg)
} else {
fmt.Printf(
"⚠️ [chaturbate] refresh failed, keeping snapshot from %s (%d rooms): %s\n",
prevFetchedAt.Format(time.RFC3339),
prevCount,
msg,
)
}
lastLoggedErr = msg
}
return
} }
tagsMu.Unlock()
if shouldFill { cbMu.RLock()
go cbModelStore.FillMissingTagsFromChaturbateOnline(roomsCopy) roomsCopy := append([]ChaturbateRoom(nil), cb.Rooms...)
roomsCount := len(cb.Rooms)
cbMu.RUnlock()
// Tags nur selten und asynchron
if cbModelStore != nil {
shouldFill := false
tagsMu.Lock()
if tagsLast.IsZero() || time.Since(tagsLast) >= tagsFillEvery {
tagsLast = time.Now()
shouldFill = true
}
tagsMu.Unlock()
if shouldFill {
go cbModelStore.FillMissingTagsFromChaturbateOnline(roomsCopy)
}
} }
}
if lastLoggedErr != "" { if lastLoggedErr != "" {
fmt.Println("✅ [chaturbate] online rooms fetch recovered") fmt.Println("✅ [chaturbate] online rooms fetch recovered")
lastLoggedErr = "" lastLoggedErr = ""
} }
if verboseLogs() && roomsCount != lastLoggedCount { if verboseLogs() && roomsCount != lastLoggedCount {
fmt.Printf( fmt.Printf(
"✅ [chaturbate] online rooms: %d (snapshot %s)\n", "✅ [chaturbate] online rooms: %d (snapshot %s, trigger=%s)\n",
roomsCount, roomsCount,
fetchedAtNow.Format(time.RFC3339), fetchedAtNow.Format(time.RFC3339),
) trigger,
lastLoggedCount = roomsCount )
} lastLoggedCount = roomsCount
}
}()
} }
// ✅ sofort einmal versuchen // ✅ sofort einmal versuchen
refreshOnce() refreshOnce("startup")
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()
for range ticker.C { for range ticker.C {
refreshOnce() refreshOnce("ticker")
} }
} }

View File

@ -1,114 +1,114 @@
{ {
"items": [ "items": [
{ {
"modelKey": "sw33t_g1rl", "modelKey": "xdaisyx",
"url": "https://www.myfreecams.com/#Sw33t_g1rl", "url": "https://www.myfreecams.com/#xDaisyx",
"mode": "probe_retry", "mode": "probe_retry",
"nextProbeAtMs": 1776172330458, "nextProbeAtMs": 1777292591836,
"currentShow": "offline", "currentShow": "offline",
"source": "watched" "source": "watched"
}, },
{ {
"modelKey": "angel_from_sky", "modelKey": "wekittycluby",
"url": "https://chaturbate.com/angel_from_sky/", "url": "https://chaturbate.com/wekittycluby/",
"mode": "wait_public", "mode": "wait_public",
"currentShow": "private", "currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/angel_from_sky.jpg", "imageUrl": "https://thumb.live.mmcdn.com/ri/wekittycluby.jpg",
"source": "watched" "source": "watched"
}, },
{ {
"modelKey": "vanicha_007", "modelKey": "la__melanie",
"url": "https://chaturbate.com/vanicha_007/", "url": "https://chaturbate.com/la__melanie/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/la__melanie.jpg",
"source": "watched"
},
{
"modelKey": "adrean_sofi",
"url": "https://chaturbate.com/adrean_sofi/",
"mode": "wait_public", "mode": "wait_public",
"currentShow": "hidden", "currentShow": "hidden",
"imageUrl": "https://thumb.live.mmcdn.com/ri/vanicha_007.jpg", "imageUrl": "https://thumb.live.mmcdn.com/ri/adrean_sofi.jpg",
"source": "watched" "source": "watched"
}, },
{ {
"modelKey": "tomokosturgeon", "modelKey": "extasy_maya",
"url": "https://chaturbate.com/tomokosturgeon/", "url": "https://chaturbate.com/extasy_maya/",
"mode": "wait_public", "mode": "wait_public",
"currentShow": "hidden", "currentShow": "hidden",
"imageUrl": "https://thumb.live.mmcdn.com/ri/tomokosturgeon.jpg", "imageUrl": "https://thumb.live.mmcdn.com/ri/extasy_maya.jpg",
"source": "watched" "source": "watched"
}, },
{ {
"modelKey": "tammieprotano", "modelKey": "rita__shy",
"url": "https://chaturbate.com/tammieprotano/", "url": "https://chaturbate.com/rita__shy/",
"mode": "wait_public", "mode": "wait_public",
"currentShow": "private", "currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/tammieprotano.jpg", "imageUrl": "https://thumb.live.mmcdn.com/ri/rita__shy.jpg",
"source": "watched" "source": "watched"
}, },
{ {
"modelKey": "troybailly", "modelKey": "shachatte",
"url": "https://chaturbate.com/troybailly/", "url": "https://chaturbate.com/shachatte/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/shachatte.jpg",
"source": "watched"
},
{
"modelKey": "carolecronan",
"url": "https://chaturbate.com/carolecronan/",
"mode": "wait_public",
"currentShow": "away",
"imageUrl": "https://thumb.live.mmcdn.com/ri/carolecronan.jpg",
"source": "watched"
},
{
"modelKey": "lora_lifelover",
"url": "https://chaturbate.com/lora_lifelover/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/lora_lifelover.jpg",
"source": "watched"
},
{
"modelKey": "phantomlace",
"url": "https://chaturbate.com/phantomlace/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/phantomlace.jpg",
"source": "watched"
},
{
"modelKey": "aurora_the_ballerina",
"url": "https://chaturbate.com/aurora_the_ballerina/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/aurora_the_ballerina.jpg",
"source": "watched"
},
{
"modelKey": "anabel054",
"url": "https://chaturbate.com/anabel054/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/anabel054.jpg",
"source": "watched"
},
{
"modelKey": "phqqipi",
"url": "https://chaturbate.com/phqqipi/",
"mode": "wait_public", "mode": "wait_public",
"currentShow": "hidden", "currentShow": "hidden",
"imageUrl": "https://thumb.live.mmcdn.com/ri/troybailly.jpg", "imageUrl": "https://thumb.live.mmcdn.com/ri/phqqipi.jpg",
"source": "watched" "source": "watched"
}, },
{ {
"modelKey": "vonen_ray", "modelKey": "evewilder",
"url": "https://chaturbate.com/vonen_ray/", "url": "https://chaturbate.com/evewilder/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/vonen_ray.jpg",
"source": "watched"
},
{
"modelKey": "sharitaklemme",
"url": "https://chaturbate.com/sharitaklemme/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/sharitaklemme.jpg",
"source": "watched"
},
{
"modelKey": "sosaguillypleasure",
"url": "https://chaturbate.com/sosaguillypleasure/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/sosaguillypleasure.jpg",
"source": "watched"
},
{
"modelKey": "kittymeow_xxx",
"url": "https://chaturbate.com/kittymeow_xxx/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/kittymeow_xxx.jpg",
"source": "watched"
},
{
"modelKey": "superstar090",
"url": "https://chaturbate.com/superstar090/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/superstar090.jpg",
"source": "watched"
},
{
"modelKey": "victoryglow",
"url": "https://chaturbate.com/victoryglow/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/victoryglow.jpg",
"source": "watched"
},
{
"modelKey": "palmiratownzen",
"url": "https://chaturbate.com/palmiratownzen/",
"mode": "wait_public",
"currentShow": "hidden",
"imageUrl": "https://thumb.live.mmcdn.com/ri/palmiratownzen.jpg",
"source": "watched"
},
{
"modelKey": "rafaelaloghry",
"url": "https://chaturbate.com/rafaelaloghry/",
"mode": "probe_retry", "mode": "probe_retry",
"nextProbeAtMs": 1776172331673, "nextProbeAtMs": 1777292606854,
"currentShow": "unknown", "currentShow": "unknown",
"source": "watched" "source": "watched"
} }

View File

@ -4,6 +4,7 @@ go 1.25.3
require ( require (
github.com/PuerkitoBio/goquery v1.11.0 github.com/PuerkitoBio/goquery v1.11.0
github.com/getlantern/systray v1.2.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/grafov/m3u8 v0.12.1 github.com/grafov/m3u8 v0.12.1
github.com/jackc/pgx/v5 v5.8.0 github.com/jackc/pgx/v5 v5.8.0
@ -15,11 +16,19 @@ require (
require ( require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect

View File

@ -9,8 +9,24 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBW
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@ -28,6 +44,10 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
@ -42,6 +62,7 @@ github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFt
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 h1:2JL2wmHXWIAxDofCK+AdkFi1KEg3dgkefCsm7isADzQ= github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 h1:2JL2wmHXWIAxDofCK+AdkFi1KEg3dgkefCsm7isADzQ=
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw= github.com/sqweek/dialog v0.0.0-20240226140203-065105509627/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@ -96,11 +117,13 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -138,6 +161,7 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y=
gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI= gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -17,6 +17,7 @@ import (
"regexp" "regexp"
"strings" "strings"
"sync" "sync"
"syscall"
"time" "time"
) )
@ -536,6 +537,11 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h
) )
cmd := exec.CommandContext(ctx, ffmpegPath, hqArgs...) cmd := exec.CommandContext(ctx, ffmpegPath, hqArgs...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var stderr bytes.Buffer var stderr bytes.Buffer
cmd.Stderr = &stderr cmd.Stderr = &stderr
@ -828,6 +834,10 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
) )
cmd := exec.CommandContext(ctx, ffmpegPath, args...) cmd := exec.CommandContext(ctx, ffmpegPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
stdout, err := cmd.StdoutPipe() stdout, err := cmd.StdoutPipe()
if err != nil { if err != nil {

View File

@ -20,6 +20,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"syscall"
"time" "time"
"github.com/grafov/m3u8" "github.com/grafov/m3u8"
@ -36,6 +37,10 @@ type Playlist struct {
Framerate int Framerate int
} }
var recordedAssetStemPrefixRe = regexp.MustCompile(
`^(.+?_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2})`,
)
const ( const (
JobRunning JobStatus = "running" JobRunning JobStatus = "running"
JobPostwork JobStatus = "postwork" // ✅ NEU: Aufnahme vorbei, Nacharbeiten laufen noch JobPostwork JobStatus = "postwork" // ✅ NEU: Aufnahme vorbei, Nacharbeiten laufen noch
@ -130,6 +135,29 @@ type ffprobeInfo struct {
Streams []ffprobeStreamInfo `json:"streams"` Streams []ffprobeStreamInfo `json:"streams"`
} }
func canonicalAssetIDFromName(name string) string {
base := strings.TrimSpace(filepath.Base(name))
if base == "" {
return ""
}
stem := strings.TrimSuffix(base, filepath.Ext(base))
stem = stripHotPrefix(strings.TrimSpace(stem))
stem = strings.TrimPrefix(stem, ".")
if stem == "" {
return ""
}
// Wichtig:
// ".chaesoffel_04_22_2026__12-22-16.tmp.20260422_134354_000"
// -> "chaesoffel_04_22_2026__12-22-16"
if m := recordedAssetStemPrefixRe.FindStringSubmatch(stem); m != nil {
return strings.TrimSpace(m[1])
}
return stem
}
func publishJobUpsertSnapshot(s jobEventSnapshot) { func publishJobUpsertSnapshot(s jobEventSnapshot) {
ev := map[string]any{ ev := map[string]any{
"type": "job_upsert", "type": "job_upsert",
@ -230,6 +258,10 @@ func probeVideoProps(ctx context.Context, filePath string) (w int, h int, fps fl
"-of", "json", "-of", "json",
filePath, filePath,
) )
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
out, err := cmd.Output() out, err := cmd.Output()
if err != nil { if err != nil {
@ -447,6 +479,11 @@ func durationSecondsCached(ctx context.Context, path string) (float64, error) {
"-of", "default=noprint_wrappers=1:nokey=1", "-of", "default=noprint_wrappers=1:nokey=1",
path, path,
) )
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
out, err := cmd.Output() out, err := cmd.Output()
if err == nil { if err == nil {
s := strings.TrimSpace(string(out)) s := strings.TrimSpace(string(out))
@ -461,6 +498,10 @@ func durationSecondsCached(ctx context.Context, path string) (float64, error) {
// 2) Fallback: ffmpeg -i "Duration: HH:MM:SS.xx" parsen // 2) Fallback: ffmpeg -i "Duration: HH:MM:SS.xx" parsen
cmd2 := exec.CommandContext(ctx, ffmpegPath, "-i", path) cmd2 := exec.CommandContext(ctx, ffmpegPath, "-i", path)
cmd2.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
b, _ := cmd2.CombinedOutput() // ffmpeg liefert hier oft ExitCode!=0, Output ist trotzdem da b, _ := cmd2.CombinedOutput() // ffmpeg liefert hier oft ExitCode!=0, Output ist trotzdem da
text := string(b) text := string(b)
@ -569,21 +610,41 @@ func removeGeneratedForID(id string) {
return return
} }
id = stripHotPrefix(id)
if isAssetIDInUse(id) { if isAssetIDInUse(id) {
return return
} }
id = strings.TrimSuffix(id, filepath.Ext(id))
id = stripHotPrefix(id)
var err error var err error
id, err = sanitizeID(id) id, err = sanitizeID(id)
if err != nil || id == "" { if err != nil || id == "" {
return return
} }
removed := map[string]struct{}{}
removeDir := func(p string) {
p = strings.TrimSpace(p)
if p == "" {
return
}
cp := filepath.Clean(p)
if _, seen := removed[cp]; seen {
return
}
_ = os.RemoveAll(cp)
removed[cp] = struct{}{}
}
// 1) Asset-Ordner mit preview.jpg / preview.mp4 / preview-sprite.jpg
if dir, err := generatedDirForID(id); err == nil && strings.TrimSpace(dir) != "" {
removeDir(dir)
}
// 2) Separater Meta-Ordner generated/meta/<id>
if root, _ := generatedMetaRoot(); strings.TrimSpace(root) != "" { if root, _ := generatedMetaRoot(); strings.TrimSpace(root) != "" {
_ = os.RemoveAll(filepath.Join(root, id)) removeDir(filepath.Join(root, id))
} }
} }
@ -766,6 +827,11 @@ func remuxTSToMP4(tsPath, mp4Path string) error {
"-movflags", "+faststart", "-movflags", "+faststart",
mp4Path, mp4Path,
) )
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var stderr bytes.Buffer var stderr bytes.Buffer
cmd.Stderr = &stderr cmd.Stderr = &stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
@ -800,16 +866,35 @@ func remuxTSToMP4WithProgress(
onRatio func(r float64), onRatio func(r float64),
) error { ) error {
// ffmpeg progress kommt auf stdout als key=value // ffmpeg progress kommt auf stdout als key=value
cmd := exec.CommandContext(ctx, ffmpegPath, args := []string{
"-y", "-y",
"-nostats", "-nostats",
"-progress", "pipe:1", "-progress", "pipe:1",
"-hide_banner",
"-loglevel", "warning",
}
args = append(args, ffmpegInputTol...)
args = append(args,
"-i", tsPath, "-i", tsPath,
"-map", "0:v:0",
"-map", "0:a:0?",
"-dn",
"-ignore_unknown",
"-avoid_negative_ts", "make_zero",
"-max_interleave_delta", "0",
"-muxpreload", "0",
"-muxdelay", "0",
"-c", "copy", "-c", "copy",
"-movflags", "+faststart", "-movflags", "+faststart",
mp4Path, mp4Path,
) )
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
stdout, err := cmd.StdoutPipe() stdout, err := cmd.StdoutPipe()
if err != nil { if err != nil {
return err return err
@ -996,6 +1081,10 @@ func ensureFastStartMP4(path string) error {
"-movflags", "+faststart", "-movflags", "+faststart",
tmp, tmp,
) )
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var stderr bytes.Buffer var stderr bytes.Buffer
cmd.Stderr = &stderr cmd.Stderr = &stderr
@ -1119,8 +1208,7 @@ func uniqueDestPath(dstDir, file string) (string, error) {
} }
func idFromVideoPath(videoPath string) string { func idFromVideoPath(videoPath string) string {
name := filepath.Base(strings.TrimSpace(videoPath)) return canonicalAssetIDFromName(videoPath)
return strings.TrimSuffix(name, filepath.Ext(name))
} }
func assetIDForJob(job *RecordJob) string { func assetIDForJob(job *RecordJob) string {
@ -1521,3 +1609,109 @@ func fileExists(path string) bool {
_, err := os.Stat(path) _, err := os.Stat(path)
return err == nil return err == nil
} }
func validateVideoDecodesNearEnd(ctx context.Context, path string) error {
path = strings.TrimSpace(path)
if path == "" {
return fmt.Errorf("empty path")
}
dur, err := durationSecondsCached(ctx, path)
if err != nil || dur <= 0 {
return nil // best effort
}
t := dur * 0.90
if t < 0.1 {
t = 0.1
}
img, err := extractFrameAtTimeJPG(path, t)
if err != nil {
return fmt.Errorf("decode near end failed at %.3fs: %w", t, err)
}
if len(img) == 0 {
return fmt.Errorf("decode near end returned empty frame at %.3fs", t)
}
return nil
}
func uniqueSiblingTempPath(finalPath, suffix string) string {
finalPath = strings.TrimSpace(finalPath)
dir := filepath.Dir(finalPath)
ext := filepath.Ext(finalPath)
base := strings.TrimSuffix(filepath.Base(finalPath), ext)
ts := time.Now().Format("20060102_150405_000")
if strings.TrimSpace(suffix) == "" {
return filepath.Join(dir, fmt.Sprintf(".%s.%s%s", base, ts, ext))
}
return filepath.Join(dir, fmt.Sprintf(".%s.%s.%s%s", base, suffix, ts, ext))
}
func reencodeTSToMP4(ctx context.Context, tsPath, mp4Path string) error {
tmp := uniqueSiblingTempPath(mp4Path, "tmp")
_ = os.Remove(tmp)
args := []string{
"-y",
"-hide_banner",
"-loglevel", "warning",
}
args = append(args, ffmpegInputTol...)
args = append(args,
"-i", tsPath,
"-map", "0:v:0",
"-map", "0:a:0?",
"-dn",
"-ignore_unknown",
"-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
"-c:v", "libx264",
"-preset", "veryfast",
"-crf", "20",
"-pix_fmt", "yuv420p",
"-c:a", "aac",
"-b:a", "128k",
"-ac", "2",
"-af", "aresample=async=1:first_pts=0",
"-shortest",
"-movflags", "+faststart",
tmp,
)
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000,
}
var stderr bytes.Buffer
cmd.Stdout = io.Discard
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("ffmpeg reencode failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
}
if err := requireNonEmptyRegularFile(tmp, "reencode tmp result"); err != nil {
_ = os.Remove(tmp)
return err
}
_ = os.Remove(mp4Path)
if err := os.Rename(tmp, mp4Path); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("rename reencode tmp -> target failed: %w", err)
}
return nil
}

View File

@ -93,11 +93,12 @@ type videoMeta struct {
Version int `json:"version"` Version int `json:"version"`
UpdatedAtUnix int64 `json:"updatedAtUnix"` UpdatedAtUnix int64 `json:"updatedAtUnix"`
File videoFileMeta `json:"file"` File videoFileMeta `json:"file"`
Media videoMediaMeta `json:"media"` Media videoMediaMeta `json:"media"`
Preview previewMeta `json:"preview,omitempty"` Preview previewMeta `json:"preview,omitempty"`
Analysis analysisMeta `json:"analysis,omitempty"` Analysis analysisMeta `json:"analysis,omitempty"`
Postwork postworkMeta `json:"postwork,omitempty"` Postwork postworkMeta `json:"postwork,omitempty"`
Validation map[string]any `json:"validation,omitempty"`
} }
type aiSegmentMeta struct { type aiSegmentMeta struct {
@ -436,6 +437,7 @@ func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64, w int, h int,
m.Preview = existing.Preview m.Preview = existing.Preview
m.Analysis = existing.Analysis m.Analysis = existing.Analysis
m.Postwork = existing.Postwork m.Postwork = existing.Postwork
m.Validation = existing.Validation
if m.File.SourceURL == "" { if m.File.SourceURL == "" {
m.File.SourceURL = existing.File.SourceURL m.File.SourceURL = existing.File.SourceURL

View File

@ -10,22 +10,27 @@ import (
// Eine Nacharbeit (kann ffmpeg, ffprobe, thumbnails, rename, etc. enthalten) // Eine Nacharbeit (kann ffmpeg, ffprobe, thumbnails, rename, etc. enthalten)
type PostWorkTask struct { type PostWorkTask struct {
Key string // z.B. Dateiname oder Job-ID, zum Deduplizieren Key string // z.B. Dateiname oder Job-ID, zum Deduplizieren
Run func(ctx context.Context) error Run func(ctx context.Context) error
Added time.Time Added time.Time
SortBucket int // 0 = /done, 1 = /done/keep
SortName string // Dateiname lower-case
} }
type PostWorkQueue struct { type PostWorkQueue struct {
q chan PostWorkTask
ffmpegSem chan struct{} // "heavy" gate: maxParallelFFmpeg gleichzeitig ffmpegSem chan struct{} // "heavy" gate: maxParallelFFmpeg gleichzeitig
maxQueued int
mu sync.Mutex mu sync.Mutex
cond *sync.Cond
queue []PostWorkTask
inflight map[string]struct{} // dedupe: queued ODER running inflight map[string]struct{} // dedupe: queued ODER running
queued int // Anzahl inflight (queued + running) queued int // Anzahl inflight (queued + running)
// ✅ für UI: Warteschlange + Running-Keys tracken // ✅ für UI: Warteschlange + Running-Keys tracken
waitingKeys []string // FIFO der Keys, die noch NICHT gestartet sind waitingKeys []string // sortierte wartende Keys
runningKeys map[string]struct{} // Keys, die gerade wirklich laufen (Semaphor gehalten) runningKeys map[string]struct{} // Keys, die gerade wirklich laufen (Semaphor gehalten)
cancelByKey map[string]context.CancelFunc cancelByKey map[string]context.CancelFunc
} }
@ -38,16 +43,71 @@ func NewPostWorkQueue(queueSize int, maxParallelFFmpeg int) *PostWorkQueue {
maxParallelFFmpeg = 1 // Default: "nacheinander" maxParallelFFmpeg = 1 // Default: "nacheinander"
} }
return &PostWorkQueue{ pq := &PostWorkQueue{
q: make(chan PostWorkTask, queueSize),
ffmpegSem: make(chan struct{}, maxParallelFFmpeg), ffmpegSem: make(chan struct{}, maxParallelFFmpeg),
maxQueued: queueSize,
queue: make([]PostWorkTask, 0, queueSize),
inflight: make(map[string]struct{}), inflight: make(map[string]struct{}),
waitingKeys: make([]string, 0, queueSize), waitingKeys: make([]string, 0, queueSize),
runningKeys: make(map[string]struct{}), runningKeys: make(map[string]struct{}),
cancelByKey: make(map[string]context.CancelFunc), cancelByKey: make(map[string]context.CancelFunc),
} }
pq.cond = sync.NewCond(&pq.mu)
return pq
}
func lessPostWorkTask(a, b PostWorkTask) bool {
aHasSort := strings.TrimSpace(a.SortName) != ""
bHasSort := strings.TrimSpace(b.SortName) != ""
if aHasSort || bHasSort {
ab := a.SortBucket
bb := b.SortBucket
if !aHasSort {
ab = 999
}
if !bHasSort {
bb = 999
}
if ab != bb {
return ab < bb
}
an := strings.ToLower(strings.TrimSpace(a.SortName))
bn := strings.ToLower(strings.TrimSpace(b.SortName))
if an != bn {
return an < bn
}
}
if !a.Added.Equal(b.Added) {
return a.Added.Before(b.Added)
}
return a.Key < b.Key
}
func containsWaitingKey(keys []string, key string) bool {
for _, k := range keys {
if k == key {
return true
}
}
return false
}
func removeQueuedTaskLocked(tasks []PostWorkTask, key string) []PostWorkTask {
for i, t := range tasks {
if t.Key == key {
return append(tasks[:i], tasks[i+1:]...)
}
}
return tasks
} }
// Enqueue dedupliziert nach Key (damit du nicht durch Events doppelt queue-st) // Enqueue dedupliziert nach Key (damit du nicht durch Events doppelt queue-st)
@ -55,31 +115,41 @@ func (pq *PostWorkQueue) Enqueue(task PostWorkTask) bool {
if task.Key == "" || task.Run == nil { if task.Key == "" || task.Run == nil {
return false return false
} }
if task.Added.IsZero() {
task.Added = time.Now()
}
pq.mu.Lock() pq.mu.Lock()
defer pq.mu.Unlock()
if _, ok := pq.inflight[task.Key]; ok { if _, ok := pq.inflight[task.Key]; ok {
pq.mu.Unlock()
return false // schon queued oder läuft return false // schon queued oder läuft
} }
pq.inflight[task.Key] = struct{}{} if pq.maxQueued > 0 && len(pq.queue) >= pq.maxQueued {
pq.waitingKeys = append(pq.waitingKeys, task.Key) // ✅ queued für UI
pq.queued++
pq.mu.Unlock()
select {
case pq.q <- task:
return true
default:
// Queue voll -> inflight zurückrollen
pq.mu.Lock()
delete(pq.inflight, task.Key)
if pq.queued > 0 {
pq.queued--
}
pq.removeWaitingKeyLocked(task.Key)
pq.mu.Unlock()
return false return false
} }
pq.inflight[task.Key] = struct{}{}
pq.queued++
insertAt := len(pq.queue)
for i, existing := range pq.queue {
if lessPostWorkTask(task, existing) {
insertAt = i
break
}
}
pq.queue = append(pq.queue, PostWorkTask{})
copy(pq.queue[insertAt+1:], pq.queue[insertAt:])
pq.queue[insertAt] = task
pq.waitingKeys = append(pq.waitingKeys, "")
copy(pq.waitingKeys[insertAt+1:], pq.waitingKeys[insertAt:])
pq.waitingKeys[insertAt] = task.Key
pq.cond.Signal()
return true
} }
func (pq *PostWorkQueue) Cancel(key string) bool { func (pq *PostWorkQueue) Cancel(key string) bool {
@ -138,27 +208,30 @@ func (pq *PostWorkQueue) RemoveQueued(key string) bool {
pq.queued-- pq.queued--
} }
pq.queue = removeQueuedTaskLocked(pq.queue, key)
// Das Task-Element bleibt evtl. noch im Channel liegen. // Das Task-Element bleibt evtl. noch im Channel liegen.
// Beim Worker-Start muss es dann erkannt und übersprungen werden. // Beim Worker-Start muss es dann erkannt und übersprungen werden.
return true return true
} }
func (pq *PostWorkQueue) workerLoop(id int) { func (pq *PostWorkQueue) workerLoop(id int) {
for task := range pq.q { for {
pq.mu.Lock()
for len(pq.queue) == 0 {
pq.cond.Wait()
}
task := pq.queue[0]
pq.queue = pq.queue[1:]
pq.mu.Unlock()
pq.mu.Lock() pq.mu.Lock()
_, stillInflight := pq.inflight[task.Key] _, stillInflight := pq.inflight[task.Key]
_, alreadyRunning := pq.runningKeys[task.Key] _, alreadyRunning := pq.runningKeys[task.Key]
waitingFound := containsWaitingKey(pq.waitingKeys, task.Key)
waitingFound := false
for _, k := range pq.waitingKeys {
if k == task.Key {
waitingFound = true
break
}
}
pq.mu.Unlock() pq.mu.Unlock()
// queued Job wurde zwischenzeitlich entfernt -> Task aus dem Channel verwerfen // queued Job wurde zwischenzeitlich entfernt -> verwerfen
if !stillInflight || (!waitingFound && !alreadyRunning) { if !stillInflight || (!waitingFound && !alreadyRunning) {
continue continue
} }
@ -188,7 +261,6 @@ func (pq *PostWorkQueue) workerLoop(id int) {
defer cancel() defer cancel()
// ffmpeg/heavy-slot holen
select { select {
case pq.ffmpegSem <- struct{}{}: case pq.ffmpegSem <- struct{}{}:
case <-ctx.Done(): case <-ctx.Done():
@ -198,7 +270,6 @@ func (pq *PostWorkQueue) workerLoop(id int) {
<-pq.ffmpegSem <-pq.ffmpegSem
}() }()
// jetzt wirklich von waiting -> running
pq.mu.Lock() pq.mu.Lock()
pq.removeWaitingKeyLocked(task.Key) pq.removeWaitingKeyLocked(task.Key)
pq.runningKeys[task.Key] = struct{}{} pq.runningKeys[task.Key] = struct{}{}
@ -277,7 +348,7 @@ func (pq *PostWorkQueue) StatusForKey(key string) PostWorkKeyStatus {
} }
// global (oder in deinem app struct halten) // global (oder in deinem app struct halten)
var postWorkQ = NewPostWorkQueue(512, 2) // schneller kritischer Pfad var postWorkQ = NewPostWorkQueue(512, 1) // schneller kritischer Pfad
var enrichQ = NewPostWorkQueue(512, 1) // langsame Hintergrundaufgaben var enrichQ = NewPostWorkQueue(512, 1) // langsame Hintergrundaufgaben
// --- Status Refresher (ehemals postwork_refresh.go) --- // --- Status Refresher (ehemals postwork_refresh.go) ---
@ -320,6 +391,26 @@ func clearEnrichStatusSnapshot(key string) {
enrichStatusLastMu.Unlock() enrichStatusLastMu.Unlock()
} }
func waitUntilEnrichMayRun(ctx context.Context) error {
t := time.NewTicker(2 * time.Second)
defer t.Stop()
for {
waiting, running, _ := postWorkQ.Stats()
// enrich erst starten, wenn keine primäre Postwork mehr läuft
if waiting == 0 && running == 0 {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
}
}
}
func startEnrichStatusRefresher() { func startEnrichStatusRefresher() {
t := time.NewTicker(2 * time.Second) t := time.NewTicker(2 * time.Second)
go func() { go func() {

View File

@ -2,7 +2,6 @@
package main package main
import ( import (
"bufio"
"bytes" "bytes"
"context" "context"
"crypto/sha1" "crypto/sha1"
@ -18,7 +17,6 @@ import (
"image/jpeg" "image/jpeg"
"image/png" "image/png"
"io" "io"
"math"
"math/rand" "math/rand"
"net/http" "net/http"
"net/url" "net/url"
@ -32,6 +30,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"syscall"
"time" "time"
"golang.org/x/image/font" "golang.org/x/image/font"
@ -1194,6 +1193,10 @@ func extractLastFrameJPG(path string) ([]byte, error) {
"-f", "image2pipe", "-f", "image2pipe",
"pipe:1", "pipe:1",
) )
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var out bytes.Buffer var out bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@ -1233,6 +1236,10 @@ func extractFrameAtTimeJPG(path string, seconds float64) ([]byte, error) {
"-f", "image2pipe", "-f", "image2pipe",
"pipe:1", "pipe:1",
) )
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var out bytes.Buffer var out bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@ -1277,6 +1284,10 @@ func extractLastFrameJPGScaled(path string, width int, quality int) ([]byte, err
"-f", "image2pipe", "-f", "image2pipe",
"pipe:1", "pipe:1",
) )
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var out bytes.Buffer var out bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@ -1312,6 +1323,10 @@ func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, er
"-f", "image2pipe", "-f", "image2pipe",
"pipe:1", "pipe:1",
) )
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var out bytes.Buffer var out bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@ -1361,6 +1376,10 @@ func extractLiveFrameFromM3U8ThumbJPG(ctx context.Context, m3u8URL, cookie, user
) )
cmd := exec.CommandContext(ctx, ffmpegPath, args...) cmd := exec.CommandContext(ctx, ffmpegPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var out bytes.Buffer var out bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@ -2163,525 +2182,3 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
_ = atomicWriteFile(thumbPath, img) _ = atomicWriteFile(thumbPath, img)
servePreviewJPGBytes(w, img) servePreviewJPGBytes(w, img)
} }
// ============================================================
// Teaser generation (used by /api/generated/teaser)
// ============================================================
const minSegmentDuration = 0.75
const defaultTeaserSegments = 12
type TeaserPreviewOptions struct {
Segments int
SegmentDuration float64
Width int
Preset string
CRF int
Audio bool
AudioBitrate string
UseVsync2 bool
}
func (o TeaserPreviewOptions) stepSizeAndOffset(dur float64) (float64, float64) {
if dur <= 0 {
return 0, 0
}
n := o.Segments
if n < 1 {
n = 1
}
segDur := o.SegmentDuration
if segDur <= 0 {
segDur = 1
}
if segDur < minSegmentDuration {
segDur = minSegmentDuration
}
maxStart := dur - 0.05 - segDur
if maxStart < 0 {
maxStart = 0
}
if n == 1 {
return 0, maxStart * 0.5
}
margin := 0.05 * maxStart
if margin < 0 {
margin = 0
}
span := maxStart - 2*margin
if span < 0 {
span = maxStart
margin = 0
}
step := 0.0
if n > 1 {
step = span / float64(n-1)
}
return step, margin
}
func generateTeaserClipsMP4(ctx context.Context, srcPath, outPath string, clipLenSec float64, maxClips int) error {
return generateTeaserClipsMP4WithProgress(ctx, srcPath, outPath, clipLenSec, maxClips, nil)
}
func generateTeaserClipsMP4WithProgress(ctx context.Context, srcPath, outPath string, clipLenSec float64, maxClips int, onRatio func(r float64)) error {
opts := TeaserPreviewOptions{
Segments: maxClips,
SegmentDuration: clipLenSec,
Width: 640,
Preset: "veryfast",
CRF: 21,
Audio: true,
AudioBitrate: "128k",
UseVsync2: false,
}
return generateTeaserPreviewMP4WithProgress(ctx, srcPath, outPath, opts, onRatio)
}
func generateTeaserChunkMP4(ctx context.Context, src, out string, start, dur float64, opts TeaserPreviewOptions) error {
opts.Audio = true
tmp := strings.TrimSuffix(out, ".mp4") + ".part.mp4"
segDur := dur
if segDur < minSegmentDuration {
segDur = minSegmentDuration
}
args := []string{"-y", "-hide_banner", "-loglevel", "error"}
args = append(args, ffmpegInputTol...)
args = append(args,
"-ss", fmt.Sprintf("%.3f", start),
"-t", fmt.Sprintf("%.3f", segDur),
"-i", src,
"-map", "0:v:0",
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
"-profile:v", "high",
"-level", "4.2",
"-preset", opts.Preset,
"-crf", strconv.Itoa(opts.CRF),
"-threads", "2",
)
if opts.UseVsync2 {
args = append(args, "-vsync", "2")
}
args = append(args,
"-map", "0:a:0",
"-c:a", "aac",
"-b:a", opts.AudioBitrate,
"-ac", "2",
"-shortest",
)
args = append(args, "-movflags", "+faststart", tmp)
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("ffmpeg teaser chunk failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
}
_ = os.Remove(out)
return os.Rename(tmp, out)
}
func computeTeaserStarts(dur float64, opts TeaserPreviewOptions) (starts []float64, segDur float64, usedSegments int) {
if opts.SegmentDuration <= 0 {
opts.SegmentDuration = 1
}
if opts.Segments <= 0 {
opts.Segments = defaultTeaserSegments
}
segDur = opts.SegmentDuration
if segDur < minSegmentDuration {
segDur = minSegmentDuration
}
if dur > 0 && dur < segDur*float64(opts.Segments) {
opts.Segments = 1
segDur = dur
}
usedSegments = opts.Segments
if !(dur > 0) {
return []float64{0}, segDur, 1
}
stepSize, offset := opts.stepSizeAndOffset(dur)
starts = make([]float64, 0, opts.Segments)
for i := 0; i < opts.Segments; i++ {
t := offset + float64(i)*stepSize
maxStart := math.Max(0, dur-0.05-segDur)
if t < 0 {
t = 0
}
if t > maxStart {
t = maxStart
}
if t < 0.05 {
t = 0.05
}
starts = append(starts, t)
}
return starts, segDur, usedSegments
}
func generateTeaserPreviewMP4WithProgress(ctx context.Context, srcPath, outPath string, opts TeaserPreviewOptions, onRatio func(r float64)) error {
opts.Audio = true
if opts.SegmentDuration <= 0 {
opts.SegmentDuration = 1
}
if opts.Segments <= 0 {
opts.Segments = defaultTeaserSegments
}
if opts.Width <= 0 {
opts.Width = 640
}
if opts.Preset == "" {
opts.Preset = "veryfast"
}
if opts.CRF <= 0 {
opts.CRF = 21
}
if opts.AudioBitrate == "" {
opts.AudioBitrate = "128k"
}
segDur := opts.SegmentDuration
if segDur < minSegmentDuration {
segDur = minSegmentDuration
}
dur, _ := durationSecondsCached(ctx, srcPath)
if dur > 0 && dur < segDur*float64(opts.Segments) {
opts.Segments = 1
segDur = dur
}
if !(dur > 0) {
if onRatio != nil {
onRatio(0)
}
err := generateTeaserChunkMP4(ctx, srcPath, outPath, 0, math.Min(8, segDur), opts)
if onRatio != nil {
onRatio(1)
}
return err
}
starts, segDurComputed, _ := computeTeaserStarts(dur, opts)
segDur = segDurComputed
expectedOutSec := float64(len(starts)) * segDur
tmp := strings.TrimSuffix(outPath, ".mp4") + ".part.mp4"
args := []string{"-y", "-nostats", "-progress", "pipe:1", "-hide_banner", "-loglevel", "error"}
for _, t := range starts {
args = append(args, ffmpegInputTol...)
args = append(args, "-ss", fmt.Sprintf("%.3f", t), "-t", fmt.Sprintf("%.3f", segDur), "-i", srcPath)
}
var fc strings.Builder
for i := range starts {
fmt.Fprintf(&fc, "[%d:v]scale=%d:-2,setsar=1,setpts=PTS-STARTPTS[v%d];", i, opts.Width, i)
fmt.Fprintf(&fc, "[%d:a]aresample=48000,aformat=channel_layouts=stereo,asetpts=PTS-STARTPTS[a%d];", i, i)
}
for i := range starts {
fmt.Fprintf(&fc, "[v%d][a%d]", i, i)
}
fmt.Fprintf(&fc, "concat=n=%d:v=1:a=1[v][a]", len(starts))
args = append(args, "-filter_complex", fc.String())
args = append(args, "-map", "[v]", "-map", "[a]")
args = append(args,
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
"-profile:v", "high",
"-level", "4.2",
"-preset", opts.Preset,
"-crf", strconv.Itoa(opts.CRF),
"-threads", "2",
)
if opts.UseVsync2 {
args = append(args, "-vsync", "2")
}
args = append(args,
"-c:a", "aac",
"-b:a", opts.AudioBitrate,
"-ac", "2",
"-shortest",
)
args = append(args, "-movflags", "+faststart", tmp)
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return err
}
sc := bufio.NewScanner(stdout)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var outSec float64
var lastSent float64
var lastAt time.Time
send := func(outSec float64, force bool) {
if onRatio == nil {
return
}
if expectedOutSec > 0 && outSec > 0 {
r := outSec / expectedOutSec
if r < 0 {
r = 0
}
if r > 1 {
r = 1
}
if r-lastSent < 0.01 && !force {
return
}
if !lastAt.IsZero() && time.Since(lastAt) < 150*time.Millisecond && !force {
return
}
lastSent = r
lastAt = time.Now()
onRatio(r)
return
}
if force {
onRatio(1)
}
}
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
k, v := parts[0], parts[1]
switch k {
case "out_time_ms":
if n, perr := strconv.ParseInt(strings.TrimSpace(v), 10, 64); perr == nil && n > 0 {
outSec = float64(n) / 1_000_000.0
send(outSec, false)
}
case "out_time":
if s := parseFFmpegOutTime(v); s > 0 {
outSec = s
send(outSec, false)
}
case "progress":
if strings.TrimSpace(v) == "end" {
send(outSec, true)
}
}
}
if err := cmd.Wait(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("ffmpeg teaser preview failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
}
_ = os.Remove(outPath)
return os.Rename(tmp, outPath)
}
func serveTeaserFile(w http.ResponseWriter, r *http.Request, path string) {
f, err := openForReadShareDelete(path)
if err != nil {
http.Error(w, "datei öffnen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
defer f.Close()
fi, err := f.Stat()
if err != nil || fi.IsDir() || fi.Size() == 0 {
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
return
}
w.Header().Set("Cache-Control", "public, max-age=31536000")
w.Header().Set("Content-Type", "video/mp4")
http.ServeContent(w, r, filepath.Base(path), fi.ModTime(), f)
}
// tolerante Input-Flags für kaputte/abgeschnittene H264/TS Streams
var ffmpegInputTol = []string{
"-fflags", "+discardcorrupt+genpts",
"-err_detect", "ignore_err",
"-max_error_rate", "1.0",
}
func generateTeaserMP4(ctx context.Context, srcPath, outPath string, startSec, durSec float64) error {
if durSec <= 0 {
durSec = 8
}
if startSec < 0 {
startSec = 0
}
// temp schreiben -> rename
tmp := outPath + ".tmp.mp4"
args := []string{
"-y",
"-hide_banner",
"-loglevel", "error",
}
args = append(args, ffmpegInputTol...)
args = append(args,
"-ss", fmt.Sprintf("%.3f", startSec),
"-i", srcPath,
"-t", fmt.Sprintf("%.3f", durSec),
// Video
"-vf", "scale=720:-2",
"-map", "0:v:0",
// Audio (optional: falls kein Audio vorhanden ist, bricht ffmpeg NICHT ab)
"-map", "0:a:0",
"-c:a", "aac",
"-b:a", "128k",
"-ac", "2",
"-c:v", "libx264",
"-preset", "veryfast",
"-threads", "2",
"-crf", "28",
"-pix_fmt", "yuv420p",
// Wenn Audio minimal kürzer/länger ist, sauber beenden
"-shortest",
"-movflags", "+faststart",
"-f", "mp4",
"-threads", "2",
tmp,
)
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
if out, err := cmd.CombinedOutput(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("ffmpeg teaser failed: %v (%s)", err, strings.TrimSpace(string(out)))
}
_ = os.Remove(outPath)
return os.Rename(tmp, outPath)
}
func generatedTeaser(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
http.Error(w, "id fehlt", http.StatusBadRequest)
return
}
var err error
id, err = sanitizeID(id)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
outPath, err := findFinishedFileByID(id)
if err != nil {
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
return
}
if err := ensureGeneratedDirs(); err != nil {
http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError)
return
}
assetID := stripHotPrefix(id)
if assetID == "" {
assetID = id
}
assetDir, err := ensureGeneratedDir(assetID)
if err != nil {
http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError)
return
}
previewPath := filepath.Join(assetDir, "preview.mp4")
// ✅ NEU: noGenerate=1 -> niemals on-the-fly erzeugen, nur liefern wenn vorhanden
qNoGen := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("noGenerate")))
noGen := qNoGen == "1" || qNoGen == "true" || qNoGen == "yes"
// Cache hit (neu)
if fi, err := os.Stat(previewPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
serveTeaserFile(w, r, previewPath)
return
}
// Legacy: generated/teaser/<id>_teaser.mp4 oder <id>.mp4
if teaserLegacy, _ := generatedTeaserRoot(); strings.TrimSpace(teaserLegacy) != "" {
cids := []string{assetID, id}
for _, cid := range cids {
candidates := []string{
filepath.Join(teaserLegacy, cid+"_teaser.mp4"),
filepath.Join(teaserLegacy, cid+".mp4"),
}
for _, c := range candidates {
if fi, err := os.Stat(c); err == nil && !fi.IsDir() && fi.Size() > 0 {
if _, err2 := os.Stat(previewPath); os.IsNotExist(err2) {
_ = os.MkdirAll(filepath.Dir(previewPath), 0o755)
_ = os.Rename(c, previewPath)
}
if fi2, err2 := os.Stat(previewPath); err2 == nil && !fi2.IsDir() && fi2.Size() > 0 {
serveTeaserFile(w, r, previewPath)
return
}
serveTeaserFile(w, r, c)
return
}
}
}
}
// ✅ NEU: wenn noGenerate aktiv und bisher kein Teaser gefunden -> 404
if noGen {
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
return
}
// Neu erzeugen
if err := genSem.Acquire(r.Context()); err != nil {
http.Error(w, "abgebrochen: "+err.Error(), http.StatusRequestTimeout)
return
}
defer genSem.Release()
genCtx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
if err := generateTeaserClipsMP4(genCtx, outPath, previewPath, 1.0, 18); err != nil {
// Fallback: einzelner kurzer Teaser ab Anfang (trifft seltener kaputte Stellen)
if err2 := generateTeaserMP4(genCtx, outPath, previewPath, 0, 8); err2 != nil {
http.Error(w, "konnte preview nicht erzeugen: "+err.Error()+" (fallback ebenfalls fehlgeschlagen: "+err2.Error()+")", http.StatusInternalServerError)
return
}
}
serveTeaserFile(w, r, previewPath)
}

View File

@ -248,8 +248,8 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) {
continue continue
} }
// laufende/queued Nacharbeit abbrechen // laufende/queued Nacharbeit abbrechen + Datei freigeben
cancelDeferredEnrichForFile(file) releaseFileForMutation(file)
target, from, fi, err := resolveDoneFileByName(doneAbs, file) target, from, fi, err := resolveDoneFileByName(doneAbs, file)
if err != nil { if err != nil {
@ -391,7 +391,7 @@ func handleKeepMany(w http.ResponseWriter, r *http.Request) {
continue continue
} }
cancelDeferredEnrichForFile(file) releaseFileForMutation(file)
src, from, fi, err := resolveDoneFileByName(doneAbs, file) src, from, fi, err := resolveDoneFileByName(doneAbs, file)
if err != nil { if err != nil {
@ -932,9 +932,7 @@ func applyPreviewSpriteTruthToRecordJobMeta(j *RecordJob) {
if outPath == "" { if outPath == "" {
return return
} }
base := filepath.Base(outPath) id := canonicalAssetIDFromName(outPath)
id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base)))
id = strings.TrimSpace(id)
if id == "" { if id == "" {
return return
} }
@ -1009,8 +1007,7 @@ func applyFinishedPhaseTruthToRecordJobMeta(j *RecordJob) {
return return
} }
base := filepath.Base(outPath) id := canonicalAssetIDFromName(outPath)
id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base)))
id = strings.TrimSpace(id) id = strings.TrimSpace(id)
if id == "" { if id == "" {
return return
@ -1348,8 +1345,7 @@ func recordRemoveQueuedPostwork(w http.ResponseWriter, r *http.Request) {
_ = removeWithRetry(out) _ = removeWithRetry(out)
purgeDurationCacheForPath(out) purgeDurationCacheForPath(out)
base := filepath.Base(out) id1 := canonicalAssetIDFromName(out)
id1 := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base)))
if strings.TrimSpace(id1) != "" { if strings.TrimSpace(id1) != "" {
removeGeneratedForID(id1) removeGeneratedForID(id1)
} }
@ -1409,22 +1405,8 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
// ---- TS -> MP4 (on-demand remux) ---- // ---- TS -> MP4 (on-demand remux) ----
if strings.ToLower(filepath.Ext(outPath)) == ".ts" { if strings.ToLower(filepath.Ext(outPath)) == ".ts" {
newOut, err := maybeRemuxTS(outPath) writeErr(http.StatusConflict, "TS-Datei ist noch nicht nachbearbeitet und kann nicht direkt abgespielt werden")
if err != nil { return
writeErr(http.StatusInternalServerError, "TS Remux fehlgeschlagen: "+err.Error())
return
}
if strings.TrimSpace(newOut) == "" {
writeErr(http.StatusInternalServerError, "TS kann im Browser nicht abgespielt werden; Remux hat keine MP4 erzeugt")
return
}
outPath = filepath.Clean(strings.TrimSpace(newOut))
fi, err := os.Stat(outPath)
if err != nil || fi == nil || fi.IsDir() || fi.Size() == 0 || strings.ToLower(filepath.Ext(outPath)) != ".mp4" {
writeErr(http.StatusInternalServerError, "Remux-Ergebnis ungültig")
return
}
} }
// ✅ Falls Datei ".mp4" heißt, aber eigentlich TS/HTML ist -> nicht als MP4 ausliefern // ✅ Falls Datei ".mp4" heißt, aber eigentlich TS/HTML ist -> nicht als MP4 ausliefern
@ -1432,12 +1414,8 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
kind, _ := sniffVideoKind(outPath) kind, _ := sniffVideoKind(outPath)
switch kind { switch kind {
case "ts": case "ts":
newOut, err := maybeRemuxTS(outPath) writeErr(http.StatusConflict, "Datei hat .mp4-Endung, enthält aber TS-Daten und wird nicht on-demand remuxt")
if err != nil { return
writeErr(http.StatusInternalServerError, "Datei ist TS (nur .mp4 benannt); Remux fehlgeschlagen: "+err.Error())
return
}
outPath = filepath.Clean(strings.TrimSpace(newOut))
case "html": case "html":
writeErr(http.StatusInternalServerError, "Server liefert HTML statt Video (Pfad/Lookup prüfen)") writeErr(http.StatusInternalServerError, "Server liefert HTML statt Video (Pfad/Lookup prüfen)")
return return
@ -1592,6 +1570,30 @@ func normalizeQueryModel(raw string) string {
return strings.ToLower(strings.TrimSpace(s)) return strings.ToLower(strings.TrimSpace(s))
} }
func isTemporaryDoneVideoName(name string) bool {
n := strings.ToLower(strings.TrimSpace(filepath.Base(name)))
if n == "" {
return false
}
// versteckte / temporäre Dateien wie:
// ".chaesoffel_04_22_2026__12-22-16.tmp.20260422_134354_000.mp4"
if strings.HasPrefix(n, ".") {
return true
}
// typische temporäre Marker
if strings.Contains(n, ".tmp.") ||
strings.HasSuffix(n, ".tmp.mp4") ||
strings.HasSuffix(n, ".tmp.ts") ||
strings.Contains(n, ".partial.") ||
strings.Contains(n, ".part.") {
return true
}
return false
}
// buildDoneIndex: identical logic as your previous record_handlers.go (indexing done + keep) // buildDoneIndex: identical logic as your previous record_handlers.go (indexing done + keep)
func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) { func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
items := make([]doneIndexItem, 0, 2048) items := make([]doneIndexItem, 0, 2048)
@ -1611,6 +1613,10 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
} }
name := filepath.Base(full) name := filepath.Base(full)
if isTemporaryDoneVideoName(name) {
return
}
ext := strings.ToLower(filepath.Ext(name)) ext := strings.ToLower(filepath.Ext(name))
if ext != ".mp4" && ext != ".ts" { if ext != ".mp4" && ext != ".ts" {
return return
@ -1625,7 +1631,17 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
start := t start := t
base := strings.TrimSuffix(name, filepath.Ext(name)) base := strings.TrimSuffix(name, filepath.Ext(name))
stem := strings.TrimPrefix(base, "HOT ")
// WICHTIG:
// stabile ID ohne HOT-Prefix, damit Rename HOT <-> non-HOT
// im Frontend nicht zu einem neuen React-Key führt.
stableID := stripHotPrefix(base)
if strings.TrimSpace(stableID) == "" {
stableID = base
}
// Timestamp immer auf Basis des HOT-bereinigten Namens parsen
stem := stableID
if m := startedAtFromFilenameRe.FindStringSubmatch(stem); m != nil { if m := startedAtFromFilenameRe.FindStringSubmatch(stem); m != nil {
mm, _ := strconv.Atoi(m[2]) mm, _ := strconv.Atoi(m[2])
dd, _ := strconv.Atoi(m[3]) dd, _ := strconv.Atoi(m[3])
@ -1671,7 +1687,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
ended := t ended := t
items = append(items, doneIndexItem{ items = append(items, doneIndexItem{
job: &RecordJob{ job: &RecordJob{
ID: base, ID: stableID,
Output: full, Output: full,
SourceURL: srcURL, SourceURL: srcURL,
Status: JobFinished, Status: JobFinished,
@ -1692,9 +1708,10 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
if err != nil { if err != nil {
return return
} }
for _, e := range entries { for _, e := range entries {
if e.IsDir() { if e.IsDir() {
if strings.EqualFold(e.Name(), ".trash") { if strings.EqualFold(e.Name(), ".trash") || strings.EqualFold(e.Name(), ".postwork_tmp") {
continue continue
} }
if skipKeep && e.Name() == "keep" { if skipKeep && e.Name() == "keep" {
@ -1731,6 +1748,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
// done (ohne keep) // done (ohne keep)
scanDir(doneAbs, true) scanDir(doneAbs, true)
// keep // keep
scanDir(filepath.Join(doneAbs, "keep"), false) scanDir(filepath.Join(doneAbs, "keep"), false)
@ -1755,12 +1773,14 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
B := items[idx[b]] B := items[idx[b]]
ta, tb := A.endedAt, B.endedAt ta, tb := A.endedAt, B.endedAt
switch sortMode { switch sortMode {
case "completed_asc": case "completed_asc":
if !ta.Equal(tb) { if !ta.Equal(tb) {
return ta.Before(tb) return ta.Before(tb)
} }
return A.fileSort < B.fileSort return A.fileSort < B.fileSort
case "completed_desc": case "completed_desc":
if !ta.Equal(tb) { if !ta.Equal(tb) {
return ta.After(tb) return ta.After(tb)
@ -1775,6 +1795,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
return ta.After(tb) return ta.After(tb)
} }
return A.fileSort < B.fileSort return A.fileSort < B.fileSort
case "file_desc": case "file_desc":
if A.fileSort != B.fileSort { if A.fileSort != B.fileSort {
return A.fileSort > B.fileSort return A.fileSort > B.fileSort
@ -1797,6 +1818,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
return ta.After(tb) return ta.After(tb)
} }
return A.fileSort < B.fileSort return A.fileSort < B.fileSort
case "duration_desc": case "duration_desc":
da, okA := durationForSort(A) da, okA := durationForSort(A)
db, okB := durationForSort(B) db, okB := durationForSort(B)
@ -1819,6 +1841,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
return ta.After(tb) return ta.After(tb)
} }
return A.fileSort < B.fileSort return A.fileSort < B.fileSort
case "size_desc": case "size_desc":
if A.job.SizeBytes != B.job.SizeBytes { if A.job.SizeBytes != B.job.SizeBytes {
return A.job.SizeBytes > B.job.SizeBytes return A.job.SizeBytes > B.job.SizeBytes
@ -1827,6 +1850,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
return ta.After(tb) return ta.After(tb)
} }
return A.fileSort < B.fileSort return A.fileSort < B.fileSort
default: default:
if !ta.Equal(tb) { if !ta.Equal(tb) {
return ta.After(tb) return ta.After(tb)
@ -1844,6 +1868,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
"duration_asc", "duration_desc", "duration_asc", "duration_desc",
"size_asc", "size_desc", "size_asc", "size_desc",
} }
for _, m := range modes { for _, m := range modes {
sortedIdx["0|"+m] = mkSorted(false, m) sortedIdx["0|"+m] = mkSorted(false, m)
sortedIdx["1|"+m] = mkSorted(true, m) sortedIdx["1|"+m] = mkSorted(true, m)
@ -1914,20 +1939,12 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
} }
outPath := filepath.Clean(strings.TrimSpace(full)) outPath := filepath.Clean(strings.TrimSpace(full))
if strings.ToLower(filepath.Ext(outPath)) == ".ts" {
if newOut, rerr := maybeRemuxTS(outPath); rerr == nil && strings.TrimSpace(newOut) != "" {
outPath = filepath.Clean(strings.TrimSpace(newOut))
if nfi, serr := os.Stat(outPath); serr == nil && nfi != nil {
fi = nfi
}
}
}
ensureMetaJSONForPlayback(r.Context(), outPath) ensureMetaJSONForPlayback(r.Context(), outPath)
resp := doneMetaFileResp{File: filepath.Base(outPath)} resp := doneMetaFileResp{File: filepath.Base(outPath)}
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(outPath), filepath.Ext(outPath))) id := canonicalAssetIDFromName(outPath)
if strings.TrimSpace(id) != "" { if strings.TrimSpace(id) != "" {
if mp, merr := generatedMetaFile(id); merr == nil && strings.TrimSpace(mp) != "" { if mp, merr := generatedMetaFile(id); merr == nil && strings.TrimSpace(mp) != "" {
@ -2362,7 +2379,7 @@ func cancelDeferredEnrichForFile(file string) {
return return
} }
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))) id := canonicalAssetIDFromName(file)
id = strings.TrimSpace(id) id = strings.TrimSpace(id)
if id == "" { if id == "" {
return return
@ -2412,6 +2429,28 @@ func cancelDeferredEnrichForFile(file string) {
} }
} }
func releaseFileForMutation(file string) {
file = strings.TrimSpace(file)
if file == "" {
return
}
assetsReleased := requestAssetsTaskSkip(file)
// Enrich für genau diese Datei stoppen / aus Queue nehmen
cancelDeferredEnrichForFile(file)
// WICHTIG:
// releaseFileTasks(file) darf NICHT mehr den globalen generate-assets Task abbrechen.
// Falls das dort aktuell passiert: rausnehmen.
resp := releaseFileTasks(file)
// Kurz warten, damit Handles der betroffenen Datei schließen
if assetsReleased || resp.ReleasedAny {
time.Sleep(250 * time.Millisecond)
}
}
func recordDeleteVideo(w http.ResponseWriter, r *http.Request) { func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost && r.Method != http.MethodDelete { if r.Method != http.MethodPost && r.Method != http.MethodDelete {
http.Error(w, "Nur POST oder DELETE erlaubt", http.StatusMethodNotAllowed) http.Error(w, "Nur POST oder DELETE erlaubt", http.StatusMethodNotAllowed)
@ -2428,8 +2467,7 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
// ✅ laufende/queued späte Nacharbeiten für dieses Asset vorher stoppen releaseFileForMutation(file)
cancelDeferredEnrichForFile(file)
s := getSettings() s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir) doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
@ -2698,8 +2736,7 @@ func recordUnkeepVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
// ✅ späte Nacharbeiten abbrechen, bevor die Datei aus keep zurück verschoben wird releaseFileForMutation(file)
cancelDeferredEnrichForFile(file)
s := getSettings() s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir) doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
@ -2771,8 +2808,7 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
// ✅ späte Nacharbeiten abbrechen, bevor die Datei verschoben wird releaseFileForMutation(file)
cancelDeferredEnrichForFile(file)
s := getSettings() s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir) doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
@ -2880,7 +2916,7 @@ func recordToggleHot(w http.ResponseWriter, r *http.Request) {
return return
} }
cancelDeferredEnrichForFile(file) releaseFileForMutation(file)
s := getSettings() s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir) doneAbs, err := resolvePathRelativeToApp(s.DoneDir)

View File

@ -9,6 +9,8 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -95,9 +97,32 @@ func RecordStream(
const maxPlaylistRefreshes = 12 const maxPlaylistRefreshes = 12
refreshes := 0 refreshes := 0
attempt := 0
useChunkMerge := strings.EqualFold(filepath.Ext(outputPath), ".ts")
for { for {
err = handleM3U8Mode(ctx, stream, outputPath, job, httpCookie, hc.userAgent, pageURL) attempt++
attemptOut := outputPath
mergeAfterRun := false
// Nur ab dem ZWEITEN Versuch in Retry-Chunk schreiben.
// Der erste Versuch muss direkt in outputPath schreiben,
// damit der laufende Download eine echte Hauptdatei hat.
if useChunkMerge && attempt > 1 {
attemptOut = hlsRetryChunkPath(outputPath, attempt)
_ = os.Remove(attemptOut)
mergeAfterRun = true
}
err = handleM3U8Mode(ctx, stream, attemptOut, job, httpCookie, hc.userAgent, pageURL)
if mergeAfterRun && fileExistsNonEmpty(attemptOut) {
if aerr := appendFileAndRemove(outputPath, attemptOut); aerr != nil {
return fmt.Errorf("retry-chunk merge failed: %w", aerr)
}
}
if err == nil { if err == nil {
return nil return nil
} }
@ -133,7 +158,6 @@ func RecordStream(
newStream, rerr := loadFreshHLS() newStream, rerr := loadFreshHLS()
if rerr != nil { if rerr != nil {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()

View File

@ -15,6 +15,7 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"syscall"
"time" "time"
"github.com/grafov/m3u8" "github.com/grafov/m3u8"
@ -334,6 +335,62 @@ func buildFFmpegInputHeaders(httpCookie, userAgent, referer string) string {
return strings.Join(lines, "\r\n") + "\r\n" return strings.Join(lines, "\r\n") + "\r\n"
} }
func hlsRetryChunkPath(outFile string, attempt int) string {
dir := filepath.Dir(outFile)
ext := filepath.Ext(outFile)
base := strings.TrimSuffix(filepath.Base(outFile), ext)
canonical := canonicalAssetIDFromName(base)
if canonical == "" {
canonical = base
}
ts := time.Now().Format("20060102_150405_000")
return filepath.Join(dir, fmt.Sprintf(".%s.retry.%02d.%s%s", canonical, attempt, ts, ext))
}
func appendFileAndRemove(dstPath, srcPath string) error {
srcPath = strings.TrimSpace(srcPath)
dstPath = strings.TrimSpace(dstPath)
if srcPath == "" || dstPath == "" {
return fmt.Errorf("append path leer")
}
if filepath.Clean(srcPath) == filepath.Clean(dstPath) {
return nil
}
sfi, err := os.Stat(srcPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if sfi == nil || sfi.IsDir() || sfi.Size() <= 0 {
_ = os.Remove(srcPath)
return nil
}
src, err := os.Open(srcPath)
if err != nil {
return err
}
defer src.Close()
dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return err
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
return err
}
return os.Remove(srcPath)
}
func handleM3U8Mode( func handleM3U8Mode(
ctx context.Context, ctx context.Context,
stream *selectedHLSStream, stream *selectedHLSStream,
@ -439,6 +496,10 @@ func handleM3U8Mode(
} }
cmd := exec.CommandContext(ctx, ffmpegPath, args...) cmd := exec.CommandContext(ctx, ffmpegPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var stderr bytes.Buffer var stderr bytes.Buffer
cmd.Stdout = io.Discard cmd.Stdout = io.Discard

View File

@ -532,6 +532,27 @@ func requireNonEmptyRegularFile(path string, label string) error {
return nil return nil
} }
func buildPostworkTempMP4Path(tsPath string) (string, error) {
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
return "", fmt.Errorf("doneDir auflösung fehlgeschlagen: %w", err)
}
doneAbs = strings.TrimSpace(doneAbs)
if doneAbs == "" {
return "", fmt.Errorf("doneDir ist leer")
}
tmpDir := filepath.Join(doneAbs, ".postwork_tmp")
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
return "", fmt.Errorf("postwork tmp dir erstellen fehlgeschlagen: %w", err)
}
base := strings.TrimSuffix(filepath.Base(strings.TrimSpace(tsPath)), filepath.Ext(tsPath)) + ".mp4"
return uniqueDestPath(tmpDir, base)
}
func runPrimaryPostworkPipeline( func runPrimaryPostworkPipeline(
ctx context.Context, ctx context.Context,
job *RecordJob, job *RecordJob,
@ -543,6 +564,9 @@ func runPrimaryPostworkPipeline(
return out, err return out, err
} }
cleanupTSAfterMove := ""
removeTempOutOnMoveFailure := false
// 1) TS -> MP4 zuerst // 1) TS -> MP4 zuerst
if strings.EqualFold(filepath.Ext(out), ".ts") { if strings.EqualFold(filepath.Ext(out), ".ts") {
tsPath := out tsPath := out
@ -557,8 +581,12 @@ func runPrimaryPostworkPipeline(
} }
inSize := fi.Size() inSize := fi.Size()
mp4Path := strings.TrimSuffix(tsPath, filepath.Ext(tsPath)) + ".mp4"
_ = os.Remove(mp4Path) mp4Path, err := buildPostworkTempMP4Path(tsPath)
if err != nil {
return tsPath, fmt.Errorf("postwork temp mp4 path failed: %w", err)
}
_ = removeWithRetry(mp4Path)
dur := 0.0 dur := 0.0
dctx, cancel := context.WithTimeout(ctx, 6*time.Second) dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
@ -587,6 +615,7 @@ func runPrimaryPostworkPipeline(
}, },
) )
if err != nil { if err != nil {
_ = removeWithRetry(mp4Path)
return tsPath, fmt.Errorf("ts remux failed: %w", err) return tsPath, fmt.Errorf("ts remux failed: %w", err)
} }
@ -595,20 +624,31 @@ func runPrimaryPostworkPipeline(
return tsPath, err return tsPath, err
} }
// best effort cleanup der alten TS if err := validateVideoDecodesNearEnd(ctx, mp4Path); err != nil {
if err := removeWithRetry(tsPath); err != nil && !os.IsNotExist(err) { fmt.Println("⚠️ remux mp4 decode validation failed, fallback to reencode:", err)
fmt.Println("⚠️ ts cleanup after remux:", err)
}
purgeDurationCacheForPath(tsPath)
_ = removeWithRetry(mp4Path)
if rerr := reencodeTSToMP4(ctx, tsPath, mp4Path); rerr != nil {
_ = removeWithRetry(mp4Path)
return tsPath, fmt.Errorf("remux invalid (%v), reencode fallback failed: %w", err, rerr)
}
if err2 := requireNonEmptyRegularFile(mp4Path, "reencode result"); err2 != nil {
_ = removeWithRetry(mp4Path)
return tsPath, err2
}
if err2 := validateVideoDecodesNearEnd(ctx, mp4Path); err2 != nil {
_ = removeWithRetry(mp4Path)
return tsPath, fmt.Errorf("reencoded mp4 still invalid: %w", err2)
}
}
// TS erst NACH erfolgreichem Move löschen
cleanupTSAfterMove = tsPath
removeTempOutOnMoveFailure = true
out = mp4Path out = mp4Path
if fi2, err := os.Stat(out); err == nil && fi2 != nil && !fi2.IsDir() {
jobsMu.Lock()
job.Output = out
job.SizeBytes = fi2.Size()
jobsMu.Unlock()
}
} }
// 2) Danach nach done verschieben // 2) Danach nach done verschieben
@ -616,20 +656,42 @@ func runPrimaryPostworkPipeline(
setPhase("moving", 0) setPhase("moving", 0)
} }
fallbackOut := out
if cleanupTSAfterMove != "" {
fallbackOut = cleanupTSAfterMove
}
moved, err := moveToDoneDir(out) moved, err := moveToDoneDir(out)
if err != nil || strings.TrimSpace(moved) == "" { if err != nil || strings.TrimSpace(moved) == "" {
if err != nil { if removeTempOutOnMoveFailure {
return out, fmt.Errorf("moveToDoneDir failed: %w", err) _ = removeWithRetry(out)
purgeDurationCacheForPath(out)
} }
return out, fmt.Errorf("moveToDoneDir returned empty path")
if err != nil {
return fallbackOut, fmt.Errorf("moveToDoneDir failed: %w", err)
}
return fallbackOut, fmt.Errorf("moveToDoneDir returned empty path")
} }
out = strings.TrimSpace(moved) out = strings.TrimSpace(moved)
if err := requireNonEmptyRegularFile(out, "moved output"); err != nil { if err := requireNonEmptyRegularFile(out, "moved output"); err != nil {
if cleanupTSAfterMove != "" {
_ = removeWithRetry(out)
purgeDurationCacheForPath(out)
return cleanupTSAfterMove, err
}
return out, err return out, err
} }
if cleanupTSAfterMove != "" {
if err := removeWithRetry(cleanupTSAfterMove); err != nil && !os.IsNotExist(err) {
fmt.Println("⚠️ ts cleanup after successful move:", err)
}
purgeDurationCacheForPath(cleanupTSAfterMove)
}
jobsMu.Lock() jobsMu.Lock()
job.Output = out job.Output = out
if fi, err := os.Stat(out); err == nil && fi != nil && !fi.IsDir() { if fi, err := os.Stat(out); err == nil && fi != nil && !fi.IsDir() {
@ -1209,7 +1271,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
return nil return nil
} }
truth := finishedPhaseTruthForID(assetID) truth := assetsTruthForVideo(outPath)
// ------------------------------------------------- // -------------------------------------------------
// Fehlende Vorstufen nur nachziehen, NICHT alles neu // Fehlende Vorstufen nur nachziehen, NICHT alles neu
@ -1222,7 +1284,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
if cerr := abortIfCanceled(err, "postwork", "meta"); cerr != nil { if cerr := abortIfCanceled(err, "postwork", "meta"); cerr != nil {
return cerr return cerr
} }
if err != nil || !okMeta || !finishedPhaseTruthForID(assetID).MetaReady { if err != nil || !okMeta || !assetsTruthForVideo(outPath).MetaReady {
if err == nil { if err == nil {
err = fmt.Errorf("meta konnte nicht erzeugt werden") err = fmt.Errorf("meta konnte nicht erzeugt werden")
} }
@ -1231,7 +1293,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
} }
publishDeferredPhase(fileName, assetID, "postwork", "meta", "done", "Meta", nil) publishDeferredPhase(fileName, assetID, "postwork", "meta", "done", "Meta", nil)
truth = finishedPhaseTruthForID(assetID) truth = assetsTruthForVideo(outPath)
} }
if !truth.ThumbReady { if !truth.ThumbReady {
@ -1241,7 +1303,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
if cerr := abortIfCanceled(err, "postwork", "thumb"); cerr != nil { if cerr := abortIfCanceled(err, "postwork", "thumb"); cerr != nil {
return cerr return cerr
} }
if err != nil || !finishedPhaseTruthForID(assetID).ThumbReady { if err != nil || !assetsTruthForVideo(outPath).ThumbReady {
if err == nil { if err == nil {
err = fmt.Errorf("vorschaubild konnte nicht erzeugt werden") err = fmt.Errorf("vorschaubild konnte nicht erzeugt werden")
} }
@ -1250,7 +1312,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
} }
publishDeferredPhase(fileName, assetID, "postwork", "thumb", "done", "Vorschaubild", nil) publishDeferredPhase(fileName, assetID, "postwork", "thumb", "done", "Vorschaubild", nil)
truth = finishedPhaseTruthForID(assetID) truth = assetsTruthForVideo(outPath)
} }
if !truth.TeaserReady { if !truth.TeaserReady {
@ -1260,7 +1322,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
if cerr := abortIfCanceled(err, "postwork", "teaser"); cerr != nil { if cerr := abortIfCanceled(err, "postwork", "teaser"); cerr != nil {
return cerr return cerr
} }
if err != nil || !finishedPhaseTruthForID(assetID).TeaserReady { if err != nil || !assetsTruthForVideo(outPath).TeaserReady {
if err == nil { if err == nil {
err = fmt.Errorf("teaser konnte nicht erzeugt werden") err = fmt.Errorf("teaser konnte nicht erzeugt werden")
} }
@ -1269,7 +1331,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
} }
publishDeferredPhase(fileName, assetID, "postwork", "teaser", "done", "Teaser", nil) publishDeferredPhase(fileName, assetID, "postwork", "teaser", "done", "Teaser", nil)
truth = finishedPhaseTruthForID(assetID) truth = assetsTruthForVideo(outPath)
} }
// Ab hier erst die langsamen Hintergrundphasen // Ab hier erst die langsamen Hintergrundphasen
@ -1282,7 +1344,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
if cerr := abortIfCanceled(err, "postwork", "sprites"); cerr != nil { if cerr := abortIfCanceled(err, "postwork", "sprites"); cerr != nil {
return cerr return cerr
} }
if err != nil || !finishedPhaseTruthForID(assetID).SpritesReady { if err != nil || !assetsTruthForVideo(outPath).SpritesReady {
if err == nil { if err == nil {
err = fmt.Errorf("sprites konnten nicht erzeugt werden") err = fmt.Errorf("sprites konnten nicht erzeugt werden")
} }
@ -1291,29 +1353,33 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
firstErr = err firstErr = err
} }
} else { } else {
if _, checkErr := runTailBlackoutCheck(ctx, outPath); checkErr != nil {
fmt.Println("⚠️ sprite tail blackout check failed:", outPath, checkErr)
}
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "done", "Sprites", nil) publishDeferredPhase(fileName, assetID, "postwork", "sprites", "done", "Sprites", nil)
} }
} }
truth = finishedPhaseTruthForID(assetID) truth = assetsTruthForVideo(outPath)
if !truth.AnalyzeReady { if !truth.AnalyzeReady {
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "running", "KI-Analyse", nil) publishDeferredPhase(fileName, assetID, "enrich", "analyze", "running", "Analyse", nil)
_, err := ensureAnalyzeForVideoCtx(ctx, outPath, sourceURL, "nsfw") _, err := ensureAnalyzeForVideoCtx(ctx, outPath, sourceURL, "nsfw")
if cerr := abortIfCanceled(err, "enrich", "analyze"); cerr != nil { if cerr := abortIfCanceled(err, "enrich", "analyze"); cerr != nil {
return cerr return cerr
} }
if err != nil || !finishedPhaseTruthForID(assetID).AnalyzeReady { if err != nil || !assetsTruthForVideo(outPath).AnalyzeReady {
if err == nil { if err == nil {
err = fmt.Errorf("ki-analyse konnte nicht erzeugt werden") err = fmt.Errorf("Analyse konnte nicht erzeugt werden")
} }
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen", err) publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "Analyse fehlgeschlagen", err)
if firstErr == nil { if firstErr == nil {
firstErr = err firstErr = err
} }
} else { } else {
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "done", "KI-Analyse", nil) publishDeferredPhase(fileName, assetID, "enrich", "analyze", "done", "Analyse", nil)
} }
} }

View File

@ -16,5 +16,10 @@
"teaserPlayback": "all", "teaserPlayback": "all",
"teaserAudio": true, "teaserAudio": true,
"enableNotifications": true, "enableNotifications": true,
"generateAssetsMeta": true,
"generateAssetsThumb": true,
"generateAssetsTeaser": true,
"generateAssetsSprites": false,
"generateAssetsAnalyze": false,
"encryptedCookies": "FEPj/igIPaDy8xr709QjHKOqXWVGesegmwzhTP1Whnusetrl1WenN7t0V1Rm/lw2nLkyis78kz+4yjsDtVgO3MXLU4Uq85AhAJQOE7SMVD+YMNenCS8Qr0JLkmI6cr/kR6sKrx7Jm6x6DY1BolFFMX1Ii3EO+8b4Re2GYaFi1iuh97oI8Zg4r5sMYmt7FIMRkr1uy0u0ToH8hsO0iDoExcxubQNHIZTQ07kkWP70Up78tYs59INxzosijATbiVhOT1H0YwS23iWC3bwLgeo2lOzMejJWpEG16CN55CoEAymFd2ktlQZ4Lmv0FlnBJ48H27Cj0TKR1yRbBxpGjgch9x0421C5qYGeXNB9jgczEKh28oYdt8ljXejoDsV7/oKYNaAFfIlICsC/Zz97ZpPRrKiRTSTkTLmIXjnWtVdwGRfCvJrYjJpoe3ZOaubulqCQs6Ug501b6JTzdZujKAl68tL5stZD7hH9XGQSpquZWGeSUGOrI5jU8dULbxCLpkGtiOEmxGb93OLYdHQ8JJg6zHeExVtPSqANHoKX3NpFc33fqOX1vOQVeg8Ei4ymxntIMOwQWs8xnbuKXmnNx6GTBSqHiD7X/a8oeEkMSw1+bCODvwuXlNBcdxHlf3/wkvHb9lhnh231BD1+Ufrnij65Apko8A==" "encryptedCookies": "FEPj/igIPaDy8xr709QjHKOqXWVGesegmwzhTP1Whnusetrl1WenN7t0V1Rm/lw2nLkyis78kz+4yjsDtVgO3MXLU4Uq85AhAJQOE7SMVD+YMNenCS8Qr0JLkmI6cr/kR6sKrx7Jm6x6DY1BolFFMX1Ii3EO+8b4Re2GYaFi1iuh97oI8Zg4r5sMYmt7FIMRkr1uy0u0ToH8hsO0iDoExcxubQNHIZTQ07kkWP70Up78tYs59INxzosijATbiVhOT1H0YwS23iWC3bwLgeo2lOzMejJWpEG16CN55CoEAymFd2ktlQZ4Lmv0FlnBJ48H27Cj0TKR1yRbBxpGjgch9x0421C5qYGeXNB9jgczEKh28oYdt8ljXejoDsV7/oKYNaAFfIlICsC/Zz97ZpPRrKiRTSTkTLmIXjnWtVdwGRfCvJrYjJpoe3ZOaubulqCQs6Ug501b6JTzdZujKAl68tL5stZD7hH9XGQSpquZWGeSUGOrI5jU8dULbxCLpkGtiOEmxGb93OLYdHQ8JJg6zHeExVtPSqANHoKX3NpFc33fqOX1vOQVeg8Ei4ymxntIMOwQWs8xnbuKXmnNx6GTBSqHiD7X/a8oeEkMSw1+bCODvwuXlNBcdxHlf3/wkvHb9lhnh231BD1+Ufrnij65Apko8A=="
} }

View File

@ -81,6 +81,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
// Tasks // Tasks
api.HandleFunc("/api/tasks/status", tasksStatusHandler) api.HandleFunc("/api/tasks/status", tasksStatusHandler)
api.HandleFunc("/api/tasks/generate-assets", tasksGenerateAssets) api.HandleFunc("/api/tasks/generate-assets", tasksGenerateAssets)
api.HandleFunc("/api/tasks/check-videos", checkVideosTaskHandler)
// -------------------------- // --------------------------
// 3) ModelStore (Postgres) // 3) ModelStore (Postgres)

View File

@ -4,21 +4,103 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"net"
"net/http" "net/http"
"os" "os"
"os/exec"
"os/signal" "os/signal"
"runtime"
"strings"
"sync" "sync"
"syscall" "syscall"
"time" "time"
) )
func escapePowerShell(s string) string {
return strings.ReplaceAll(s, "'", "''")
}
func escapeAppleScript(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, `"`, `\"`)
return s
}
func showAppNotification(title, message string) error {
switch runtime.GOOS {
case "windows":
ps := fmt.Sprintf(`
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$n = New-Object System.Windows.Forms.NotifyIcon
$n.Icon = [System.Drawing.SystemIcons]::Information
$n.BalloonTipTitle = '%s'
$n.BalloonTipText = '%s'
$n.Visible = $true
$n.ShowBalloonTip(3000)
Start-Sleep -Milliseconds 3500
$n.Dispose()
`, escapePowerShell(title), escapePowerShell(message))
cmd := exec.Command(
"powershell",
"-NoProfile",
"-NonInteractive",
"-WindowStyle", "Hidden",
"-ExecutionPolicy", "Bypass",
"-Command", ps,
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
return cmd.Run()
case "darwin":
return exec.Command(
"osascript",
"-e",
fmt.Sprintf(
`display notification "%s" with title "%s"`,
escapeAppleScript(message),
escapeAppleScript(title),
),
).Run()
case "linux":
return exec.Command("notify-send", title, message).Run()
default:
return nil
}
}
func buildStartupNotificationMessage() string {
return "Die App läuft im Hintergrund."
}
// --- main --- // --- main ---
func main() { func main() {
loadSettings() loadSettings()
// Nur eine Instanz erlauben:
appLn, err := net.Listen("tcp", ":9999")
if err != nil {
_ = showAppNotification(
"Recorder",
"Die Anwendung wird bereits ausgeführt.",
)
fmt.Println("⚠️ Die App läuft bereits oder Port 9999 ist bereits belegt.")
os.Exit(0)
}
defer appLn.Close()
fixKeepRootFilesIntoModelSubdirs() fixKeepRootFilesIntoModelSubdirs()
postWorkQ.StartWorkers(2) postWorkQ.StartWorkers(1)
enrichQ.StartWorkers(1) enrichQ.StartWorkers(1)
startPostWorkStatusRefresher() startPostWorkStatusRefresher()
startEnrichStatusRefresher() startEnrichStatusRefresher()
@ -86,25 +168,52 @@ func main() {
Handler: handler, Handler: handler,
} }
var shutdownOnce sync.Once
shutdown := func() {
shutdownOnce.Do(func() {
fmt.Println("🛑 Beende Server...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
closeNSFW()
})
}
stopSig := make(chan os.Signal, 1) stopSig := make(chan os.Signal, 1)
signal.Notify(stopSig, os.Interrupt, syscall.SIGTERM) signal.Notify(stopSig, os.Interrupt, syscall.SIGTERM)
go func() { go func() {
<-stopSig <-stopSig
fmt.Println("🛑 Beende Server...") shutdown()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
closeNSFW()
}() }()
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { serverErrCh := make(chan error, 1)
fmt.Println("❌ HTTP-Server Fehler:", err)
closeNSFW()
os.Exit(1)
}
closeNSFW() go func() {
if err := srv.Serve(appLn); err != nil && err != http.ErrServerClosed {
serverErrCh <- err
return
}
serverErrCh <- nil
}()
go func() {
_ = showAppNotification(
"Recorder gestartet",
buildStartupNotificationMessage(),
)
}()
go func() {
err := <-serverErrCh
if err != nil {
fmt.Println("❌ HTTP-Server Fehler:", err)
shutdown()
}
}()
runTray(shutdown, buildTrayStats)
shutdown()
} }

View File

@ -39,6 +39,12 @@ type RecorderSettings struct {
EnableNotifications bool `json:"enableNotifications"` EnableNotifications bool `json:"enableNotifications"`
GenerateAssetsMeta bool `json:"generateAssetsMeta"`
GenerateAssetsThumb bool `json:"generateAssetsThumb"`
GenerateAssetsTeaser bool `json:"generateAssetsTeaser"`
GenerateAssetsSprites bool `json:"generateAssetsSprites"`
GenerateAssetsAnalyze bool `json:"generateAssetsAnalyze"`
// EncryptedCookies contains base64(nonce+ciphertext) of a JSON cookie map. // EncryptedCookies contains base64(nonce+ciphertext) of a JSON cookie map.
EncryptedCookies string `json:"encryptedCookies"` EncryptedCookies string `json:"encryptedCookies"`
} }
@ -69,6 +75,12 @@ var (
EnableNotifications: true, EnableNotifications: true,
GenerateAssetsMeta: true,
GenerateAssetsThumb: true,
GenerateAssetsTeaser: true,
GenerateAssetsSprites: false,
GenerateAssetsAnalyze: false,
EncryptedCookies: "", EncryptedCookies: "",
} }
settingsFile = "recorder_settings.json" settingsFile = "recorder_settings.json"
@ -223,6 +235,12 @@ type RecorderSettingsPublic struct {
TeaserAudio bool `json:"teaserAudio"` TeaserAudio bool `json:"teaserAudio"`
EnableNotifications bool `json:"enableNotifications"` EnableNotifications bool `json:"enableNotifications"`
GenerateAssetsMeta bool `json:"generateAssetsMeta"`
GenerateAssetsThumb bool `json:"generateAssetsThumb"`
GenerateAssetsTeaser bool `json:"generateAssetsTeaser"`
GenerateAssetsSprites bool `json:"generateAssetsSprites"`
GenerateAssetsAnalyze bool `json:"generateAssetsAnalyze"`
} }
func toPublicSettings(s RecorderSettings) RecorderSettingsPublic { func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
@ -250,6 +268,12 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
TeaserAudio: s.TeaserAudio, TeaserAudio: s.TeaserAudio,
EnableNotifications: s.EnableNotifications, EnableNotifications: s.EnableNotifications,
GenerateAssetsMeta: s.GenerateAssetsMeta,
GenerateAssetsThumb: s.GenerateAssetsThumb,
GenerateAssetsTeaser: s.GenerateAssetsTeaser,
GenerateAssetsSprites: s.GenerateAssetsSprites,
GenerateAssetsAnalyze: s.GenerateAssetsAnalyze,
} }
} }
@ -401,6 +425,12 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
next.EnableNotifications = in.EnableNotifications next.EnableNotifications = in.EnableNotifications
next.GenerateAssetsMeta = in.GenerateAssetsMeta
next.GenerateAssetsThumb = in.GenerateAssetsThumb
next.GenerateAssetsTeaser = in.GenerateAssetsTeaser
next.GenerateAssetsSprites = in.GenerateAssetsSprites
next.GenerateAssetsAnalyze = in.GenerateAssetsAnalyze
dbChanged := dbChanged :=
strings.TrimSpace(next.DatabaseURL) != strings.TrimSpace(current.DatabaseURL) || strings.TrimSpace(next.DatabaseURL) != strings.TrimSpace(current.DatabaseURL) ||
strings.TrimSpace(next.EncryptedDBPassword) != strings.TrimSpace(current.EncryptedDBPassword) strings.TrimSpace(next.EncryptedDBPassword) != strings.TrimSpace(current.EncryptedDBPassword)

View File

@ -3,6 +3,7 @@
package main package main
import ( import (
"bufio"
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
@ -14,16 +15,26 @@ import (
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"syscall"
"time" "time"
) )
type splitVideoSegmentIn struct {
Start float64 `json:"start"`
End float64 `json:"end"`
Label string `json:"label,omitempty"`
}
type splitVideoRequest struct { type splitVideoRequest struct {
File string `json:"file"` // z. B. "model_01_01_2026__12-00-00.mp4" File string `json:"file"` // z. B. "model_01_01_2026__12-00-00.mp4"
Splits []float64 `json:"splits"` // Sekunden, z. B. [120.5, 300.0] Splits []float64 `json:"splits"` // Sekunden, z. B. [120.5, 300.0]
Segments []splitVideoSegmentIn `json:"segments"` // echte Segmente
DeleteOriginal bool `json:"deleteOriginal,omitempty"`
} }
type splitVideoSegmentResponse struct { type splitVideoSegmentResponse struct {
Index int `json:"index"` Index int `json:"index"`
Label string `json:"label,omitempty"`
Start float64 `json:"start"` Start float64 `json:"start"`
End float64 `json:"end"` End float64 `json:"end"`
Duration float64 `json:"duration"` Duration float64 `json:"duration"`
@ -38,6 +49,21 @@ type splitVideoResponse struct {
Segments []splitVideoSegmentResponse `json:"segments"` Segments []splitVideoSegmentResponse `json:"segments"`
} }
type splitProgressEvent struct {
Type string `json:"type"`
OK bool `json:"ok,omitempty"`
File string `json:"file,omitempty"`
Current int `json:"current,omitempty"`
Total int `json:"total,omitempty"`
Progress float64 `json:"progress,omitempty"` // 0..1 aktuelles Segment
Overall float64 `json:"overall,omitempty"` // 0..1 gesamt
Start float64 `json:"start,omitempty"`
End float64 `json:"end,omitempty"`
Message string `json:"message,omitempty"`
Segment *splitVideoSegmentResponse `json:"segment,omitempty"`
Segments []splitVideoSegmentResponse `json:"segments,omitempty"`
}
func recordSplitVideo(w http.ResponseWriter, r *http.Request) { func recordSplitVideo(w http.ResponseWriter, r *http.Request) {
if !mustMethod(w, r, http.MethodPost) { if !mustMethod(w, r, http.MethodPost) {
return return
@ -59,6 +85,8 @@ func recordSplitVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
stream := r.URL.Query().Get("stream") == "1"
s := getSettings() s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir) doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil { if err != nil {
@ -82,31 +110,43 @@ func recordSplitVideo(w http.ResponseWriter, r *http.Request) {
srcPath = filepath.Clean(srcPath) srcPath = filepath.Clean(srcPath)
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) probeCtx, probeCancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel() durationSec, err := durationSecondsCached(probeCtx, srcPath)
probeCancel()
durationSec, err := durationSecondsCached(ctx, srcPath)
if err != nil || durationSec <= 0 { if err != nil || durationSec <= 0 {
http.Error(w, "videodauer konnte nicht ermittelt werden", http.StatusInternalServerError) http.Error(w, "videodauer konnte nicht ermittelt werden", http.StatusInternalServerError)
return return
} }
points, err := normalizeSplitPoints(req.Splits, durationSec) var segments []normalizedSegment
if err != nil {
http.Error(w, "splits ungültig: "+err.Error(), http.StatusBadRequest) if len(req.Segments) > 0 {
return segments, err = normalizeSegments(req.Segments, durationSec)
if err != nil {
http.Error(w, "segments ungültig: "+err.Error(), http.StatusBadRequest)
return
}
} else {
points, err := normalizeSplitPoints(req.Splits, durationSec)
if err != nil {
http.Error(w, "splits ungültig: "+err.Error(), http.StatusBadRequest)
return
}
if len(points) == 0 {
http.Error(w, "keine gültigen splits übergeben", http.StatusBadRequest)
return
}
segments = buildSplitSegments(points, durationSec)
if len(segments) < 2 {
http.Error(w, "zu wenige segmente nach split-berechnung", http.StatusBadRequest)
return
}
} }
if len(points) == 0 { splitCtx, splitCancel := context.WithTimeout(r.Context(), splitTimeoutForSegments(len(segments)))
http.Error(w, "keine gültigen splits übergeben", http.StatusBadRequest) defer splitCancel()
return
}
segments := buildSplitSegments(points, durationSec)
if len(segments) < 2 {
http.Error(w, "zu wenige segmente nach split-berechnung", http.StatusBadRequest)
return
}
outDir := filepath.Join(filepath.Dir(srcPath), "_split") outDir := filepath.Join(filepath.Dir(srcPath), "_split")
if err := os.MkdirAll(outDir, 0o755); err != nil { if err := os.MkdirAll(outDir, 0o755); err != nil {
@ -126,11 +166,78 @@ func recordSplitVideo(w http.ResponseWriter, r *http.Request) {
Source: srcPath, Source: srcPath,
} }
for i, seg := range segments { var flusher http.Flusher
outName := fmt.Sprintf("%s__part_%02d%s", base, i+1, ext) if stream {
outPath := filepath.Join(outDir, outName) var ok bool
flusher, ok = w.(http.Flusher)
if !ok {
http.Error(w, "streaming wird von diesem server nicht unterstützt", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/x-ndjson")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
_ = writeSplitProgressEvent(w, flusher, splitProgressEvent{
Type: "start",
OK: true,
File: req.File,
Total: len(segments),
Message: "Split gestartet",
})
}
for i, seg := range segments {
rawName := fmt.Sprintf("%s_%d%s", base, i+1, ext)
outPath := uniqueSplitOutPath(outDir, rawName)
outName := filepath.Base(outPath)
if stream {
_ = writeSplitProgressEvent(w, flusher, splitProgressEvent{
Type: "segment_start",
File: req.File,
Current: i + 1,
Total: len(segments),
Start: seg.Start,
End: seg.End,
Overall: float64(i) / float64(len(segments)),
Message: fmt.Sprintf("Segment %d/%d wird gesplittet", i+1, len(segments)),
})
}
err := splitSingleSegment(splitCtx, srcPath, outPath, seg.Start, seg.Duration, func(ratio float64) {
if !stream {
return
}
_ = writeSplitProgressEvent(w, flusher, splitProgressEvent{
Type: "segment_progress",
File: req.File,
Current: i + 1,
Total: len(segments),
Start: seg.Start,
End: seg.End,
Progress: clamp01(ratio),
Overall: clamp01((float64(i) + clamp01(ratio)) / float64(len(segments))),
Message: fmt.Sprintf("Segment %d/%d wird gesplittet", i+1, len(segments)),
})
})
if err != nil {
if stream {
_ = writeSplitProgressEvent(w, flusher, splitProgressEvent{
Type: "error",
File: req.File,
Current: i + 1,
Total: len(segments),
Start: seg.Start,
End: seg.End,
Message: fmt.Sprintf("segment %d konnte nicht erzeugt werden: %v", i+1, err),
})
return
}
if err := splitSingleSegment(r.Context(), srcPath, outPath, seg.Start, seg.Duration); err != nil {
http.Error( http.Error(
w, w,
fmt.Sprintf("segment %d konnte nicht erzeugt werden: %v", i+1, err), fmt.Sprintf("segment %d konnte nicht erzeugt werden: %v", i+1, err),
@ -139,26 +246,242 @@ func recordSplitVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
resp.Segments = append(resp.Segments, splitVideoSegmentResponse{ segResp := splitVideoSegmentResponse{
Index: i + 1, Index: i + 1,
Label: seg.Label,
Start: seg.Start, Start: seg.Start,
End: seg.End, End: seg.End,
Duration: seg.Duration, Duration: seg.Duration,
File: outName, File: outName,
Path: outPath, Path: outPath,
}) }
resp.Segments = append(resp.Segments, segResp)
if stream {
_ = writeSplitProgressEvent(w, flusher, splitProgressEvent{
Type: "segment_done",
File: req.File,
Current: i + 1,
Total: len(segments),
Start: seg.Start,
End: seg.End,
Overall: float64(i+1) / float64(len(segments)),
Segment: &segResp,
Message: fmt.Sprintf("Segment %d/%d fertig", i+1, len(segments)),
})
}
}
if req.DeleteOriginal {
originalFile := filepath.Base(srcPath)
originalID := stripHotPrefix(strings.TrimSuffix(originalFile, filepath.Ext(originalFile)))
releaseFileForMutation(originalFile)
if err := removeWithRetry(srcPath); err != nil {
if stream {
_ = writeSplitProgressEvent(w, flusher, splitProgressEvent{
Type: "error",
File: req.File,
Total: len(resp.Segments),
Overall: 1,
Message: "Originaldatei konnte nicht gelöscht werden: " + err.Error(),
})
return
}
http.Error(w, "Originaldatei konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
return
}
purgeDurationCacheForPath(srcPath)
removeJobsByOutputBasename(originalFile)
if strings.TrimSpace(originalID) != "" {
removeGeneratedForID(originalID)
}
if stream {
_ = writeSplitProgressEvent(w, flusher, splitProgressEvent{
Type: "source_deleted",
File: req.File,
Total: len(resp.Segments),
Overall: 1,
Message: "Originaldatei gelöscht",
})
}
} }
notifyDoneChanged() notifyDoneChanged()
if stream {
_ = writeSplitProgressEvent(w, flusher, splitProgressEvent{
Type: "done",
OK: true,
File: req.File,
Total: len(resp.Segments),
Overall: 1,
Segments: resp.Segments,
Message: "Split abgeschlossen",
})
return
}
respondJSON(w, resp) respondJSON(w, resp)
} }
func sanitizeTimeForFilename(v float64) string {
if v < 0 {
v = 0
}
return strings.ReplaceAll(formatFFSec(v), ".", "_")
}
func splitTimeoutForSegments(count int) time.Duration {
if count < 1 {
count = 1
}
// grob 2 Minuten pro Segment + Puffer
d := time.Duration(count)*2*time.Minute + 30*time.Second
if d < 5*time.Minute {
d = 5 * time.Minute
}
if d > 2*time.Hour {
d = 2 * time.Hour
}
return d
}
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
func writeSplitProgressEvent(w http.ResponseWriter, flusher http.Flusher, ev splitProgressEvent) error {
b, err := json.Marshal(ev)
if err != nil {
return err
}
if _, err := w.Write(append(b, '\n')); err != nil {
return err
}
flusher.Flush()
return nil
}
type normalizedSegment struct { type normalizedSegment struct {
Label string
Start float64 Start float64
End float64 End float64
Duration float64 Duration float64
} }
func normalizeSegments(raw []splitVideoSegmentIn, duration float64) ([]normalizedSegment, error) {
if duration <= 0 {
return nil, fmt.Errorf("duration <= 0")
}
if len(raw) == 0 {
return nil, fmt.Errorf("keine segmente übergeben")
}
out := make([]normalizedSegment, 0, len(raw))
for _, seg := range raw {
start := seg.Start
end := seg.End
if start > end {
start, end = end, start
}
if start < 0 {
start = 0
}
if end > duration {
end = duration
}
dur := end - start
if dur <= 0.10 {
continue
}
out = append(out, normalizedSegment{
Label: strings.TrimSpace(seg.Label),
Start: start,
End: end,
Duration: dur,
})
}
if len(out) == 0 {
return nil, fmt.Errorf("keine gültigen segmente übrig")
}
sort.Slice(out, func(i, j int) bool {
if out[i].Start == out[j].Start {
return out[i].End < out[j].End
}
return out[i].Start < out[j].Start
})
// near-duplicate entfernen
dedup := make([]normalizedSegment, 0, len(out))
for _, seg := range out {
if len(dedup) == 0 {
dedup = append(dedup, seg)
continue
}
last := dedup[len(dedup)-1]
sameStart := absFloat(last.Start-seg.Start) < 0.20
sameEnd := absFloat(last.End-seg.End) < 0.20
if sameStart && sameEnd {
continue
}
dedup = append(dedup, seg)
}
if len(dedup) == 0 {
return nil, fmt.Errorf("keine eindeutigen segmente übrig")
}
return dedup, nil
}
func uniqueSplitOutPath(dir, fileName string) string {
ext := filepath.Ext(fileName)
base := strings.TrimSuffix(fileName, ext)
outPath := filepath.Join(dir, fileName)
if _, err := os.Stat(outPath); err == nil {
// existiert schon -> unten nächsten Namen suchen
} else if os.IsNotExist(err) {
return outPath
}
for i := 2; i < 10000; i++ {
candidate := filepath.Join(dir, fmt.Sprintf("%s__%02d%s", base, i, ext))
if _, err := os.Stat(candidate); os.IsNotExist(err) {
return candidate
}
}
return filepath.Join(dir, fmt.Sprintf("%s__%d%s", base, time.Now().UnixNano(), ext))
}
func normalizeSplitPoints(raw []float64, duration float64) ([]float64, error) { func normalizeSplitPoints(raw []float64, duration float64) ([]float64, error) {
if duration <= 0 { if duration <= 0 {
return nil, fmt.Errorf("duration <= 0") return nil, fmt.Errorf("duration <= 0")
@ -218,7 +541,29 @@ func buildSplitSegments(points []float64, duration float64) []normalizedSegment
return out return out
} }
func splitSingleSegment(parentCtx context.Context, srcPath, outPath string, startSec, durSec float64) error { func splitSingleSegmentTimeout(durSec float64) time.Duration {
if durSec <= 0 {
return 10 * time.Minute
}
d := time.Duration(durSec*4)*time.Second + 2*time.Minute
if d < 5*time.Minute {
d = 5 * time.Minute
}
if d > 90*time.Minute {
d = 90 * time.Minute
}
return d
}
func splitSingleSegment(
parentCtx context.Context,
srcPath, outPath string,
startSec, durSec float64,
onProgress func(ratio float64),
) error {
if strings.TrimSpace(srcPath) == "" { if strings.TrimSpace(srcPath) == "" {
return fmt.Errorf("srcPath leer") return fmt.Errorf("srcPath leer")
} }
@ -229,19 +574,27 @@ func splitSingleSegment(parentCtx context.Context, srcPath, outPath string, star
return fmt.Errorf("dauer <= 0") return fmt.Errorf("dauer <= 0")
} }
tmpPath := outPath + ".part" ext := strings.ToLower(filepath.Ext(outPath))
base := strings.TrimSuffix(outPath, filepath.Ext(outPath))
tmpPath := base + ".part" + ext
_ = os.Remove(tmpPath) _ = os.Remove(tmpPath)
_ = os.Remove(outPath)
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Minute) timeout := splitSingleSegmentTimeout(durSec)
ctx, cancel := context.WithTimeout(parentCtx, timeout)
defer cancel() defer cancel()
// Re-Encode ist robuster/framesauberer als -c copy formatName := "mp4"
if ext == ".ts" {
formatName = "mpegts"
}
args := []string{ args := []string{
"-y", "-y",
"-nostats",
"-progress", "pipe:1",
"-hide_banner", "-hide_banner",
"-loglevel", "error", "-loglevel", "warning",
"-ss", formatFFSec(startSec), "-ss", formatFFSec(startSec),
"-i", srcPath, "-i", srcPath,
"-t", formatFFSec(durSec), "-t", formatFFSec(durSec),
@ -255,15 +608,93 @@ func splitSingleSegment(parentCtx context.Context, srcPath, outPath string, star
"-c:a", "aac", "-c:a", "aac",
"-b:a", "128k", "-b:a", "128k",
"-movflags", "+faststart", "-movflags", "+faststart",
"-f", formatName,
tmpPath, tmpPath,
} }
cmd := exec.CommandContext(ctx, ffmpegPath, args...) cmd := exec.CommandContext(ctx, ffmpegPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000,
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
var stderr bytes.Buffer var stderr bytes.Buffer
cmd.Stderr = &stderr cmd.Stderr = &stderr
if err := cmd.Run(); err != nil { if err := cmd.Start(); err != nil {
return err
}
if onProgress != nil {
onProgress(0)
}
sc := bufio.NewScanner(stdout)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
lastRatio := -1.0
send := func(outSec float64, force bool) {
if onProgress == nil || durSec <= 0 {
return
}
ratio := clamp01(outSec / durSec)
if !force && lastRatio >= 0 && absFloat(lastRatio-ratio) < 0.01 {
return
}
lastRatio = ratio
onProgress(ratio)
}
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
switch k {
case "out_time_us":
if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && n > 0 {
send(float64(n)/1_000_000.0, false)
}
case "out_time_ms":
if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && n > 0 {
send(float64(n)/1_000.0, false)
}
case "out_time":
if s := parseFFmpegOutTime(v); s > 0 {
send(s, false)
}
case "progress":
if strings.TrimSpace(v) == "end" {
send(durSec, true)
}
}
}
if err := sc.Err(); err != nil {
_ = os.Remove(tmpPath) _ = os.Remove(tmpPath)
return err
}
if err := cmd.Wait(); err != nil {
_ = os.Remove(tmpPath)
if ctx.Err() != nil {
return fmt.Errorf("ffmpeg timeout nach %s: %w", timeout, ctx.Err())
}
msg := strings.TrimSpace(stderr.String()) msg := strings.TrimSpace(stderr.String())
if msg != "" { if msg != "" {
return fmt.Errorf("%w (%s)", err, msg) return fmt.Errorf("%w (%s)", err, msg)
@ -282,6 +713,10 @@ func splitSingleSegment(parentCtx context.Context, srcPath, outPath string, star
return fmt.Errorf("rename fehlgeschlagen: %w", err) return fmt.Errorf("rename fehlgeschlagen: %w", err)
} }
if onProgress != nil {
onProgress(1)
}
return nil return nil
} }

View File

@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -20,13 +21,14 @@ import (
type AssetsTaskState struct { type AssetsTaskState struct {
Queued bool `json:"queued"` Queued bool `json:"queued"`
Running bool `json:"running"` Running bool `json:"running"`
QueuedAt time.Time `json:"queuedAt"`
StartedAt time.Time `json:"startedAt"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
Total int `json:"total"` Total int `json:"total"`
Done int `json:"done"` Done int `json:"done"`
GeneratedThumbs int `json:"generatedThumbs"` GeneratedThumbs int `json:"generatedThumbs"`
GeneratedPreviews int `json:"generatedPreviews"` GeneratedPreviews int `json:"generatedPreviews"`
Skipped int `json:"skipped"` Skipped int `json:"skipped"`
StartedAt time.Time `json:"startedAt"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
CurrentFile string `json:"currentFile,omitempty"` CurrentFile string `json:"currentFile,omitempty"`
CurrentQueue string `json:"currentQueue,omitempty"` CurrentQueue string `json:"currentQueue,omitempty"`
@ -35,10 +37,101 @@ type AssetsTaskState struct {
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
} }
type assetsPhaseSelection struct {
Meta bool
Thumb bool
Teaser bool
Sprites bool
Analyze bool
}
var assetsTaskMu sync.Mutex var assetsTaskMu sync.Mutex
var assetsTaskState AssetsTaskState var assetsTaskState AssetsTaskState
var assetsTaskCancel context.CancelFunc var assetsTaskCancel context.CancelFunc
var assetsTaskFileCancels = map[string]context.CancelFunc{}
var assetsTaskSkipFiles = map[string]struct{}{}
func selectedAssetsPhasesFromSettings() assetsPhaseSelection {
s := getSettings()
return assetsPhaseSelection{
Meta: s.GenerateAssetsMeta,
Thumb: s.GenerateAssetsThumb,
Teaser: s.GenerateAssetsTeaser,
Sprites: s.GenerateAssetsSprites,
Analyze: s.GenerateAssetsAnalyze,
}
}
func assetsTaskFileKey(file string) string {
return strings.ToLower(strings.TrimSpace(filepath.Base(file)))
}
func resetAssetsTaskFileControlLocked() {
assetsTaskFileCancels = map[string]context.CancelFunc{}
assetsTaskSkipFiles = map[string]struct{}{}
}
func registerAssetsTaskFileCancel(file string, cancel context.CancelFunc) {
key := assetsTaskFileKey(file)
if key == "" {
return
}
assetsTaskMu.Lock()
assetsTaskFileCancels[key] = cancel
assetsTaskMu.Unlock()
}
func clearAssetsTaskFileControl(file string) {
key := assetsTaskFileKey(file)
if key == "" {
return
}
assetsTaskMu.Lock()
delete(assetsTaskFileCancels, key)
delete(assetsTaskSkipFiles, key)
assetsTaskMu.Unlock()
}
func requestAssetsTaskSkip(file string) bool {
key := assetsTaskFileKey(file)
if key == "" {
return false
}
var cancel context.CancelFunc
assetsTaskMu.Lock()
assetsTaskSkipFiles[key] = struct{}{}
cancel = assetsTaskFileCancels[key]
assetsTaskMu.Unlock()
if cancel != nil {
cancel()
return true
}
return false
}
func isAssetsTaskSkipRequested(file string) bool {
key := assetsTaskFileKey(file)
if key == "" {
return false
}
assetsTaskMu.Lock()
_, ok := assetsTaskSkipFiles[key]
assetsTaskMu.Unlock()
return ok
}
func (p assetsPhaseSelection) Any() bool {
return p.Meta || p.Thumb || p.Teaser || p.Sprites || p.Analyze
}
// updateAssetsState mutiert den State atomar und triggert danach SSE notify. // updateAssetsState mutiert den State atomar und triggert danach SSE notify.
// notifyAssetsChanged() muss außerhalb des Locks passieren. // notifyAssetsChanged() muss außerhalb des Locks passieren.
func updateAssetsState(fn func(st *AssetsTaskState)) AssetsTaskState { func updateAssetsState(fn func(st *AssetsTaskState)) AssetsTaskState {
@ -85,13 +178,14 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
assetsTaskState = AssetsTaskState{ assetsTaskState = AssetsTaskState{
Queued: true, Queued: true,
Running: false, Running: false,
QueuedAt: time.Now(),
StartedAt: time.Time{},
FinishedAt: nil,
Total: 0, Total: 0,
Done: 0, Done: 0,
GeneratedThumbs: 0, GeneratedThumbs: 0,
GeneratedPreviews: 0, GeneratedPreviews: 0,
Skipped: 0, Skipped: 0,
StartedAt: time.Time{},
FinishedAt: nil,
Error: "", Error: "",
CurrentFile: "", CurrentFile: "",
CurrentQueue: "", CurrentQueue: "",
@ -99,6 +193,7 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
CurrentLabel: "", CurrentLabel: "",
Text: "Wartet…", Text: "Wartet…",
} }
resetAssetsTaskFileControlLocked()
st := assetsTaskState st := assetsTaskState
assetsTaskMu.Unlock() assetsTaskMu.Unlock()
@ -189,21 +284,117 @@ func publishAssetsTaskPhase(fileName string, queue string, phase string, state s
) )
} }
func clearAssetsTaskFinishedPostworkStates(fileName string) { func clearAssetsTaskFinishedPostworkStates(fileName string, sel assetsPhaseSelection) {
defs := []struct { if sel.Meta {
queue string publishAssetsTaskPhase(fileName, "postwork", "meta", "missing", "")
phase string }
}{ if sel.Thumb {
{queue: "postwork", phase: "meta"}, publishAssetsTaskPhase(fileName, "postwork", "thumb", "missing", "")
{queue: "postwork", phase: "thumb"}, }
{queue: "postwork", phase: "teaser"}, if sel.Teaser {
{queue: "postwork", phase: "sprites"}, publishAssetsTaskPhase(fileName, "postwork", "teaser", "missing", "")
{queue: "enrich", phase: "analyze"}, }
if sel.Sprites {
publishAssetsTaskPhase(fileName, "postwork", "sprites", "missing", "")
}
if sel.Analyze {
publishAssetsTaskPhase(fileName, "enrich", "analyze", "missing", "")
}
}
func fileExistsNonEmpty(path string) bool {
path = strings.TrimSpace(path)
if path == "" {
return false
} }
for _, def := range defs { fi, err := os.Stat(path)
publishAssetsTaskPhase(fileName, def.queue, def.phase, "missing", "") return err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0
}
type orderedAssetItem struct {
name string
path string
}
func listAssetsDoneThenKeepByFilenameAsc(doneAbs string) []orderedAssetItem {
indexedItems, _ := buildDoneIndex(doneAbs)
doneItems := make([]orderedAssetItem, 0, len(indexedItems))
keepItems := make([]orderedAssetItem, 0, len(indexedItems))
seen := make(map[string]struct{}, len(indexedItems))
for _, entry := range indexedItems {
if entry.job == nil {
continue
}
full := strings.TrimSpace(entry.job.Output)
if full == "" {
continue
}
cleanKey := strings.ToLower(filepath.Clean(full))
if _, ok := seen[cleanKey]; ok {
continue
}
seen[cleanKey] = struct{}{}
it := orderedAssetItem{
name: filepath.Base(full),
path: full,
}
if entry.fromKeep {
keepItems = append(keepItems, it)
} else {
doneItems = append(doneItems, it)
}
} }
sort.Slice(doneItems, func(i, j int) bool {
ai := strings.ToLower(doneItems[i].name)
aj := strings.ToLower(doneItems[j].name)
if ai != aj {
return ai < aj
}
return strings.ToLower(filepath.Clean(doneItems[i].path)) < strings.ToLower(filepath.Clean(doneItems[j].path))
})
sort.Slice(keepItems, func(i, j int) bool {
ai := strings.ToLower(keepItems[i].name)
aj := strings.ToLower(keepItems[j].name)
if ai != aj {
return ai < aj
}
return strings.ToLower(filepath.Clean(keepItems[i].path)) < strings.ToLower(filepath.Clean(keepItems[j].path))
})
return append(doneItems, keepItems...)
}
func assetsTaskTruthForVideo(id string, videoPath string) finishedPhaseTruth {
truth := finishedPhaseTruthForID(id)
_, thumbPath, previewPath, spritePath, metaPath, err := assetPathsForID(id)
if err != nil {
return truth
}
// meta muss nicht nur logisch "ready" sein, sondern die Datei muss auch existieren
truth.MetaReady = truth.MetaReady && fileExistsNonEmpty(metaPath)
// dateibasierte Assets IMMER an echter Datei festmachen
truth.ThumbReady = fileExistsNonEmpty(thumbPath)
truth.TeaserReady = fileExistsNonEmpty(previewPath)
truth.SpritesReady = fileExistsNonEmpty(spritePath)
// Analyse bleibt inhaltlich/meta-basiert
if strings.TrimSpace(videoPath) != "" {
truth.AnalyzeReady = hasAIResultsForOutput(videoPath)
}
return truth
} }
func runGenerateMissingAssets(ctx context.Context) { func runGenerateMissingAssets(ctx context.Context) {
@ -237,67 +428,27 @@ func runGenerateMissingAssets(ctx context.Context) {
st.Error = err.Error() st.Error = err.Error()
} }
}) })
}
abortIfCanceled := func(stepErr error, fileName string) bool { assetsTaskMu.Lock()
if errors.Is(stepErr, context.Canceled) || errors.Is(stepErr, context.DeadlineExceeded) || ctx.Err() != nil { resetAssetsTaskFileControlLocked()
if strings.TrimSpace(fileName) != "" { assetsTaskMu.Unlock()
clearAssetsTaskFinishedPostworkStates(fileName)
}
finishWithErr(context.Canceled)
return true
}
return false
} }
s := getSettings() s := getSettings()
sel := selectedAssetsPhasesFromSettings()
if !sel.Any() {
finishWithErr(nil)
return
}
doneAbs, err := resolvePathRelativeToApp(s.DoneDir) doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil || strings.TrimSpace(doneAbs) == "" { if err != nil || strings.TrimSpace(doneAbs) == "" {
finishWithErr(fmt.Errorf("doneDir auflösung fehlgeschlagen: %v", err)) finishWithErr(fmt.Errorf("doneDir auflösung fehlgeschlagen: %v", err))
return return
} }
type item struct { items := listAssetsDoneThenKeepByFilenameAsc(doneAbs)
name string
path string
}
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, len(order))
for _, idx := range order {
if idx < 0 || idx >= len(indexedItems) {
continue
}
j := indexedItems[idx].job
if j == nil {
continue
}
full := strings.TrimSpace(j.Output)
if full == "" {
continue
}
if _, ok := seen[full]; ok {
continue
}
seen[full] = struct{}{}
items = append(items, item{
name: filepath.Base(full),
path: full,
})
}
// Initialisierung: Total etc. + SSE Push
type assetCandidate struct { type assetCandidate struct {
name string name string
path string path string
@ -307,6 +458,7 @@ func runGenerateMissingAssets(ctx context.Context) {
candidates := make([]assetCandidate, 0, len(items)) candidates := make([]assetCandidate, 0, len(items))
skippedCount := 0 skippedCount := 0
initialDone := 0
for _, it := range items { for _, it := range items {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
@ -317,30 +469,33 @@ func runGenerateMissingAssets(ctx context.Context) {
base := strings.TrimSuffix(it.name, filepath.Ext(it.name)) base := strings.TrimSuffix(it.name, filepath.Ext(it.name))
id := stripHotPrefix(base) id := stripHotPrefix(base)
if strings.TrimSpace(id) == "" { if strings.TrimSpace(id) == "" {
clearAssetsTaskFinishedPostworkStates(it.name) clearAssetsTaskFinishedPostworkStates(it.name, sel)
skippedCount++ skippedCount++
initialDone++
continue continue
} }
vfi, verr := os.Stat(it.path) vfi, verr := os.Stat(it.path)
if verr != nil || vfi == nil || vfi.IsDir() || vfi.Size() <= 0 { if verr != nil || vfi == nil || vfi.IsDir() || vfi.Size() <= 0 {
clearAssetsTaskFinishedPostworkStates(it.name) clearAssetsTaskFinishedPostworkStates(it.name, sel)
skippedCount++ skippedCount++
initialDone++
continue continue
} }
beforeTruth := finishedPhaseTruthForID(id) beforeTruth := assetsTaskTruthForVideo(id, it.path)
needMeta := !beforeTruth.MetaReady needMeta := sel.Meta && !beforeTruth.MetaReady
needThumb := !beforeTruth.ThumbReady needThumb := sel.Thumb && !beforeTruth.ThumbReady
needTeaser := !beforeTruth.TeaserReady needTeaser := sel.Teaser && !beforeTruth.TeaserReady
needSprites := !beforeTruth.SpritesReady needSprites := sel.Sprites && !beforeTruth.SpritesReady
needAnalyze := !beforeTruth.AnalyzeReady needAnalyze := sel.Analyze && !beforeTruth.AnalyzeReady
// Bereits vollständig -> gar nicht erst in die Arbeitsliste aufnehmen // Bereits vollständig bzgl. ausgewählter Phasen -> als erledigt/skipped mitzählen
if !needMeta && !needThumb && !needTeaser && !needSprites && !needAnalyze { if !needMeta && !needThumb && !needTeaser && !needSprites && !needAnalyze {
clearAssetsTaskFinishedPostworkStates(it.name) clearAssetsTaskFinishedPostworkStates(it.name, sel)
skippedCount++ skippedCount++
initialDone++
continue continue
} }
@ -353,8 +508,8 @@ func runGenerateMissingAssets(ctx context.Context) {
} }
updateAssetsState(func(st *AssetsTaskState) { updateAssetsState(func(st *AssetsTaskState) {
st.Total = len(candidates) st.Total = len(items) // alle Dateien, inkl. keep + skips
st.Done = 0 st.Done = initialDone // vorhandene/ungültige direkt als erledigt zählen
st.GeneratedThumbs = 0 st.GeneratedThumbs = 0
st.GeneratedPreviews = 0 st.GeneratedPreviews = 0
st.Skipped = skippedCount st.Skipped = skippedCount
@ -363,42 +518,109 @@ func runGenerateMissingAssets(ctx context.Context) {
st.CurrentQueue = "" st.CurrentQueue = ""
st.CurrentPhase = "" st.CurrentPhase = ""
st.CurrentLabel = "" st.CurrentLabel = ""
st.Text = ""
}) })
if len(items) == 0 {
finishWithErr(nil)
return
}
if len(candidates) == 0 { if len(candidates) == 0 {
finishWithErr(nil) finishWithErr(nil)
return return
} }
for i, it := range candidates { for _, it := range candidates {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
finishWithErr(err) finishWithErr(err)
return return
} }
fileCtx, fileCancel := context.WithCancel(ctx)
registerAssetsTaskFileCancel(it.name, fileCancel)
finishFileControl := func() {
fileCancel()
clearAssetsTaskFileControl(it.name)
}
skipCurrentFile := func() {
clearAssetsTaskFinishedPostworkStates(it.name, sel)
updateAssetsState(func(st *AssetsTaskState) {
st.Done++
st.Skipped++
st.CurrentFile = ""
st.CurrentQueue = ""
st.CurrentPhase = ""
st.CurrentLabel = ""
})
finishFileControl()
}
handleFileAbort := func(stepErr error) (skipFile bool, stopAll bool) {
if stepErr == nil {
if isAssetsTaskSkipRequested(it.name) || !fileExistsNonEmpty(it.path) {
return true, false
}
return false, false
}
if errors.Is(stepErr, context.Canceled) || errors.Is(stepErr, context.DeadlineExceeded) {
// globaler Task wurde beendet
if ctx.Err() != nil && !isAssetsTaskSkipRequested(it.name) {
finishFileControl()
finishWithErr(context.Canceled)
return false, true
}
// nur diese eine Datei wurde freigegeben/übersprungen
return true, false
}
// Datei wurde evtl. während des Verarbeitungsschritts gelöscht/verschoben
if isAssetsTaskSkipRequested(it.name) || !fileExistsNonEmpty(it.path) {
return true, false
}
return false, false
}
updateAssetsState(func(st *AssetsTaskState) { updateAssetsState(func(st *AssetsTaskState) {
st.CurrentFile = it.name st.CurrentFile = it.name
}) })
if isAssetsTaskSkipRequested(it.name) || !fileExistsNonEmpty(it.path) {
skipCurrentFile()
continue
}
id := it.id id := it.id
vfi, verr := os.Stat(it.path) vfi, verr := os.Stat(it.path)
if verr != nil || vfi == nil || vfi.IsDir() || vfi.Size() <= 0 { if verr != nil || vfi == nil || vfi.IsDir() || vfi.Size() <= 0 {
clearAssetsTaskFinishedPostworkStates(it.name) clearAssetsTaskFinishedPostworkStates(it.name, sel)
updateAssetsState(func(st *AssetsTaskState) { updateAssetsState(func(st *AssetsTaskState) {
st.Done = i + 1 st.Done++
st.Skipped++
st.CurrentFile = ""
st.CurrentQueue = ""
st.CurrentPhase = ""
st.CurrentLabel = ""
}) })
finishFileControl()
continue continue
} }
beforeTruth := it.beforeTruth beforeTruth := it.beforeTruth
needMeta := !beforeTruth.MetaReady needMeta := sel.Meta && !beforeTruth.MetaReady
needThumb := !beforeTruth.ThumbReady needThumb := sel.Thumb && !beforeTruth.ThumbReady
needTeaser := !beforeTruth.TeaserReady needTeaser := sel.Teaser && !beforeTruth.TeaserReady
needSprites := !beforeTruth.SpritesReady needSprites := sel.Sprites && !beforeTruth.SpritesReady
needAnalyze := !beforeTruth.AnalyzeReady needAnalyze := sel.Analyze && !beforeTruth.AnalyzeReady
// Pfade einmalig über zentralen Helper // Pfade einmalig über zentralen Helper
_, _, _, _, metaPath, perr := assetPathsForID(id) _, _, _, _, metaPath, perr := assetPathsForID(id)
@ -416,13 +638,19 @@ func runGenerateMissingAssets(ctx context.Context) {
publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen") publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen")
} }
if needAnalyze { if needAnalyze {
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen") publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen")
} }
updateAssetsState(func(st *AssetsTaskState) { updateAssetsState(func(st *AssetsTaskState) {
st.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)" st.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)"
st.Done = i + 1 st.Done++
st.CurrentFile = ""
st.CurrentQueue = ""
st.CurrentPhase = ""
st.CurrentLabel = ""
}) })
finishFileControl()
fmt.Println("⚠️ assetPathsForID:", perr) fmt.Println("⚠️ assetPathsForID:", perr)
continue continue
} }
@ -449,9 +677,12 @@ func runGenerateMissingAssets(ctx context.Context) {
publishAssetsTaskPhase(it.name, "postwork", "meta", "running", "Meta") publishAssetsTaskPhase(it.name, "postwork", "meta", "running", "Meta")
okMeta, merr := ensureMetaForVideoCtx(ctx, it.path, sourceURL) okMeta, merr := ensureMetaForVideoCtx(fileCtx, it.path, sourceURL)
if abortIfCanceled(merr, it.name) { if skipFile, stopAll := handleFileAbort(merr); stopAll {
return return
} else if skipFile {
skipCurrentFile()
continue
} }
if merr != nil || !okMeta { if merr != nil || !okMeta {
publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen") publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen")
@ -473,14 +704,17 @@ func runGenerateMissingAssets(ctx context.Context) {
publishAssetsTaskPhase(it.name, "postwork", "thumb", "running", "Vorschaubild") publishAssetsTaskPhase(it.name, "postwork", "thumb", "running", "Vorschaubild")
resThumb, terr := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, it.path, sourceURL, nil) thumbGeneratedNow, terr := ensureThumbForVideoCtx(fileCtx, it.path, sourceURL)
if abortIfCanceled(terr, it.name) { if skipFile, stopAll := handleFileAbort(terr); stopAll {
return return
} else if skipFile {
skipCurrentFile()
continue
} }
if terr != nil { if terr != nil {
publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen") publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen")
} else if finishedPhaseTruthForID(id).ThumbReady { } else if assetsTaskTruthForVideo(id, it.path).ThumbReady {
if resThumb.ThumbGenerated { if thumbGeneratedNow {
thumbGenerated = true thumbGenerated = true
} }
publishAssetsTaskPhase(it.name, "postwork", "thumb", "done", "Vorschaubild") publishAssetsTaskPhase(it.name, "postwork", "thumb", "done", "Vorschaubild")
@ -502,13 +736,16 @@ func runGenerateMissingAssets(ctx context.Context) {
publishAssetsTaskPhase(it.name, "postwork", "teaser", "running", "Teaser") publishAssetsTaskPhase(it.name, "postwork", "teaser", "running", "Teaser")
genTeaser, terr := ensureTeaserForVideoCtx(ctx, it.path, sourceURL) genTeaser, terr := ensureTeaserForVideoCtx(fileCtx, it.path, sourceURL)
if abortIfCanceled(terr, it.name) { if skipFile, stopAll := handleFileAbort(terr); stopAll {
return return
} else if skipFile {
skipCurrentFile()
continue
} }
if terr != nil { if terr != nil {
publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen") publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen")
} else if finishedPhaseTruthForID(id).TeaserReady { } else if assetsTaskTruthForVideo(id, it.path).TeaserReady {
if genTeaser { if genTeaser {
previewGenerated = true previewGenerated = true
} }
@ -531,13 +768,16 @@ func runGenerateMissingAssets(ctx context.Context) {
publishAssetsTaskPhase(it.name, "postwork", "sprites", "running", "Sprites") publishAssetsTaskPhase(it.name, "postwork", "sprites", "running", "Sprites")
_, serr := ensureSpritesForVideoCtx(ctx, it.path, sourceURL) _, serr := ensureSpritesForVideoCtx(fileCtx, it.path, sourceURL)
if abortIfCanceled(serr, it.name) { if skipFile, stopAll := handleFileAbort(serr); stopAll {
return return
} else if skipFile {
skipCurrentFile()
continue
} }
if serr != nil { if serr != nil {
publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen") publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen")
} else if finishedPhaseTruthForID(id).SpritesReady { } else if assetsTaskTruthForVideo(id, it.path).SpritesReady {
publishAssetsTaskPhase(it.name, "postwork", "sprites", "done", "Sprites") publishAssetsTaskPhase(it.name, "postwork", "sprites", "done", "Sprites")
} else { } else {
publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen") publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen")
@ -552,21 +792,24 @@ func runGenerateMissingAssets(ctx context.Context) {
st.CurrentFile = it.name st.CurrentFile = it.name
st.CurrentQueue = "enrich" st.CurrentQueue = "enrich"
st.CurrentPhase = "analyze" st.CurrentPhase = "analyze"
st.CurrentLabel = "KI-Analyse" st.CurrentLabel = "Analyse"
}) })
publishAssetsTaskPhase(it.name, "enrich", "analyze", "running", "KI-Analyse") publishAssetsTaskPhase(it.name, "enrich", "analyze", "running", "Analyse")
_, aerr := ensureAnalyzeForVideoCtx(ctx, it.path, sourceURL, "nsfw") _, aerr := ensureAnalyzeForVideoCtx(fileCtx, it.path, sourceURL, "nsfw")
if abortIfCanceled(aerr, it.name) { if skipFile, stopAll := handleFileAbort(aerr); stopAll {
return return
} else if skipFile {
skipCurrentFile()
continue
} }
if aerr != nil { if aerr != nil {
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen") publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen")
} else if finishedPhaseTruthForID(id).AnalyzeReady { } else if assetsTaskTruthForVideo(id, it.path).AnalyzeReady {
publishAssetsTaskPhase(it.name, "enrich", "analyze", "done", "KI-Analyse") publishAssetsTaskPhase(it.name, "enrich", "analyze", "done", "Analyse")
} else { } else {
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen") publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen")
} }
} }
@ -577,8 +820,14 @@ func runGenerateMissingAssets(ctx context.Context) {
if previewGenerated { if previewGenerated {
st.GeneratedPreviews++ st.GeneratedPreviews++
} }
st.Done = i + 1 st.Done++
st.CurrentFile = ""
st.CurrentQueue = ""
st.CurrentPhase = ""
st.CurrentLabel = ""
}) })
finishFileControl()
} }
finishWithErr(nil) finishWithErr(nil)

View File

@ -0,0 +1,665 @@
// backend\tasks_check_video.go
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"image"
"image/jpeg"
"math"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
type tailBlackoutSample struct {
Role string `json:"role,omitempty"`
TimeSec float64 `json:"timeSec"`
AvgLuma float64 `json:"avgLuma"`
BlackRatio float64 `json:"blackRatio"`
MostlyBlack bool `json:"mostlyBlack"`
}
type tailBlackoutResult struct {
CheckedAt string `json:"checkedAt"`
IsCorrupt bool `json:"isCorrupt"`
Reason string `json:"reason,omitempty"`
ReferenceBlack bool `json:"referenceBlack,omitempty"`
BlackTailFrames int `json:"blackTailFrames"`
SampleCount int `json:"sampleCount"`
Samples []tailBlackoutSample `json:"samples,omitempty"`
}
type checkVideosTaskState struct {
mu sync.Mutex
queued bool
running bool
done int
total int
text string
currentFile string
err string
queuedAt time.Time
startedAt time.Time
finishedAt time.Time
cancel context.CancelFunc
}
func shortTaskFilename(name string, max int) string {
s := strings.TrimSpace(name)
if s == "" {
return ""
}
if max <= 1 || len(s) <= max {
return s
}
return "…" + s[len(s)-(max-1):]
}
func snapshotCheckVideosTaskState() CheckVideosTaskState {
checkVideosTask.mu.Lock()
defer checkVideosTask.mu.Unlock()
// Fertige/abgebrochene/fehlerhafte Tasks nach kurzer Zeit nicht mehr zurückgeben
if !checkVideosTask.queued &&
!checkVideosTask.running &&
!checkVideosTask.finishedAt.IsZero() &&
time.Since(checkVideosTask.finishedAt) > 4*time.Second {
return CheckVideosTaskState{}
}
out := CheckVideosTaskState{
Queued: checkVideosTask.queued,
Running: checkVideosTask.running,
QueuedAt: checkVideosTask.queuedAt,
StartedAt: checkVideosTask.startedAt,
Text: checkVideosTask.text,
Error: checkVideosTask.err,
CurrentFile: checkVideosTask.currentFile,
Done: checkVideosTask.done,
Total: checkVideosTask.total,
}
if !checkVideosTask.finishedAt.IsZero() {
finishedAt := checkVideosTask.finishedAt
out.FinishedAt = &finishedAt
}
return out
}
var checkVideosTask checkVideosTaskState
func clampFloat(v, lo, hi float64) float64 {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
func shouldCheckFinishedVideo(path string) bool {
n := strings.ToLower(strings.ReplaceAll(path, "\\", "/"))
base := filepath.Base(n)
ext := strings.ToLower(filepath.Ext(base))
if strings.Contains(n, "/.trash/") {
return false
}
switch base {
case "preview.jpg", "preview.mp4", "preview-sprite.jpg":
return false
}
switch ext {
case ".mp4", ".mkv", ".webm", ".ts", ".mov":
return true
default:
return false
}
}
func hasUsableSpriteForVideo(videoPath string) bool {
assetID := assetIDFromVideoPath(videoPath)
if assetID == "" {
return false
}
_, _, _, spritePath, _, err := assetPathsForID(assetID)
if err != nil {
return false
}
fi, err := os.Stat(spritePath)
return err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0
}
func collectFinishedVideosForCheck() ([]string, error) {
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(strings.TrimSpace(s.DoneDir))
if err != nil {
return nil, err
}
files := make([]string, 0, 256)
err = filepath.Walk(doneAbs, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return nil
}
if info == nil || info.IsDir() {
return nil
}
if !info.Mode().IsRegular() {
return nil
}
if !shouldCheckFinishedVideo(path) {
return nil
}
if !hasUsableSpriteForVideo(path) {
return nil
}
files = append(files, path)
return nil
})
if err != nil {
return nil, err
}
sort.Strings(files)
return files, nil
}
func finishCheckVideosTask(err error) {
checkVideosTask.mu.Lock()
checkVideosTask.queued = false
checkVideosTask.running = false
checkVideosTask.cancel = nil
checkVideosTask.finishedAt = time.Now()
errText := strings.TrimSpace(func() string {
if err == nil {
return ""
}
return err.Error()
}())
cancelled :=
strings.EqualFold(errText, "abgebrochen") ||
strings.EqualFold(errText, "abbruch") ||
strings.EqualFold(errText, "cancelled") ||
strings.EqualFold(errText, "canceled")
if cancelled {
checkVideosTask.err = ""
checkVideosTask.text = "Abgebrochen."
} else if err != nil {
checkVideosTask.err = errText
checkVideosTask.text = "Fehler bei der Video-Prüfung."
} else {
checkVideosTask.err = ""
checkVideosTask.text = "Prüfung abgeschlossen."
}
checkVideosTask.mu.Unlock()
publishTaskState()
}
func runCheckVideosTask(ctx context.Context) {
files, err := collectFinishedVideosForCheck()
if err != nil {
finishCheckVideosTask(err)
return
}
checkVideosTask.mu.Lock()
checkVideosTask.queued = false
checkVideosTask.running = true
checkVideosTask.total = len(files)
checkVideosTask.done = 0
checkVideosTask.startedAt = time.Now()
checkVideosTask.text = "Prüfe Videos…"
checkVideosTask.mu.Unlock()
publishTaskState()
var firstErr error
for i, file := range files {
select {
case <-ctx.Done():
finishCheckVideosTask(fmt.Errorf("abgebrochen"))
return
default:
}
checkVideosTask.mu.Lock()
checkVideosTask.currentFile = file
checkVideosTask.text = shortTaskFilename(file, 52)
checkVideosTask.done = i
checkVideosTask.mu.Unlock()
if _, err := runTailBlackoutCheck(ctx, file); err != nil {
if firstErr == nil {
firstErr = err
}
}
checkVideosTask.mu.Lock()
checkVideosTask.done = i + 1
checkVideosTask.mu.Unlock()
}
finishCheckVideosTask(firstErr)
}
func startCheckVideosTask() error {
checkVideosTask.mu.Lock()
defer checkVideosTask.mu.Unlock()
if checkVideosTask.running || checkVideosTask.queued {
return fmt.Errorf("task läuft bereits")
}
ctx, cancel := context.WithCancel(context.Background())
checkVideosTask.queued = true
checkVideosTask.running = false
checkVideosTask.done = 0
checkVideosTask.total = 0
checkVideosTask.text = "Wartet…"
checkVideosTask.currentFile = ""
checkVideosTask.err = ""
checkVideosTask.queuedAt = time.Now()
checkVideosTask.startedAt = time.Time{}
checkVideosTask.finishedAt = time.Time{}
checkVideosTask.cancel = cancel
publishTaskState()
go runCheckVideosTask(ctx)
return nil
}
func cancelCheckVideosTask() {
checkVideosTask.mu.Lock()
cancel := checkVideosTask.cancel
checkVideosTask.mu.Unlock()
if cancel != nil {
cancel()
}
}
func checkVideosTaskHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
if err := startCheckVideosTask(); err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
return
case http.MethodDelete:
cancelCheckVideosTask()
w.WriteHeader(http.StatusNoContent)
return
default:
http.Error(w, "Nur POST/DELETE erlaubt", http.StatusMethodNotAllowed)
return
}
}
func analyzeImageBoundsBlackness(img image.Image, bounds image.Rectangle) (avgLuma float64, blackRatio float64, err error) {
b := bounds.Intersect(img.Bounds())
w := b.Dx()
h := b.Dy()
if w <= 0 || h <= 0 {
return 0, 0, fmt.Errorf("invalid image bounds")
}
totalPixels := w * h
step := int(math.Sqrt(float64(totalPixels) / 12000.0))
if step < 1 {
step = 1
}
var (
sumLuma float64
count int
black int
)
for y := b.Min.Y; y < b.Max.Y; y += step {
for x := b.Min.X; x < b.Max.X; x += step {
r, g, bb, _ := img.At(x, y).RGBA()
r8 := float64(r >> 8)
g8 := float64(g >> 8)
b8 := float64(bb >> 8)
luma := 0.2126*r8 + 0.7152*g8 + 0.0722*b8
sumLuma += luma
count++
if luma <= 16 {
black++
}
}
}
if count == 0 {
return 0, 0, fmt.Errorf("no sampled pixels")
}
avgLuma = sumLuma / float64(count)
blackRatio = float64(black) / float64(count)
return avgLuma, blackRatio, nil
}
func analyzeJPGBlackness(jpg []byte) (avgLuma float64, blackRatio float64, err error) {
img, err := jpeg.Decode(bytes.NewReader(jpg))
if err != nil {
return 0, 0, err
}
return analyzeImageBoundsBlackness(img, img.Bounds())
}
func spriteCellBounds(img image.Image, cols, rows, index int) (image.Rectangle, error) {
if cols <= 0 || rows <= 0 {
return image.Rectangle{}, fmt.Errorf("invalid sprite layout")
}
if index < 0 || index >= cols*rows {
return image.Rectangle{}, fmt.Errorf("sprite index out of range")
}
b := img.Bounds()
col := index % cols
row := index / cols
x0 := b.Min.X + (col*b.Dx())/cols
x1 := b.Min.X + ((col+1)*b.Dx())/cols
y0 := b.Min.Y + (row*b.Dy())/rows
y1 := b.Min.Y + ((row+1)*b.Dy())/rows
rect := image.Rect(x0, y0, x1, y1)
if rect.Dx() <= 0 || rect.Dy() <= 0 {
return image.Rectangle{}, fmt.Errorf("invalid sprite cell bounds")
}
return rect, nil
}
func analyzeSpriteCellBlackness(img image.Image, cols, rows, index int) (avgLuma float64, blackRatio float64, err error) {
rect, err := spriteCellBounds(img, cols, rows, index)
if err != nil {
return 0, 0, err
}
return analyzeImageBoundsBlackness(img, rect)
}
func isMostlyBlackFrame(avgLuma, blackRatio float64) bool {
return avgLuma <= 18.0 && blackRatio >= 0.96
}
func makeTailSample(role string, t, avgLuma, blackRatio float64) tailBlackoutSample {
return tailBlackoutSample{
Role: role,
TimeSec: t,
AvgLuma: avgLuma,
BlackRatio: blackRatio,
MostlyBlack: isMostlyBlackFrame(avgLuma, blackRatio),
}
}
func uniqueTailIndexes(indexes []int, count int) []int {
if count <= 0 {
return nil
}
seen := map[int]struct{}{}
out := make([]int, 0, len(indexes))
for _, idx := range indexes {
if idx < 0 {
idx = 0
}
if idx >= count {
idx = count - 1
}
if _, ok := seen[idx]; ok {
continue
}
seen[idx] = struct{}{}
out = append(out, idx)
}
return out
}
func spriteSampleTimeSec(index int, stepSec float64) float64 {
if stepSec <= 0 || index < 0 {
return 0
}
return float64(index) * stepSec
}
func uniqueTailTimes(times []float64, dur float64) []float64 {
maxT := math.Max(0.15, dur-0.15)
seen := map[int]struct{}{}
out := make([]float64, 0, len(times))
for _, t := range times {
t = clampFloat(t, 0.10, maxT)
key := int(math.Round(t * 1000))
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, t)
}
return out
}
func inspectVideoTailBlackout(videoPath string) (*tailBlackoutResult, error) {
assetID := assetIDFromVideoPath(videoPath)
if assetID == "" {
return nil, fmt.Errorf("asset id fehlt")
}
_, _, _, spritePath, metaPath, err := assetPathsForID(assetID)
if err != nil {
return nil, err
}
f, err := os.Open(spritePath)
if err != nil {
return nil, fmt.Errorf("sprite open failed: %w", err)
}
defer f.Close()
img, err := jpeg.Decode(f)
if err != nil {
return nil, fmt.Errorf("sprite decode failed: %w", err)
}
cols, rows, count, _, _ := fixedPreviewSpriteLayout()
stepSec := 0.0
if ps, ok := readPreviewSpriteMetaFromMetaFile(metaPath); ok {
if ps.Cols > 0 {
cols = ps.Cols
}
if ps.Rows > 0 {
rows = ps.Rows
}
if ps.Count > 0 {
count = ps.Count
}
if ps.StepSeconds > 0 {
stepSec = ps.StepSeconds
}
}
maxCells := cols * rows
if maxCells <= 0 {
return nil, fmt.Errorf("invalid sprite layout")
}
if count <= 0 {
count = maxCells
}
if count > maxCells {
count = maxCells
}
if count < 2 {
return nil, fmt.Errorf("sprite enthält zu wenige frames")
}
res := &tailBlackoutResult{
CheckedAt: time.Now().Format(time.RFC3339Nano),
}
refIndex := int(math.Round(float64(count-1) * 0.55))
if avg, ratio, err := analyzeSpriteCellBlackness(img, cols, rows, refIndex); err == nil {
refSample := makeTailSample("reference", spriteSampleTimeSec(refIndex, stepSec), avg, ratio)
res.ReferenceBlack = refSample.MostlyBlack
res.Samples = append(res.Samples, refSample)
}
tailIndexes := uniqueTailIndexes([]int{
int(math.Round(float64(count-1) * 0.82)),
int(math.Round(float64(count-1) * 0.88)),
int(math.Round(float64(count-1) * 0.94)),
int(math.Round(float64(count-1) * 0.985)),
}, count)
validTail := 0
blackTail := 0
for _, idx := range tailIndexes {
avg, ratio, err := analyzeSpriteCellBlackness(img, cols, rows, idx)
if err != nil {
continue
}
sample := makeTailSample("tail", spriteSampleTimeSec(idx, stepSec), avg, ratio)
res.Samples = append(res.Samples, sample)
validTail++
if sample.MostlyBlack {
blackTail++
}
}
res.SampleCount = validTail
res.BlackTailFrames = blackTail
switch {
case validTail >= 4 && blackTail >= 3 && !res.ReferenceBlack:
res.IsCorrupt = true
res.Reason = fmt.Sprintf("%d/%d Sprite-Endzellen sind fast komplett schwarz", blackTail, validTail)
case validTail >= 3 && blackTail == validTail && !res.ReferenceBlack:
res.IsCorrupt = true
res.Reason = fmt.Sprintf("alle %d geprüften Sprite-Endzellen sind fast komplett schwarz", validTail)
case validTail >= 4 && blackTail == validTail:
res.IsCorrupt = true
res.Reason = "das komplette geprüfte Sprite-Ende ist fast komplett schwarz"
default:
res.IsCorrupt = false
if validTail == 0 {
res.Reason = "keine validen Sprite-Endzellen prüfbar"
} else {
res.Reason = fmt.Sprintf("%d/%d Sprite-Endzellen schwarz", blackTail, validTail)
}
}
return res, nil
}
func persistTailBlackoutResult(videoPath string, res *tailBlackoutResult) error {
if res == nil {
return nil
}
assetID := assetIDFromVideoPath(videoPath)
if assetID == "" {
return fmt.Errorf("asset id fehlt")
}
_, _, _, _, metaPath, err := assetPathsForID(assetID)
if err != nil {
return err
}
raw, err := os.ReadFile(metaPath)
if err != nil {
return err
}
root := map[string]any{}
if len(raw) > 0 {
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
if err := dec.Decode(&root); err != nil {
return err
}
}
validation, _ := root["validation"].(map[string]any)
if validation == nil {
validation = map[string]any{}
}
b, err := json.Marshal(res)
if err != nil {
return err
}
node := map[string]any{}
if err := json.Unmarshal(b, &node); err != nil {
return err
}
validation["tailBlackout"] = node
root["validation"] = validation
out, err := json.Marshal(root)
if err != nil {
return err
}
return atomicWriteFile(metaPath, out)
}
func runTailBlackoutCheck(ctx context.Context, videoPath string) (*tailBlackoutResult, error) {
_ = ctx
res, err := inspectVideoTailBlackout(videoPath)
if err != nil {
return nil, err
}
if err := persistTailBlackoutResult(videoPath, res); err != nil {
return nil, err
}
return res, nil
}

View File

@ -3,6 +3,7 @@
package main package main
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/fs" "io/fs"
@ -61,7 +62,6 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
s := getSettings() s := getSettings()
// doneDir auflösen
doneAbs, err := resolvePathRelativeToApp(s.DoneDir) doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil || strings.TrimSpace(doneAbs) == "" { if err != nil || strings.TrimSpace(doneAbs) == "" {
setCleanupTaskError("doneDir auflösung fehlgeschlagen") setCleanupTaskError("doneDir auflösung fehlgeschlagen")
@ -74,11 +74,8 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Threshold: standardmäßig aus Settings
mb := int(s.AutoDeleteSmallDownloadsBelowMB) mb := int(s.AutoDeleteSmallDownloadsBelowMB)
// optional Body-Override (wenn du es später brauchst)
// (Frontend sendet aktuell nichts; ist trotzdem safe)
var req cleanupReq var req cleanupReq
if r.Body != nil { if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&req) _ = json.NewDecoder(r.Body).Decode(&req)
@ -90,44 +87,62 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
mb = 0 mb = 0
} }
// ✅ exklusiv warten: Cleanup startet erst, wenn andere exklusive Tasks fertig sind cleanupTaskMu.Lock()
if err := acquireExclusiveTask(r.Context()); err != nil { if cleanupTaskState.Queued || cleanupTaskState.Running {
http.Error(w, err.Error(), http.StatusRequestTimeout) st := cleanupTaskState
cleanupTaskMu.Unlock()
writeJSON(w, http.StatusOK, st)
return return
} }
defer releaseExclusiveTask() cleanupTaskMu.Unlock()
setCleanupTaskRunning("Räume auf…") setCleanupTaskQueued("Wartet…")
st := snapshotCleanupTaskState()
resp := cleanupResp{} go func(doneAbs string, mb int) {
ctx := context.Background()
// 1) Kleine Downloads löschen (wenn mb > 0) if err := acquireExclusiveTask(ctx); err != nil {
if mb > 0 { setCleanupTaskError(err.Error())
threshold := int64(mb) * 1024 * 1024 time.AfterFunc(7*time.Second, func() {
cleanupSmallFiles(doneAbs, threshold, &resp) clearCleanupTaskState()
} })
return
}
defer releaseExclusiveTask()
// ✅ Beim manuellen Aufräumen: Generated-GC synchron laufen lassen, setCleanupTaskRunning("Räume auf…")
// damit die Zahlen in der JSON-Response landen.
gcStats := triggerGeneratedGarbageCollectorSync()
resp.GeneratedOrphansChecked = gcStats.Checked
resp.GeneratedOrphansRemoved = gcStats.Removed
resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes
resp.GeneratedOrphansRemovedBytesHuman = formatBytesSI(gcStats.RemovedBytes)
// ✅ Generated-GC zur insgesamt freigegebenen Größe addieren resp := cleanupResp{}
resp.DeletedBytes += gcStats.RemovedBytes
resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes)
setCleanupTaskDone( if mb > 0 {
cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes), threshold := int64(mb) * 1024 * 1024
) cleanupSmallFiles(doneAbs, threshold, &resp)
}
time.AfterFunc(7*time.Second, func() { gcStats := triggerGeneratedGarbageCollectorSync()
clearCleanupTaskState() resp.GeneratedOrphansChecked = gcStats.Checked
}) resp.GeneratedOrphansRemoved = gcStats.Removed
resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes
resp.GeneratedOrphansRemovedBytesHuman = formatBytesSI(gcStats.RemovedBytes)
writeJSON(w, http.StatusOK, resp) resp.DeletedBytes += gcStats.RemovedBytes
resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes)
if resp.DeletedFiles > 0 || resp.GeneratedOrphansRemoved > 0 {
notifyDoneChanged()
}
setCleanupTaskDone(
cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes),
)
time.AfterFunc(7*time.Second, func() {
clearCleanupTaskState()
})
}(doneAbs, mb)
writeJSON(w, http.StatusOK, st)
} }
func cleanupProgressText(deleted int, total int, freedBytes int64) string { func cleanupProgressText(deleted int, total int, freedBytes int64) string {
@ -317,6 +332,67 @@ func startGeneratedGarbageCollector() {
}() }()
} }
func collectLiveIDsFromTrash(trashDir string, live map[string]struct{}) {
trashDir = strings.TrimSpace(trashDir)
if trashDir == "" || live == nil {
return
}
entries, err := os.ReadDir(trashDir)
if err != nil {
return
}
for _, e := range entries {
if e == nil || e.IsDir() {
continue
}
name := strings.TrimSpace(e.Name())
if name == "" {
continue
}
// last.json ignorieren
if strings.EqualFold(name, "last.json") {
continue
}
// Format in .trash:
// <token>__<original-file>
parts := strings.SplitN(name, "__", 2)
if len(parts) != 2 {
continue
}
originalFile := strings.TrimSpace(parts[1])
if originalFile == "" {
continue
}
ext := strings.ToLower(filepath.Ext(originalFile))
if ext != ".mp4" && ext != ".ts" {
continue
}
full := filepath.Join(trashDir, name)
fi, err := os.Stat(full)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
continue
}
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(originalFile), ext))
id = strings.TrimSpace(id)
if id == "" {
continue
}
if sid, err := sanitizeID(id); err == nil && sid != "" {
live[sid] = struct{}{}
}
}
}
// Core-Logik ohne Delay (für manuelle Trigger, z.B. nach Cleanup) // Core-Logik ohne Delay (für manuelle Trigger, z.B. nach Cleanup)
// Liefert Stats zurück, damit /api/settings/cleanup die Zahlen anzeigen kann. // Liefert Stats zurück, damit /api/settings/cleanup die Zahlen anzeigen kann.
func runGeneratedGarbageCollector() generatedGCStats { func runGeneratedGarbageCollector() generatedGCStats {
@ -334,7 +410,7 @@ func runGeneratedGarbageCollector() generatedGCStats {
return stats return stats
} }
// 1) Live-IDs sammeln: alle mp4/ts unter /done (rekursiv), .trash ignorieren // 1) Live-IDs sammeln: alle mp4/ts unter /done (rekursiv), .trash zunächst ignorieren
live := make(map[string]struct{}, 4096) live := make(map[string]struct{}, 4096)
_ = filepath.WalkDir(doneAbs, func(p string, d fs.DirEntry, err error) error { _ = filepath.WalkDir(doneAbs, func(p string, d fs.DirEntry, err error) error {
@ -371,6 +447,11 @@ func runGeneratedGarbageCollector() generatedGCStats {
return nil return nil
}) })
// 1a) Dateien in .trash ebenfalls als "live" behandeln,
// damit ihre Generated-Dateien erst gelöscht werden,
// wenn sie wirklich endgültig aus .trash verschwinden.
collectLiveIDsFromTrash(filepath.Join(doneAbs, ".trash"), live)
// 1b) Auch aktuell laufende / nachbearbeitete Jobs als "live" behandeln. // 1b) Auch aktuell laufende / nachbearbeitete Jobs als "live" behandeln.
// Sonst räumt der GC generated/meta/<id> weg, obwohl RecordStream dort noch schreibt. // Sonst räumt der GC generated/meta/<id> weg, obwohl RecordStream dort noch schreibt.
jobsMu.RLock() jobsMu.RLock()

View File

@ -4,12 +4,88 @@ package main
import ( import (
"context" "context"
"sync/atomic"
) )
var exclusiveTaskSem = make(chan struct{}, 1) type exclusiveTaskRequest struct {
id uint64
granted chan struct{}
}
type exclusiveTaskCancelRequest struct {
id uint64
ack chan bool
}
var (
exclusiveTaskReqCh = make(chan *exclusiveTaskRequest)
exclusiveTaskCancelCh = make(chan exclusiveTaskCancelRequest)
exclusiveTaskReleaseCh = make(chan struct{}, 1)
exclusiveTaskNextID atomic.Uint64
)
func init() { func init() {
exclusiveTaskSem <- struct{}{} go runExclusiveTaskQueue()
}
func runExclusiveTaskQueue() {
queue := make([]*exclusiveTaskRequest, 0, 8)
locked := false
var activeID uint64
grantNext := func() {
if locked {
return
}
for len(queue) > 0 {
req := queue[0]
queue = queue[1:]
if req == nil {
continue
}
locked = true
activeID = req.id
close(req.granted)
return
}
activeID = 0
}
for {
grantNext()
select {
case req := <-exclusiveTaskReqCh:
if req != nil {
queue = append(queue, req)
}
case cancelReq := <-exclusiveTaskCancelCh:
grantedAlready := locked && activeID == cancelReq.id
if !grantedAlready {
for i, req := range queue {
if req != nil && req.id == cancelReq.id {
queue = append(queue[:i], queue[i+1:]...)
break
}
}
}
cancelReq.ack <- grantedAlready
case <-exclusiveTaskReleaseCh:
if locked {
locked = false
activeID = 0
}
}
}
} }
func acquireExclusiveTask(ctx context.Context) error { func acquireExclusiveTask(ctx context.Context) error {
@ -17,17 +93,41 @@ func acquireExclusiveTask(ctx context.Context) error {
ctx = context.Background() ctx = context.Background()
} }
req := &exclusiveTaskRequest{
id: exclusiveTaskNextID.Add(1),
granted: make(chan struct{}),
}
select { select {
case exclusiveTaskReqCh <- req:
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
case <-exclusiveTaskSem: }
select {
case <-req.granted:
return nil return nil
case <-ctx.Done():
ack := make(chan bool, 1)
exclusiveTaskCancelCh <- exclusiveTaskCancelRequest{
id: req.id,
ack: ack,
}
grantedAlready := <-ack
if grantedAlready {
return nil
}
return ctx.Err()
} }
} }
func releaseExclusiveTask() { func releaseExclusiveTask() {
select { select {
case exclusiveTaskSem <- struct{}{}: case exclusiveTaskReleaseCh <- struct{}{}:
default: default:
} }
} }

View File

@ -9,13 +9,12 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
) )
const regenerateAssetsMaxParallel = 1
type regenerateAssetsRequest struct { type regenerateAssetsRequest struct {
File string `json:"file"` File string `json:"file"`
} }
@ -47,10 +46,8 @@ type regenerateAssetsJob struct {
} }
var ( var (
regenerateAssetsMu sync.Mutex regenerateAssetsMu sync.Mutex
regenerateAssetsJobs = map[string]*regenerateAssetsJob{} // key = file regenerateAssetsJobs = map[string]*regenerateAssetsJob{} // key = file
regenerateAssetsQueue []string // file names
regenerateAssetsWorkersStarted bool
) )
func publishFinishedPostworkPhase( func publishFinishedPostworkPhase(
@ -152,56 +149,11 @@ func firstMissingDeferredRegeneratePhaseError(videoPath string, assetID string)
return errors.New("preview-sprite.jpg fehlt oder ist leer") return errors.New("preview-sprite.jpg fehlt oder ist leer")
} }
if !truth.AnalyzeReady { if !truth.AnalyzeReady {
return errors.New("KI-Analyse fehlt oder wurde nicht in meta.json gespeichert") return errors.New("Analyse fehlt oder wurde nicht in meta.json gespeichert")
} }
return nil return nil
} }
func ensureRegenerateAssetsWorkersStarted() {
regenerateAssetsMu.Lock()
defer regenerateAssetsMu.Unlock()
if regenerateAssetsWorkersStarted {
return
}
regenerateAssetsWorkersStarted = true
for i := 0; i < regenerateAssetsMaxParallel; i++ {
go regenerateAssetsWorker()
}
}
func enqueueRegenerateAssetsJob(job *regenerateAssetsJob) {
regenerateAssetsMu.Lock()
defer regenerateAssetsMu.Unlock()
regenerateAssetsJobs[job.File] = job
regenerateAssetsQueue = append(regenerateAssetsQueue, job.File)
}
func nextQueuedRegenerateAssetsJob() *regenerateAssetsJob {
regenerateAssetsMu.Lock()
defer regenerateAssetsMu.Unlock()
for len(regenerateAssetsQueue) > 0 {
file := regenerateAssetsQueue[0]
regenerateAssetsQueue = regenerateAssetsQueue[1:]
job := regenerateAssetsJobs[file]
if job == nil {
continue
}
if job.State != "queued" {
continue
}
return job
}
return nil
}
func markRegenerateAssetsJobRunning(file string) *regenerateAssetsJob { func markRegenerateAssetsJobRunning(file string) *regenerateAssetsJob {
regenerateAssetsMu.Lock() regenerateAssetsMu.Lock()
defer regenerateAssetsMu.Unlock() defer regenerateAssetsMu.Unlock()
@ -270,14 +222,69 @@ func removeRegenerateAssetsJobLater(file string, after time.Duration) {
}) })
} }
func regeneratePhaseOrder(queue string, phase string) int {
q := strings.ToLower(strings.TrimSpace(queue))
p := strings.ToLower(strings.TrimSpace(phase))
switch q + ":" + p {
case "postwork:meta":
return 0
case "postwork:thumb":
return 1
case "postwork:teaser":
return 2
case "postwork:sprites":
return 3
case "enrich:analyze":
return 4
default:
return 999
}
}
func getRegenerateAssetsJobsSnapshot() []map[string]any { func getRegenerateAssetsJobsSnapshot() []map[string]any {
regenerateAssetsMu.Lock() regenerateAssetsMu.Lock()
defer regenerateAssetsMu.Unlock() defer regenerateAssetsMu.Unlock()
out := make([]map[string]any, 0, len(regenerateAssetsJobs)) jobs := make([]*regenerateAssetsJob, 0, len(regenerateAssetsJobs))
for _, job := range regenerateAssetsJobs { for _, job := range regenerateAssetsJobs {
phases := make([]map[string]any, 0, len(job.Phases)) if job != nil {
jobs = append(jobs, job)
}
}
sort.SliceStable(jobs, func(i, j int) bool {
ai := jobs[i].EnqueuedAt.UnixMilli()
aj := jobs[j].EnqueuedAt.UnixMilli()
if ai != aj {
return ai < aj
}
return jobs[i].File < jobs[j].File
})
out := make([]map[string]any, 0, len(jobs))
for _, job := range jobs {
phaseList := make([]regenerateAssetsPhase, 0, len(job.Phases))
for _, p := range job.Phases { for _, p := range job.Phases {
phaseList = append(phaseList, p)
}
sort.SliceStable(phaseList, func(i, j int) bool {
oi := regeneratePhaseOrder(phaseList[i].Queue, phaseList[i].Phase)
oj := regeneratePhaseOrder(phaseList[j].Queue, phaseList[j].Phase)
if oi != oj {
return oi < oj
}
return phaseList[i].TS < phaseList[j].TS
})
phases := make([]map[string]any, 0, len(phaseList))
for _, p := range phaseList {
phases = append(phases, map[string]any{ phases = append(phases, map[string]any{
"queue": p.Queue, "queue": p.Queue,
"phase": p.Phase, "phase": p.Phase,
@ -288,12 +295,17 @@ func getRegenerateAssetsJobsSnapshot() []map[string]any {
} }
out = append(out, map[string]any{ out = append(out, map[string]any{
"file": job.File, "file": job.File,
"assetId": job.AssetID, "assetId": job.AssetID,
"state": job.State, "state": job.State,
"error": job.Error, "error": job.Error,
"phases": phases, "phases": phases,
"enqueuedAt": job.EnqueuedAt.UnixMilli(), "enqueuedAt": func() int64 {
if job.EnqueuedAt.IsZero() {
return 0
}
return job.EnqueuedAt.UnixMilli()
}(),
"startedAt": func() int64 { "startedAt": func() int64 {
if job.StartedAt.IsZero() { if job.StartedAt.IsZero() {
return 0 return 0
@ -312,35 +324,6 @@ func getRegenerateAssetsJobsSnapshot() []map[string]any {
return out return out
} }
func regenerateAssetsWorker() {
for {
job := nextQueuedRegenerateAssetsJob()
if job == nil {
time.Sleep(250 * time.Millisecond)
continue
}
// ✅ wartet global, bis kein anderer exklusiver Task läuft
if err := acquireExclusiveTask(job.Ctx); err != nil {
finishRegenerateAssetsJob(job.File, "cancelled", "Abgebrochen")
setRegenerateAssetsTaskError(job.File, "Abgebrochen")
removeRegenerateAssetsJobLater(job.File, 2*time.Second)
continue
}
job = markRegenerateAssetsJobRunning(job.File)
if job == nil {
releaseExclusiveTask()
continue
}
func() {
defer releaseExclusiveTask()
runRegenerateAssetsJob(job)
}()
}
}
func runRegenerateAssetsJob(job *regenerateAssetsJob) { func runRegenerateAssetsJob(job *regenerateAssetsJob) {
file := job.File file := job.File
videoPath := job.VideoPath videoPath := job.VideoPath
@ -385,7 +368,7 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild", "") publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild", "")
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser", "") publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser", "")
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites", "") publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites", "")
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse", "") publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "Analyse", "")
// Alte Assets löschen // Alte Assets löschen
if id != "" { if id != "" {
@ -471,8 +454,8 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites", "") publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites", "")
// 5) KI-Analyse // 5) Analyse
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "KI-Analyse", "") publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "Analyse", "")
if _, err := ensureAnalyzeForVideoCtx(ctx, videoPath, "", "nsfw"); err != nil { if _, err := ensureAnalyzeForVideoCtx(ctx, videoPath, "", "nsfw"); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled { if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
@ -484,11 +467,11 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
} }
if !regeneratePhaseTruthForVideo(videoPath, id).AnalyzeReady { if !regeneratePhaseTruthForVideo(videoPath, id).AnalyzeReady {
failJob("enrich", "analyze", "KI-Analyse fehlt oder wurde nicht in meta.json gespeichert") failJob("enrich", "analyze", "Analyse fehlt oder wurde nicht in meta.json gespeichert")
return return
} }
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "KI-Analyse", "") publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "Analyse", "")
finishRegenerateAssetsJob(file, "done", "") finishRegenerateAssetsJob(file, "done", "")
setRegenerateAssetsTaskDone(file, "Fertig") setRegenerateAssetsTaskDone(file, "Fertig")
@ -577,15 +560,30 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
return return
} }
ensureRegenerateAssetsWorkersStarted()
regenerateAssetsMu.Lock() regenerateAssetsMu.Lock()
if existing := regenerateAssetsJobs[file]; existing != nil { if existing := regenerateAssetsJobs[file]; existing != nil {
state := existing.State
errText := existing.Error
phaseList := make([]regenerateAssetsPhase, 0, len(existing.Phases))
for _, p := range existing.Phases {
phaseList = append(phaseList, p)
}
regenerateAssetsMu.Unlock() regenerateAssetsMu.Unlock()
w.Header().Set("Content-Type", "application/json") sort.SliceStable(phaseList, func(i, j int) bool {
phases := make([]map[string]any, 0, len(existing.Phases)) oi := regeneratePhaseOrder(phaseList[i].Queue, phaseList[i].Phase)
for _, p := range existing.Phases { oj := regeneratePhaseOrder(phaseList[j].Queue, phaseList[j].Phase)
if oi != oj {
return oi < oj
}
return phaseList[i].TS < phaseList[j].TS
})
phases := make([]map[string]any, 0, len(phaseList))
for _, p := range phaseList {
phases = append(phases, map[string]any{ phases = append(phases, map[string]any{
"queue": p.Queue, "queue": p.Queue,
"phase": p.Phase, "phase": p.Phase,
@ -595,11 +593,12 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
}) })
} }
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
"ok": true, "ok": true,
"file": file, "file": file,
"state": existing.State, "state": state,
"error": existing.Error, "error": errText,
"phases": phases, "phases": phases,
}) })
return return
@ -624,13 +623,39 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
Phases: make(map[string]regenerateAssetsPhase), Phases: make(map[string]regenerateAssetsPhase),
} }
enqueueRegenerateAssetsJob(job) regenerateAssetsMu.Lock()
regenerateAssetsJobs[file] = job
regenerateAssetsMu.Unlock()
publishFinishedPostworkPhase(file, id, "postwork", "meta", "queued", "Meta", "") publishFinishedPostworkPhase(file, id, "postwork", "meta", "queued", "Meta", "")
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild", "") publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild", "")
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser", "") publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser", "")
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites", "") publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites", "")
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse", "") publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "Analyse", "")
go func(file string, runCtx context.Context) {
if err := acquireExclusiveTask(runCtx); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
finishRegenerateAssetsJob(file, "cancelled", "Abgebrochen")
setRegenerateAssetsTaskError(file, "Abgebrochen")
removeRegenerateAssetsJobLater(file, 2*time.Second)
return
}
finishRegenerateAssetsJob(file, "error", err.Error())
setRegenerateAssetsTaskError(file, err.Error())
removeRegenerateAssetsJobLater(file, 5*time.Second)
return
}
defer releaseExclusiveTask()
runningJob := markRegenerateAssetsJobRunning(file)
if runningJob == nil {
return
}
runRegenerateAssetsJob(runningJob)
}(file, runCtx)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{

View File

@ -7,7 +7,6 @@ import (
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
) )
type releaseFileTasksRequest struct { type releaseFileTasksRequest struct {
@ -44,20 +43,24 @@ func sameBaseFile(a, b string) bool {
} }
func cancelAssetsTaskForFile(file string) bool { func cancelAssetsTaskForFile(file string) bool {
file = strings.TrimSpace(file)
if file == "" {
return false
}
assetsTaskMu.Lock() assetsTaskMu.Lock()
cancel := assetsTaskCancel active := assetsTaskState.Running || assetsTaskState.Queued
running := assetsTaskState.Running
currentFile := assetsTaskState.CurrentFile
assetsTaskMu.Unlock() assetsTaskMu.Unlock()
if !running || cancel == nil { if !active {
return false
}
if !sameBaseFile(currentFile, file) {
return false return false
} }
cancel() // Wichtig:
// NICHT den globalen Assets-Task canceln.
// Stattdessen nur diese Datei im Assets-Task zum Überspringen markieren.
_ = requestAssetsTaskSkip(file)
return true return true
} }
@ -183,11 +186,6 @@ func releaseFileTasks(file string) releaseFileTasksResponse {
resp.PostworkCancelled || resp.PostworkCancelled ||
resp.EnrichCancelled resp.EnrichCancelled
// ffmpeg / Analyse / Sprite-Generierung einen Moment zum Beenden geben
if resp.ReleasedAny {
time.Sleep(900 * time.Millisecond)
}
return resp return resp
} }

View File

@ -11,13 +11,29 @@ import (
) )
type CleanupTaskState struct { type CleanupTaskState struct {
Running bool `json:"running"` Queued bool `json:"queued"`
Done int `json:"done"` Running bool `json:"running"`
Total int `json:"total"` QueuedAt time.Time `json:"queuedAt"`
Text string `json:"text,omitempty"` StartedAt time.Time `json:"startedAt"`
CurrentFile string `json:"currentFile,omitempty"` FinishedAt *time.Time `json:"finishedAt,omitempty"`
Error string `json:"error,omitempty"` Text string `json:"text,omitempty"`
FinishedAt *string `json:"finishedAt,omitempty"` Error string `json:"error,omitempty"`
CurrentFile string `json:"currentFile,omitempty"`
Done int `json:"done"`
Total int `json:"total"`
}
type CheckVideosTaskState struct {
Queued bool `json:"queued"`
Running bool `json:"running"`
QueuedAt time.Time `json:"queuedAt"`
StartedAt time.Time `json:"startedAt"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
Text string `json:"text,omitempty"`
Error string `json:"error,omitempty"`
CurrentFile string `json:"currentFile,omitempty"`
Done int `json:"done"`
Total int `json:"total"`
} }
type RegenerateAssetsTaskState struct { type RegenerateAssetsTaskState struct {
@ -31,6 +47,7 @@ type TasksStatusResponse struct {
GenerateAssets AssetsTaskState `json:"generateAssets"` GenerateAssets AssetsTaskState `json:"generateAssets"`
RegenerateAssets RegenerateAssetsTaskState `json:"regenerateAssets"` RegenerateAssets RegenerateAssetsTaskState `json:"regenerateAssets"`
RegenerateAssetsJobs []map[string]any `json:"regenerateAssetsJobs"` RegenerateAssetsJobs []map[string]any `json:"regenerateAssetsJobs"`
CheckVideos CheckVideosTaskState `json:"checkVideos"`
Cleanup CleanupTaskState `json:"cleanup"` Cleanup CleanupTaskState `json:"cleanup"`
AnyRunning bool `json:"anyRunning"` AnyRunning bool `json:"anyRunning"`
} }
@ -43,9 +60,9 @@ var (
regenerateAssetsTaskState RegenerateAssetsTaskState regenerateAssetsTaskState RegenerateAssetsTaskState
) )
func cleanupNowISO() *string { func cleanupNowTime() *time.Time {
s := time.Now().Format(time.RFC3339) t := time.Now()
return &s return &t
} }
func anyToString(v any) string { func anyToString(v any) string {
@ -120,16 +137,45 @@ func snapshotCleanupTaskState() CleanupTaskState {
return st return st
} }
func setCleanupTaskRunning(text string) { func setCleanupTaskQueued(text string) {
cleanupTaskMu.Lock() cleanupTaskMu.Lock()
cleanupTaskState = CleanupTaskState{ cleanupTaskState = CleanupTaskState{
Running: true, Queued: true,
Running: false,
QueuedAt: time.Now(),
StartedAt: time.Time{},
FinishedAt: nil,
Text: strings.TrimSpace(text),
Error: "",
CurrentFile: "",
Done: 0, Done: 0,
Total: 0, Total: 0,
Text: strings.TrimSpace(text), }
CurrentFile: "", cleanupTaskMu.Unlock()
Error: "",
publishTaskState()
}
func setCleanupTaskRunning(text string) {
cleanupTaskMu.Lock()
now := time.Now()
queuedAt := cleanupTaskState.QueuedAt
if queuedAt.IsZero() {
queuedAt = now
}
cleanupTaskState = CleanupTaskState{
Queued: false,
Running: true,
QueuedAt: queuedAt,
StartedAt: now,
FinishedAt: nil, FinishedAt: nil,
Text: strings.TrimSpace(text),
Error: "",
CurrentFile: "",
Done: 0,
Total: 0,
} }
cleanupTaskMu.Unlock() cleanupTaskMu.Unlock()
@ -138,6 +184,16 @@ func setCleanupTaskRunning(text string) {
func setCleanupTaskProgress(done, total int, currentFile, text string) { func setCleanupTaskProgress(done, total int, currentFile, text string) {
cleanupTaskMu.Lock() cleanupTaskMu.Lock()
now := time.Now()
if cleanupTaskState.QueuedAt.IsZero() {
cleanupTaskState.QueuedAt = now
}
if cleanupTaskState.StartedAt.IsZero() {
cleanupTaskState.StartedAt = now
}
cleanupTaskState.Queued = false
cleanupTaskState.Running = true cleanupTaskState.Running = true
cleanupTaskState.Done = done cleanupTaskState.Done = done
cleanupTaskState.Total = total cleanupTaskState.Total = total
@ -145,6 +201,7 @@ func setCleanupTaskProgress(done, total int, currentFile, text string) {
cleanupTaskState.Text = strings.TrimSpace(text) cleanupTaskState.Text = strings.TrimSpace(text)
cleanupTaskState.Error = "" cleanupTaskState.Error = ""
cleanupTaskState.FinishedAt = nil cleanupTaskState.FinishedAt = nil
cleanupTaskMu.Unlock() cleanupTaskMu.Unlock()
publishTaskState() publishTaskState()
@ -152,11 +209,13 @@ func setCleanupTaskProgress(done, total int, currentFile, text string) {
func setCleanupTaskDone(text string) { func setCleanupTaskDone(text string) {
cleanupTaskMu.Lock() cleanupTaskMu.Lock()
cleanupTaskState.Queued = false
cleanupTaskState.Running = false cleanupTaskState.Running = false
cleanupTaskState.Text = strings.TrimSpace(text) cleanupTaskState.Text = strings.TrimSpace(text)
cleanupTaskState.CurrentFile = "" cleanupTaskState.CurrentFile = ""
cleanupTaskState.Error = "" cleanupTaskState.Error = ""
cleanupTaskState.FinishedAt = cleanupNowISO() cleanupTaskState.FinishedAt = cleanupNowTime()
if cleanupTaskState.Total > 0 { if cleanupTaskState.Total > 0 {
cleanupTaskState.Done = cleanupTaskState.Total cleanupTaskState.Done = cleanupTaskState.Total
@ -169,11 +228,14 @@ func setCleanupTaskDone(text string) {
func setCleanupTaskError(errText string) { func setCleanupTaskError(errText string) {
cleanupTaskMu.Lock() cleanupTaskMu.Lock()
cleanupTaskState.Queued = false
cleanupTaskState.Running = false cleanupTaskState.Running = false
cleanupTaskState.Text = "" cleanupTaskState.Text = ""
cleanupTaskState.Error = strings.TrimSpace(errText) cleanupTaskState.Error = strings.TrimSpace(errText)
cleanupTaskState.CurrentFile = "" cleanupTaskState.CurrentFile = ""
cleanupTaskState.FinishedAt = cleanupNowISO() cleanupTaskState.FinishedAt = cleanupNowTime()
cleanupTaskMu.Unlock() cleanupTaskMu.Unlock()
publishTaskState() publishTaskState()
@ -188,6 +250,7 @@ func tasksStatusHandler(w http.ResponseWriter, r *http.Request) {
assets := getGenerateAssetsTaskStatus() assets := getGenerateAssetsTaskStatus()
regenerate := snapshotRegenerateAssetsTaskState() regenerate := snapshotRegenerateAssetsTaskState()
regenerateJobs := getRegenerateAssetsJobsSnapshot() regenerateJobs := getRegenerateAssetsJobsSnapshot()
checkVideos := snapshotCheckVideosTaskState()
cleanup := snapshotCleanupTaskState() cleanup := snapshotCleanupTaskState()
anyRegenerateRunning := regenerate.Running anyRegenerateRunning := regenerate.Running
@ -205,8 +268,9 @@ func tasksStatusHandler(w http.ResponseWriter, r *http.Request) {
GenerateAssets: assets, GenerateAssets: assets,
RegenerateAssets: regenerate, RegenerateAssets: regenerate,
RegenerateAssetsJobs: regenerateJobs, RegenerateAssetsJobs: regenerateJobs,
CheckVideos: checkVideos,
Cleanup: cleanup, Cleanup: cleanup,
AnyRunning: assets.Queued || assets.Running || anyRegenerateRunning || cleanup.Running, AnyRunning: assets.Queued || assets.Running || anyRegenerateRunning || checkVideos.Queued || checkVideos.Running || cleanup.Queued || cleanup.Running,
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")

567
backend/teaser.go Normal file
View File

@ -0,0 +1,567 @@
// backend\teaser.go
package main
import (
"bufio"
"bytes"
"context"
"fmt"
"math"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
// ============================================================
// Teaser generation (preview.mp4)
// Route:
// - /api/generated/teaser?id=<assetID>
// ============================================================
const minSegmentDuration = 0.75
const defaultTeaserSegments = 18
type TeaserPreviewOptions struct {
Segments int
SegmentDuration float64
Width int
Preset string
CRF int
Audio bool
AudioBitrate string
UseVsync2 bool
}
func (o TeaserPreviewOptions) stepSizeAndOffset(dur float64) (float64, float64) {
if dur <= 0 {
return 0, 0
}
n := o.Segments
if n < 1 {
n = 1
}
segDur := o.SegmentDuration
if segDur <= 0 {
segDur = 1
}
if segDur < minSegmentDuration {
segDur = minSegmentDuration
}
maxStart := dur - 0.05 - segDur
if maxStart < 0 {
maxStart = 0
}
if n == 1 {
return 0, maxStart * 0.5
}
margin := 0.05 * maxStart
if margin < 0 {
margin = 0
}
span := maxStart - 2*margin
if span < 0 {
span = maxStart
margin = 0
}
step := 0.0
if n > 1 {
step = span / float64(n-1)
}
return step, margin
}
func generateTeaserClipsMP4(ctx context.Context, srcPath, outPath string, clipLenSec float64, maxClips int) error {
return generateTeaserClipsMP4WithProgress(ctx, srcPath, outPath, clipLenSec, maxClips, nil)
}
func generateTeaserClipsMP4WithProgress(
ctx context.Context,
srcPath, outPath string,
clipLenSec float64,
maxClips int,
onRatio func(r float64),
) error {
opts := TeaserPreviewOptions{
Segments: maxClips,
SegmentDuration: clipLenSec,
Width: 640,
Preset: "veryfast",
CRF: 21,
Audio: true,
AudioBitrate: "128k",
UseVsync2: false,
}
return generateTeaserPreviewMP4WithProgress(ctx, srcPath, outPath, opts, onRatio)
}
func generateTeaserChunkMP4(
ctx context.Context,
src, out string,
start, dur float64,
opts TeaserPreviewOptions,
) error {
opts.Audio = true
tmp := strings.TrimSuffix(out, ".mp4") + ".part.mp4"
segDur := dur
if segDur < minSegmentDuration {
segDur = minSegmentDuration
}
args := []string{"-y", "-hide_banner", "-loglevel", "error"}
args = append(args, ffmpegInputTol...)
args = append(args,
"-ss", fmt.Sprintf("%.3f", start),
"-t", fmt.Sprintf("%.3f", segDur),
"-i", src,
"-map", "0:v:0",
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
"-profile:v", "high",
"-level", "4.2",
"-preset", opts.Preset,
"-crf", strconv.Itoa(opts.CRF),
"-threads", "2",
)
if opts.UseVsync2 {
args = append(args, "-vsync", "2")
}
args = append(args,
"-map", "0:a:0",
"-c:a", "aac",
"-b:a", opts.AudioBitrate,
"-ac", "2",
"-shortest",
)
args = append(args, "-movflags", "+faststart", tmp)
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("ffmpeg teaser chunk failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
}
_ = os.Remove(out)
return os.Rename(tmp, out)
}
func computeTeaserStarts(dur float64, opts TeaserPreviewOptions) (starts []float64, segDur float64, usedSegments int) {
if opts.SegmentDuration <= 0 {
opts.SegmentDuration = 1
}
if opts.Segments <= 0 {
opts.Segments = defaultTeaserSegments
}
segDur = opts.SegmentDuration
if segDur < minSegmentDuration {
segDur = minSegmentDuration
}
if dur > 0 && segDur > dur {
segDur = dur
}
usedSegments = opts.Segments
if !(dur > 0) {
return []float64{0}, segDur, 1
}
stepSize, offset := opts.stepSizeAndOffset(dur)
starts = make([]float64, 0, opts.Segments)
for i := 0; i < opts.Segments; i++ {
t := offset + float64(i)*stepSize
maxStart := math.Max(0, dur-0.05-segDur)
if t < 0 {
t = 0
}
if t > maxStart {
t = maxStart
}
if t < 0.05 {
t = 0.05
}
starts = append(starts, t)
}
return starts, segDur, usedSegments
}
func generateTeaserPreviewMP4WithProgress(
ctx context.Context,
srcPath, outPath string,
opts TeaserPreviewOptions,
onRatio func(r float64),
) error {
opts.Audio = true
if opts.SegmentDuration <= 0 {
opts.SegmentDuration = 1
}
if opts.Segments <= 0 {
opts.Segments = defaultTeaserSegments
}
if opts.Width <= 0 {
opts.Width = 640
}
if opts.Preset == "" {
opts.Preset = "veryfast"
}
if opts.CRF <= 0 {
opts.CRF = 21
}
if opts.AudioBitrate == "" {
opts.AudioBitrate = "128k"
}
segDur := opts.SegmentDuration
if segDur < minSegmentDuration {
segDur = minSegmentDuration
}
dur, _ := durationSecondsCached(ctx, srcPath)
if dur > 0 && dur < segDur*float64(opts.Segments) {
opts.Segments = 1
segDur = dur
}
if !(dur > 0) {
if onRatio != nil {
onRatio(0)
}
err := generateTeaserChunkMP4(ctx, srcPath, outPath, 0, math.Min(8, segDur), opts)
if onRatio != nil {
onRatio(1)
}
return err
}
starts, segDurComputed, _ := computeTeaserStarts(dur, opts)
segDur = segDurComputed
expectedOutSec := float64(len(starts)) * segDur
tmp := strings.TrimSuffix(outPath, ".mp4") + ".part.mp4"
args := []string{"-y", "-nostats", "-progress", "pipe:1", "-hide_banner", "-loglevel", "error"}
for _, t := range starts {
args = append(args, ffmpegInputTol...)
args = append(args, "-ss", fmt.Sprintf("%.3f", t), "-t", fmt.Sprintf("%.3f", segDur), "-i", srcPath)
}
var fc strings.Builder
for i := range starts {
fmt.Fprintf(&fc, "[%d:v]scale=%d:-2,setsar=1,setpts=PTS-STARTPTS[v%d];", i, opts.Width, i)
fmt.Fprintf(&fc, "[%d:a]aresample=48000,aformat=channel_layouts=stereo,asetpts=PTS-STARTPTS[a%d];", i, i)
}
for i := range starts {
fmt.Fprintf(&fc, "[v%d][a%d]", i, i)
}
fmt.Fprintf(&fc, "concat=n=%d:v=1:a=1[v][a]", len(starts))
args = append(args, "-filter_complex", fc.String())
args = append(args, "-map", "[v]", "-map", "[a]")
args = append(args,
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
"-profile:v", "high",
"-level", "4.2",
"-preset", opts.Preset,
"-crf", strconv.Itoa(opts.CRF),
"-threads", "2",
)
if opts.UseVsync2 {
args = append(args, "-vsync", "2")
}
args = append(args,
"-c:a", "aac",
"-b:a", opts.AudioBitrate,
"-ac", "2",
"-shortest",
)
args = append(args, "-movflags", "+faststart", tmp)
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return err
}
sc := bufio.NewScanner(stdout)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var outSec float64
var lastSent float64
var lastAt time.Time
send := func(outSec float64, force bool) {
if onRatio == nil {
return
}
if expectedOutSec > 0 && outSec > 0 {
r := outSec / expectedOutSec
if r < 0 {
r = 0
}
if r > 1 {
r = 1
}
if r-lastSent < 0.01 && !force {
return
}
if !lastAt.IsZero() && time.Since(lastAt) < 150*time.Millisecond && !force {
return
}
lastSent = r
lastAt = time.Now()
onRatio(r)
return
}
if force {
onRatio(1)
}
}
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
k, v := parts[0], parts[1]
switch k {
case "out_time_ms":
if n, perr := strconv.ParseInt(strings.TrimSpace(v), 10, 64); perr == nil && n > 0 {
outSec = float64(n) / 1_000_000.0
send(outSec, false)
}
case "out_time":
if s := parseFFmpegOutTime(v); s > 0 {
outSec = s
send(outSec, false)
}
case "progress":
if strings.TrimSpace(v) == "end" {
send(outSec, true)
}
}
}
if err := cmd.Wait(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("ffmpeg teaser preview failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
}
_ = os.Remove(outPath)
return os.Rename(tmp, outPath)
}
func serveTeaserFile(w http.ResponseWriter, r *http.Request, path string) {
f, err := openForReadShareDelete(path)
if err != nil {
http.Error(w, "datei öffnen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
defer f.Close()
fi, err := f.Stat()
if err != nil || fi.IsDir() || fi.Size() == 0 {
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
return
}
w.Header().Set("Cache-Control", "public, max-age=31536000")
w.Header().Set("Content-Type", "video/mp4")
http.ServeContent(w, r, filepath.Base(path), fi.ModTime(), f)
}
// tolerante Input-Flags für kaputte/abgeschnittene H264/TS Streams
var ffmpegInputTol = []string{
"-fflags", "+discardcorrupt+genpts",
"-err_detect", "ignore_err",
"-max_error_rate", "1.0",
}
func generateTeaserMP4(ctx context.Context, srcPath, outPath string, startSec, durSec float64) error {
if durSec <= 0 {
durSec = 8
}
if startSec < 0 {
startSec = 0
}
tmp := outPath + ".tmp.mp4"
args := []string{
"-y",
"-hide_banner",
"-loglevel", "error",
}
args = append(args, ffmpegInputTol...)
args = append(args,
"-ss", fmt.Sprintf("%.3f", startSec),
"-i", srcPath,
"-t", fmt.Sprintf("%.3f", durSec),
"-vf", "scale=720:-2",
"-map", "0:v:0",
"-map", "0:a:0",
"-c:a", "aac",
"-b:a", "128k",
"-ac", "2",
"-c:v", "libx264",
"-preset", "veryfast",
"-threads", "2",
"-crf", "28",
"-pix_fmt", "yuv420p",
"-shortest",
"-movflags", "+faststart",
"-f", "mp4",
"-threads", "2",
tmp,
)
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
if out, err := cmd.CombinedOutput(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("ffmpeg teaser failed: %v (%s)", err, strings.TrimSpace(string(out)))
}
_ = os.Remove(outPath)
return os.Rename(tmp, outPath)
}
func generatedTeaser(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
http.Error(w, "id fehlt", http.StatusBadRequest)
return
}
var err error
id, err = sanitizeID(id)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
outPath, err := findFinishedFileByID(id)
if err != nil {
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
return
}
if err := ensureGeneratedDirs(); err != nil {
http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError)
return
}
assetID := stripHotPrefix(id)
if assetID == "" {
assetID = id
}
assetDir, err := ensureGeneratedDir(assetID)
if err != nil {
http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError)
return
}
previewPath := filepath.Join(assetDir, "preview.mp4")
qNoGen := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("noGenerate")))
noGen := qNoGen == "1" || qNoGen == "true" || qNoGen == "yes"
if fi, err := os.Stat(previewPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
serveTeaserFile(w, r, previewPath)
return
}
if teaserLegacy, _ := generatedTeaserRoot(); strings.TrimSpace(teaserLegacy) != "" {
cids := []string{assetID, id}
for _, cid := range cids {
candidates := []string{
filepath.Join(teaserLegacy, cid+"_teaser.mp4"),
filepath.Join(teaserLegacy, cid+".mp4"),
}
for _, c := range candidates {
if fi, err := os.Stat(c); err == nil && !fi.IsDir() && fi.Size() > 0 {
if _, err2 := os.Stat(previewPath); os.IsNotExist(err2) {
_ = os.MkdirAll(filepath.Dir(previewPath), 0o755)
_ = os.Rename(c, previewPath)
}
if fi2, err2 := os.Stat(previewPath); err2 == nil && !fi2.IsDir() && fi2.Size() > 0 {
serveTeaserFile(w, r, previewPath)
return
}
serveTeaserFile(w, r, c)
return
}
}
}
}
if noGen {
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
return
}
if err := genSem.Acquire(r.Context()); err != nil {
http.Error(w, "abgebrochen: "+err.Error(), http.StatusRequestTimeout)
return
}
defer genSem.Release()
genCtx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
if err := generateTeaserClipsMP4(genCtx, outPath, previewPath, minSegmentDuration, defaultTeaserSegments); err != nil {
if err2 := generateTeaserMP4(genCtx, outPath, previewPath, 0, 8); err2 != nil {
http.Error(
w,
"konnte preview nicht erzeugen: "+err.Error()+" (fallback ebenfalls fehlgeschlagen: "+err2.Error()+")",
http.StatusInternalServerError,
)
return
}
}
serveTeaserFile(w, r, previewPath)
}

226
backend/tray.go Normal file
View File

@ -0,0 +1,226 @@
// backend\tray.go
package main
import (
_ "embed"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/getlantern/systray"
)
//go:embed assets/img/tray.ico
var trayIcon []byte
func runTray(onQuit func(), statsFn func() TrayStats) {
systray.Run(func() {
if len(trayIcon) > 0 {
systray.SetIcon(trayIcon)
}
systray.SetTitle("Recorder")
systray.SetTooltip("Recorder Backend läuft")
mStatus := systray.AddMenuItem("Status wird geladen…", "Aktueller Recorder-Status")
mStatus.Disable()
mAutostart := systray.AddMenuItem("Autostart: …", "Autostart-Status")
mAutostart.Disable()
mDownloads := systray.AddMenuItem("Downloads: …", "Download-Status")
mDownloads.Disable()
mPostwork := systray.AddMenuItem("Postwork: …", "Postwork-Status")
mPostwork.Disable()
mEnrich := systray.AddMenuItem("Enrich: …", "Enrich-Status")
mEnrich.Disable()
mDisk := systray.AddMenuItem("Freier Speicher: …", "Speicherplatz")
mDisk.Disable()
mUptime := systray.AddMenuItem("Uptime: …", "Laufzeit")
mUptime.Disable()
systray.AddSeparator()
mOpenFrontend := systray.AddMenuItem("Frontend öffnen", "Öffnet das Web-Frontend")
mOpenRecordDir := systray.AddMenuItem("Record-Ordner öffnen", "Öffnet den Aufnahmeordner")
mOpenDoneDir := systray.AddMenuItem("Done-Ordner öffnen", "Öffnet den Done-Ordner")
systray.AddSeparator()
mToggleAutostart := systray.AddMenuItem("Autostart pausieren", "Autostart pausieren oder fortsetzen")
mStopAll := systray.AddMenuItem(
"Alle Downloads stoppen + Autostart pausieren",
"Stoppt alle aktiven Downloads und pausiert den Autostart",
)
systray.AddSeparator()
mQuit := systray.AddMenuItem("Beenden", "Backend sauber beenden")
updateStatus := func() {
if statsFn == nil {
return
}
stats := statsFn()
mStatus.SetTitle(stats.MenuTitle())
mAutostart.SetTitle(stats.AutostartLine())
mDownloads.SetTitle(stats.DownloadsLine())
mPostwork.SetTitle(stats.PostworkLine())
mEnrich.SetTitle(stats.EnrichLine())
mDisk.SetTitle(stats.DiskLine())
mUptime.SetTitle(stats.UptimeLine())
mToggleAutostart.SetTitle(stats.ToggleAutostartTitle())
if stats.AutostartPausedByDisk {
mToggleAutostart.Disable()
} else {
mToggleAutostart.Enable()
}
if stats.ActiveDownloads > 0 || !stats.AutostartPaused {
mStopAll.Enable()
} else {
mStopAll.Disable()
}
systray.SetTooltip(stats.Tooltip())
}
updateStatus()
go func() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
updateStatus()
case <-mOpenFrontend.ClickedCh:
_ = openBrowser("http://localhost:9999")
case <-mOpenRecordDir.ClickedCh:
_ = openFolder(resolveConfiguredDir(getSettings().RecordDir))
case <-mOpenDoneDir.ClickedCh:
_ = openFolder(resolveConfiguredDir(getSettings().DoneDir))
case <-mToggleAutostart.ClickedCh:
state := getAutostartStatePayload()
if state.PausedByDisk {
updateStatus()
continue
}
if state.Paused {
setAutostartPaused(false)
} else {
setAutostartPaused(true)
}
updateStatus()
case <-mStopAll.ClickedCh:
_ = stopAllDownloadsAndPauseAutostart()
updateStatus()
case <-mQuit.ClickedCh:
onQuit()
systray.Quit()
return
}
}
}()
}, func() {
// optional cleanup
})
}
func stopAllDownloadsAndPauseAutostart() int {
// Immer zuerst Autostart pausieren, damit nichts direkt wieder neu startet.
setAutostartPaused(true)
// Danach aktive Downloads stoppen.
return stopAllActiveDownloads()
}
func stopAllActiveDownloads() int {
jobsMu.RLock()
toStop := make([]*RecordJob, 0, len(jobs))
for _, job := range jobs {
if job == nil {
continue
}
if isPostworkJob(job) {
continue
}
if isTerminalJobStatus(job.Status) {
continue
}
if job.EndedAt != nil {
continue
}
toStop = append(toStop, job)
}
jobsMu.RUnlock()
if len(toStop) > 0 {
stopJobsInternal(toStop)
}
return len(toStop)
}
func resolveConfiguredDir(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if abs, err := resolvePathRelativeToApp(raw); err == nil && strings.TrimSpace(abs) != "" {
return abs
}
return raw
}
func openFolder(dir string) error {
dir = filepath.Clean(strings.TrimSpace(dir))
if dir == "" {
return nil
}
switch runtime.GOOS {
case "windows":
return exec.Command("explorer", dir).Start()
case "darwin":
return exec.Command("open", dir).Start()
default:
return exec.Command("xdg-open", dir).Start()
}
}
func openBrowser(url string) error {
switch runtime.GOOS {
case "windows":
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
case "darwin":
return exec.Command("open", url).Start()
default:
return exec.Command("xdg-open", url).Start()
}
}

232
backend/tray_stats.go Normal file
View File

@ -0,0 +1,232 @@
// backend\tray_stats.go
package main
import (
"fmt"
"strconv"
"strings"
"time"
godisk "github.com/shirou/gopsutil/v3/disk"
)
type TrayStats struct {
ActiveDownloads int
DownloadLimitEnabled bool
DownloadLimitMax int
DownloadLimitReached bool
PostworkWaiting int
PostworkRunning int
EnrichWaiting int
EnrichRunning int
AutostartPaused bool
AutostartPausedByUser bool
AutostartPausedByDisk bool
DiskFreeBytes uint64
HasDiskInfo bool
DiskEmergency bool
Uptime time.Duration
}
func buildTrayStats() TrayStats {
active := activeRecordingJobsCount()
limitEnabled, limitMax, _, limitReached := concurrentDownloadsLimitState()
if limitMax < 1 {
limitMax = 1
}
pwWaiting, pwRunning, _ := postWorkQ.Stats()
enWaiting, enRunning, _ := enrichQ.Stats()
auto := getAutostartStatePayload()
diskFree, hasDisk := getTrayDiskFreeBytes()
return TrayStats{
ActiveDownloads: active,
DownloadLimitEnabled: limitEnabled,
DownloadLimitMax: limitMax,
DownloadLimitReached: limitReached,
PostworkWaiting: pwWaiting,
PostworkRunning: pwRunning,
EnrichWaiting: enWaiting,
EnrichRunning: enRunning,
AutostartPaused: auto.Paused,
AutostartPausedByUser: auto.PausedByUser,
AutostartPausedByDisk: auto.PausedByDisk,
DiskFreeBytes: diskFree,
HasDiskInfo: hasDisk,
DiskEmergency: diskEmergencyActive(),
Uptime: time.Since(serverStartedAt).Round(time.Second),
}
}
func getTrayDiskFreeBytes() (uint64, bool) {
s := getSettings()
recordDir, err := resolvePathRelativeToApp(s.RecordDir)
if err != nil || strings.TrimSpace(recordDir) == "" {
recordDir = strings.TrimSpace(s.RecordDir)
}
if strings.TrimSpace(recordDir) == "" {
return 0, false
}
u, err := godisk.Usage(recordDir)
if err != nil || u == nil {
return 0, false
}
return u.Free, true
}
func diskEmergencyActive() bool {
return isAutostartPaused() && getAutostartStatePayload().PausedByDisk
}
func (s TrayStats) MenuTitle() string {
return fmt.Sprintf(
"Aktiv: %d • PW: %d/%d • Enrich: %d/%d",
s.ActiveDownloads,
s.PostworkRunning,
s.PostworkWaiting,
s.EnrichRunning,
s.EnrichWaiting,
)
}
func (s TrayStats) Tooltip() string {
return fmt.Sprintf(
"Recorder | Downloads %s | PW %d/%d | Enrich %d/%d",
s.downloadLimitText(),
s.PostworkRunning,
s.PostworkWaiting,
s.EnrichRunning,
s.EnrichWaiting,
)
}
func (s TrayStats) AutostartLine() string {
switch {
case s.AutostartPausedByDisk:
return "Autostart: Pausiert (Disk-Notbremse)"
case s.AutostartPausedByUser:
return "Autostart: Pausiert"
default:
return "Autostart: Aktiv"
}
}
func (s TrayStats) DownloadsLine() string {
if s.DownloadLimitEnabled {
if s.DownloadLimitReached {
return fmt.Sprintf(
"Downloads: %d/%d aktiv (Limit erreicht)",
s.ActiveDownloads,
s.DownloadLimitMax,
)
}
return fmt.Sprintf(
"Downloads: %d/%d aktiv",
s.ActiveDownloads,
s.DownloadLimitMax,
)
}
return fmt.Sprintf("Downloads: %d aktiv (kein Limit)", s.ActiveDownloads)
}
func (s TrayStats) PostworkLine() string {
return fmt.Sprintf(
"Postwork: %d läuft • %d wartend",
s.PostworkRunning,
s.PostworkWaiting,
)
}
func (s TrayStats) EnrichLine() string {
return fmt.Sprintf(
"Enrich: %d läuft • %d wartend",
s.EnrichRunning,
s.EnrichWaiting,
)
}
func (s TrayStats) DiskLine() string {
if !s.HasDiskInfo {
return "Freier Speicher: unbekannt"
}
if s.DiskEmergency {
return fmt.Sprintf(
"Freier Speicher: %s (kritisch)",
formatTrayBytes(s.DiskFreeBytes),
)
}
return fmt.Sprintf(
"Freier Speicher: %s",
formatTrayBytes(s.DiskFreeBytes),
)
}
func (s TrayStats) UptimeLine() string {
return "Uptime: " + formatTrayDuration(s.Uptime)
}
func (s TrayStats) ToggleAutostartTitle() string {
if s.AutostartPausedByDisk {
return "Autostart fortsetzen (gesperrt: Disk)"
}
if s.AutostartPaused {
return "Autostart fortsetzen"
}
return "Autostart pausieren"
}
func (s TrayStats) downloadLimitText() string {
if s.DownloadLimitEnabled {
return fmt.Sprintf("%d/%d", s.ActiveDownloads, s.DownloadLimitMax)
}
return strconv.Itoa(s.ActiveDownloads)
}
func formatTrayDuration(d time.Duration) string {
if d < 0 {
d = 0
}
total := int(d.Seconds())
h := total / 3600
m := (total % 3600) / 60
s := total % 60
return fmt.Sprintf("%02d:%02d:%02d", h, m, s)
}
func formatTrayBytes(b uint64) string {
const unit = 1000
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := uint64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "kMGTPE"[exp])
}

View File

@ -9,7 +9,7 @@ Currently, two official plugins are available:
## React Compiler ## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://dev/learn/react-compiler/installation).
## Expanding the ESLint configuration ## Expanding the ESLint configuration

View File

@ -1,6 +1,6 @@
// frontend\src\App.tsx // frontend\src\App.tsx
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from 'react'
import './App.css' import './App.css'
import Button from './components/ui/Button' import Button from './components/ui/Button'
import CookieModal from './components/ui/CookieModal' import CookieModal from './components/ui/CookieModal'
@ -26,7 +26,6 @@ import {
} from '@heroicons/react/24/solid' } from '@heroicons/react/24/solid'
import PerformanceMonitor from './components/ui/PerformanceMonitor' import PerformanceMonitor from './components/ui/PerformanceMonitor'
import { useNotify } from './components/ui/notify' import { useNotify } from './components/ui/notify'
//import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller'
import CategoriesTab from './components/ui/CategoriesTab' import CategoriesTab from './components/ui/CategoriesTab'
import LoginPage from './components/ui/LoginPage' import LoginPage from './components/ui/LoginPage'
import VideoSplitModal from './components/ui/VideoSplitModal' import VideoSplitModal from './components/ui/VideoSplitModal'
@ -125,6 +124,25 @@ type TaskStateEvent = {
ts?: number ts?: number
} }
type BackgroundSplitProgress = {
phase: 'preparing' | 'splitting' | 'done' | 'error'
total: number
current: number
completed: number
segmentProgress?: number
overallProgress?: number
message?: string
segment?: {
index: number
label: string
start: number
end: number
duration: number
}
startedAtMs: number
sourceFile: string
}
const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = { const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
recordDir: 'records', recordDir: 'records',
doneDir: 'records/done', doneDir: 'records/done',
@ -602,6 +620,61 @@ function upsertJobById(list: RecordJob[], incoming: RecordJob): RecordJob[] {
} }
export default function App() { export default function App() {
const [splitProgressByFile, setSplitProgressByFile] = useState<Record<string, BackgroundSplitProgress>>({})
const splitProgressClearTimersRef = useRef<Record<string, number>>({})
useEffect(() => {
return () => {
for (const t of Object.values(splitProgressClearTimersRef.current)) {
window.clearTimeout(t)
}
}
}, [])
const clearSplitProgressTimer = useCallback((file: string) => {
const t = splitProgressClearTimersRef.current[file]
if (t != null) {
window.clearTimeout(t)
delete splitProgressClearTimersRef.current[file]
}
}, [])
const scheduleSplitProgressCleanup = useCallback((file: string, delayMs: number) => {
clearSplitProgressTimer(file)
splitProgressClearTimersRef.current[file] = window.setTimeout(() => {
setSplitProgressByFile((prev) => {
if (!(file in prev)) return prev
const next = { ...prev }
delete next[file]
return next
})
delete splitProgressClearTimersRef.current[file]
}, delayMs)
}, [clearSplitProgressTimer])
const pushSplitProgress = useCallback((
file: string,
progress: Omit<BackgroundSplitProgress, 'sourceFile' | 'startedAtMs'>
) => {
clearSplitProgressTimer(file)
setSplitProgressByFile((prev) => ({
...prev,
[file]: {
sourceFile: file,
startedAtMs: prev[file]?.startedAtMs ?? Date.now(),
...progress,
},
}))
if (progress.phase === 'done') {
scheduleSplitProgressCleanup(file, 8000)
} else if (progress.phase === 'error') {
scheduleSplitProgressCleanup(file, 15000)
}
}, [clearSplitProgressTimer, scheduleSplitProgressCleanup])
const [authChecked, setAuthChecked] = useState(false) const [authChecked, setAuthChecked] = useState(false)
const [authed, setAuthed] = useState(false) const [authed, setAuthed] = useState(false)
@ -3766,7 +3839,7 @@ export default function App() {
ref={sourceUrlInputRef} ref={sourceUrlInputRef}
value={sourceUrl} value={sourceUrl}
size='sm' size='sm'
onChange={(e) => setSourceUrl(e.target.value)} onChange={(e: { target: { value: SetStateAction<string> } }) => setSourceUrl(e.target.value)}
selectAllOnFocus selectAllOnFocus
selectAllOnMouseDown selectAllOnMouseDown
placeholder="https://…" placeholder="https://…"
@ -3890,6 +3963,7 @@ export default function App() {
setDonePage(1) setDonePage(1)
donePrefetchRef.current = null donePrefetchRef.current = null
}} }}
splitProgressByFile={splitProgressByFile}
/> />
) : null} ) : null}
@ -3952,13 +4026,268 @@ export default function App() {
setSplitModalOpen(false) setSplitModalOpen(false)
setSplitJob(null) setSplitJob(null)
}} }}
onApply={async ({ job, splits, segments }) => { onApply={({ job, splits, segments, deleteOriginal }) => {
console.log('VIDEO_SPLIT_APPLY', { const file = baseName(job.output || '')
jobId: job.id, if (!file) {
output: job.output, throw new Error('Kein Dateiname vorhanden.')
splits, }
segments,
}) const payload =
Array.isArray(segments) && segments.length > 0
? {
file,
segments: segments.map((seg) => ({
start: Number(seg.start),
end: Number(seg.end),
})),
deleteOriginal,
}
: {
file,
splits,
deleteOriginal,
}
const clamp01 = (n: number) => Math.max(0, Math.min(1, n))
const totalFallback =
Array.isArray(segments) && segments.length > 0
? segments.length
: Math.max(0, splits.length + 1)
const selectedSegmentForEvent = (ev: any) => {
const pos = Number(ev?.current ?? 0)
if (!Number.isFinite(pos) || pos < 1 || pos > segments.length) {
return null
}
return segments[pos - 1] ?? null
}
const publish = (
progress: Omit<BackgroundSplitProgress, 'sourceFile' | 'startedAtMs'>
) => {
pushSplitProgress(file, progress)
}
void (async () => {
let buffer = ''
let finalData: any = null
let total = totalFallback
try {
publish({
phase: 'preparing',
total: totalFallback,
current: 0,
completed: 0,
segmentProgress: 0,
overallProgress: 0,
message: 'Split wird vorbereitet…',
})
const res = await fetch('/api/record/split?stream=1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
if (!res.body) {
throw new Error('Split-Stream konnte nicht gelesen werden.')
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
const applyEvent = (ev: any) => {
const type = String(ev?.type ?? '').trim()
switch (type) {
case 'start': {
total = Math.max(1, Number(ev?.total ?? totalFallback))
publish({
phase: 'preparing',
total,
current: 0,
completed: 0,
segmentProgress: 0,
overallProgress: 0,
message: String(ev?.message || 'Split gestartet'),
})
break
}
case 'segment_start': {
const current = Math.max(1, Number(ev?.current ?? 1))
const evTotal = Math.max(1, Number(ev?.total ?? total))
const currentSegment = selectedSegmentForEvent(ev)
publish({
phase: 'splitting',
total: evTotal,
current,
completed: current - 1,
segmentProgress: 0,
overallProgress: clamp01((current - 1) / evTotal),
message: String(ev?.message || `Segment ${current}/${evTotal} wird gesplittet`),
segment: currentSegment
? {
index: currentSegment.index,
label: currentSegment.label,
start: currentSegment.start,
end: currentSegment.end,
duration: currentSegment.duration,
}
: {
index: current,
label: `Segment ${current}`,
start: Number(ev?.start ?? 0),
end: Number(ev?.end ?? 0),
duration: Math.max(0, Number(ev?.end ?? 0) - Number(ev?.start ?? 0)),
},
})
break
}
case 'segment_progress': {
const current = Math.max(1, Number(ev?.current ?? 1))
const evTotal = Math.max(1, Number(ev?.total ?? total))
const segmentProgress = clamp01(Number(ev?.progress ?? 0))
const overallProgress = clamp01(
Number.isFinite(Number(ev?.overall))
? Number(ev?.overall)
: (current - 1 + segmentProgress) / evTotal
)
const currentSegment = selectedSegmentForEvent(ev)
publish({
phase: 'splitting',
total: evTotal,
current,
completed: current - 1,
segmentProgress,
overallProgress,
message: String(ev?.message || `Segment ${current}/${evTotal} wird gesplittet`),
segment: currentSegment
? {
index: currentSegment.index,
label: currentSegment.label,
start: currentSegment.start,
end: currentSegment.end,
duration: currentSegment.duration,
}
: {
index: current,
label: `Segment ${current}`,
start: Number(ev?.start ?? 0),
end: Number(ev?.end ?? 0),
duration: Math.max(0, Number(ev?.end ?? 0) - Number(ev?.start ?? 0)),
},
})
break
}
case 'segment_done': {
const current = Math.max(1, Number(ev?.current ?? 1))
const evTotal = Math.max(1, Number(ev?.total ?? total))
const currentSegment = selectedSegmentForEvent(ev)
publish({
phase: current >= evTotal ? 'done' : 'splitting',
total: evTotal,
current,
completed: current,
segmentProgress: 1,
overallProgress: clamp01(current / evTotal),
message: String(ev?.message || `Segment ${current}/${evTotal} fertig`),
segment: currentSegment
? {
index: currentSegment.index,
label: currentSegment.label,
start: currentSegment.start,
end: currentSegment.end,
duration: currentSegment.duration,
}
: {
index: current,
label: `Segment ${current}`,
start: Number(ev?.start ?? 0),
end: Number(ev?.end ?? 0),
duration: Math.max(0, Number(ev?.end ?? 0) - Number(ev?.start ?? 0)),
},
})
break
}
case 'error': {
throw new Error(String(ev?.message || 'Split fehlgeschlagen'))
}
case 'done': {
finalData = ev
publish({
phase: 'done',
total: Math.max(1, Number(ev?.total ?? total)),
current: Math.max(1, Number(ev?.total ?? total)),
completed: Math.max(1, Number(ev?.total ?? total)),
segmentProgress: 1,
overallProgress: 1,
message: String(ev?.message || 'Split abgeschlossen'),
})
break
}
}
}
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const rawLine of lines) {
const line = rawLine.trim()
if (!line) continue
applyEvent(JSON.parse(line))
}
}
buffer += decoder.decode()
if (buffer.trim()) {
applyEvent(JSON.parse(buffer.trim()))
}
if (!finalData?.ok) {
throw new Error('Split wurde nicht erfolgreich abgeschlossen.')
}
bumpAssets()
void loadDonePage(donePageRef.current, doneSortRef.current)
void loadDoneCount()
} catch (err) {
const message =
err instanceof Error ? err.message : 'Split fehlgeschlagen.'
publish({
phase: 'error',
total: Math.max(1, totalFallback),
current: 0,
completed: 0,
segmentProgress: 0,
overallProgress: 0,
message,
})
notify.error('Split fehlgeschlagen', `${file}${message}`)
}
})()
}} }}
/> />

View File

@ -1,22 +1,22 @@
// frontend\src\components\ui\Button.tsx // frontend\src\components\ui\Button.tsx
import * as React from 'react' import { type ButtonHTMLAttributes, type ReactNode } from 'react'
type Variant = 'primary' | 'secondary' | 'soft' type Variant = 'primary' | 'secondary' | 'soft'
type Size = 'xs' | 'sm' | 'md' | 'lg' type Size = 'xs' | 'sm' | 'md' | 'lg'
type Color = 'indigo' | 'blue' | 'emerald' | 'red' | 'amber' type Color = 'indigo' | 'blue' | 'emerald' | 'red' | 'amber'
export type ButtonProps = Omit< export type ButtonProps = Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>, ButtonHTMLAttributes<HTMLButtonElement>,
'children' 'children'
> & { > & {
children: React.ReactNode children: ReactNode
variant?: Variant variant?: Variant
size?: Size size?: Size
color?: Color color?: Color
rounded?: 'sm' | 'md' | 'full' rounded?: 'sm' | 'md' | 'full'
leadingIcon?: React.ReactNode leadingIcon?: ReactNode
trailingIcon?: React.ReactNode trailingIcon?: ReactNode
isLoading?: boolean isLoading?: boolean
className?: string className?: string
} }

View File

@ -1,14 +1,14 @@
// components/ui/ButtonGroup.tsx // components/ui/ButtonGroup.tsx
'use client' 'use client'
import * as React from 'react' import { type ReactNode } from 'react'
type Size = 'sm' | 'md' | 'lg' type Size = 'sm' | 'md' | 'lg'
export type ButtonGroupItem = { export type ButtonGroupItem = {
id: string id: string
label?: React.ReactNode label?: ReactNode
icon?: React.ReactNode icon?: ReactNode
srLabel?: string srLabel?: string
disabled?: boolean disabled?: boolean
} }

View File

@ -2,7 +2,7 @@
'use client' 'use client'
import * as React from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import Card from './Card' import Card from './Card'
import Button from './Button' import Button from './Button'
@ -93,25 +93,25 @@ type CoverInfoListItem = {
export default function CategoriesTab() { export default function CategoriesTab() {
const [rows, setRows] = React.useState<TagRow[]>([]) const [rows, setRows] = useState<TagRow[]>([])
const [loading, setLoading] = React.useState(false) const [loading, setLoading] = useState(false)
const [err, setErr] = React.useState<string | null>(null) const [err, setErr] = useState<string | null>(null)
const [coverBust, setCoverBust] = React.useState<number>(() => Date.now()) const [coverBust, setCoverBust] = useState<number>(() => Date.now())
const [coverState, setCoverState] = React.useState<Record<string, 'ok' | 'error'>>({}) const [coverState, setCoverState] = useState<Record<string, 'ok' | 'error'>>({})
const [hasCoverByTag, setHasCoverByTag] = React.useState<Record<string, boolean>>({}) const [hasCoverByTag, setHasCoverByTag] = useState<Record<string, boolean>>({})
const [renewing, setRenewing] = React.useState(false) const [renewing, setRenewing] = useState(false)
const [renewProgress, setRenewProgress] = React.useState<{ done: number; total: number } | null>(null) const [renewProgress, setRenewProgress] = useState<{ done: number; total: number } | null>(null)
// TagLower -> Modelname fürs Cover (UI-Overlay) // TagLower -> Modelname fürs Cover (UI-Overlay)
const [coverModelByTag, setCoverModelByTag] = React.useState<Record<string, string>>({}) const [coverModelByTag, setCoverModelByTag] = useState<Record<string, string>>({})
const refreshAbortRef = React.useRef<AbortController | null>(null) const refreshAbortRef = useRef<AbortController | null>(null)
// TagLower -> outputs[] // TagLower -> outputs[]
const candidatesRef = React.useRef<Record<string, string[]>>({}) const candidatesRef = useRef<Record<string, string[]>>({})
const buildCandidates = React.useCallback((models: StoredModel[], doneJobs: RecordJob[]) => { const buildCandidates = useCallback((models: StoredModel[], doneJobs: RecordJob[]) => {
// 1) modelKeyLower -> tagsLower[] // 1) modelKeyLower -> tagsLower[]
const tagsByModelKey: Record<string, string[]> = {} const tagsByModelKey: Record<string, string[]> = {}
for (const m of Array.isArray(models) ? models : []) { for (const m of Array.isArray(models) ? models : []) {
@ -148,7 +148,7 @@ export default function CategoriesTab() {
candidatesRef.current = out candidatesRef.current = out
}, []) }, [])
const goToFinishedDownloadsWithTag = React.useCallback((tag: string) => { const goToFinishedDownloadsWithTag = useCallback((tag: string) => {
const clean = String(tag || '').trim() const clean = String(tag || '').trim()
if (!clean) return if (!clean) return
@ -172,7 +172,7 @@ export default function CategoriesTab() {
) )
}, []) }, [])
const goToModelsWithTag = React.useCallback((tag: string) => { const goToModelsWithTag = useCallback((tag: string) => {
const clean = String(tag || '').trim() const clean = String(tag || '').trim()
if (!clean) return if (!clean) return
@ -196,7 +196,7 @@ export default function CategoriesTab() {
) )
}, []) }, [])
const refresh = React.useCallback(async () => { const refresh = useCallback(async () => {
// laufenden refresh abbrechen // laufenden refresh abbrechen
refreshAbortRef.current?.abort() refreshAbortRef.current?.abort()
@ -326,14 +326,14 @@ export default function CategoriesTab() {
} }
}, [buildCandidates]) }, [buildCandidates])
React.useEffect(() => { useEffect(() => {
void refresh() void refresh()
return () => { return () => {
refreshAbortRef.current?.abort() refreshAbortRef.current?.abort()
} }
}, [refresh]) }, [refresh])
const renewCovers = React.useCallback(async () => { const renewCovers = useCallback(async () => {
if (renewing) return if (renewing) return
setRenewing(true) setRenewing(true)
setErr(null) setErr(null)

View File

@ -2,7 +2,7 @@
'use client' 'use client'
import * as React from 'react' import { useEffect, useRef } from 'react'
type Props = { type Props = {
checked: boolean checked: boolean
@ -25,9 +25,9 @@ export default function Checkbox({
name, name,
className = '', className = '',
}: Props) { }: Props) {
const ref = React.useRef<HTMLInputElement | null>(null) const ref = useRef<HTMLInputElement | null>(null)
React.useEffect(() => { useEffect(() => {
if (ref.current) { if (ref.current) {
ref.current.indeterminate = indeterminate && !checked ref.current.indeterminate = indeterminate && !checked
} }

View File

@ -2,12 +2,12 @@
'use client' 'use client'
import * as React from 'react' import { useEffect, useRef, useState, useLayoutEffect, type ReactNode } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import clsx from 'clsx' import clsx from 'clsx'
export type ContextMenuItem = { export type ContextMenuItem = {
label: React.ReactNode label: ReactNode
onClick: () => void onClick: () => void
disabled?: boolean disabled?: boolean
danger?: boolean danger?: boolean
@ -23,11 +23,11 @@ type Props = {
} }
export default function ContextMenu({ open, x, y, items, onClose, className }: Props) { export default function ContextMenu({ open, x, y, items, onClose, className }: Props) {
const menuRef = React.useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const [pos, setPos] = React.useState<{ left: number; top: number }>({ left: x, top: y }) const [pos, setPos] = useState<{ left: number; top: number }>({ left: x, top: y })
// Position clamped in viewport // Position clamped in viewport
React.useLayoutEffect(() => { useLayoutEffect(() => {
if (!open) return if (!open) return
const el = menuRef.current const el = menuRef.current
if (!el) return if (!el) return
@ -43,7 +43,7 @@ export default function ContextMenu({ open, x, y, items, onClose, className }: P
}, [open, x, y, items.length]) }, [open, x, y, items.length])
// close on ESC / scroll / resize // close on ESC / scroll / resize
React.useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const onKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && onClose() const onKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && onClose()

File diff suppressed because it is too large Load Diff

View File

@ -2,12 +2,12 @@
'use client' 'use client'
import { import {
memo,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
type MutableRefObject,
type ReactNode, type ReactNode,
} from 'react' } from 'react'
import Card from './Card' import Card from './Card'
@ -41,11 +41,16 @@ import {
import ModelGenderIcon from './ModelGenderIcon' import ModelGenderIcon from './ModelGenderIcon'
import { parseFinishedTags, resolveFinishedModelFlags } from './finishedModelFlags' import { parseFinishedTags, resolveFinishedModelFlags } from './finishedModelFlags'
import CountryFlag, { resolveFinishedCountry } from './CountryFlag' import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
import React from 'react'
import { import {
type FinishedSelectionStore, type FinishedSelectionStore,
useSelectionChecked, useSelectionChecked,
} from './finishedSelectionStore' } from './finishedSelectionStore'
import SpritePreloader from './SpritePreloader'
type SwipeRefs = {
current: Map<string, SwipeCardHandle>
}
type Props = { type Props = {
rows: RecordJob[] rows: RecordJob[]
@ -65,7 +70,7 @@ type Props = {
keepingKeys: Set<string> keepingKeys: Set<string>
removingKeys: Set<string> removingKeys: Set<string>
swipeRefs: MutableRefObject<Map<string, SwipeCardHandle>> swipeRefs: SwipeRefs
assetNonce?: number assetNonce?: number
@ -92,6 +97,8 @@ type Props = {
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
handleDuration: (job: RecordJob, seconds: number) => void handleDuration: (job: RecordJob, seconds: number) => void
handleResolution: (job: RecordJob, w: number, h: number) => void
resolutionLabelOf: (job: RecordJob) => string
deleteVideo: (job: RecordJob) => Promise<boolean> deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean> keepVideo: (job: RecordJob) => Promise<boolean>
@ -119,6 +126,7 @@ type Props = {
forcePreviewMuted?: boolean forcePreviewMuted?: boolean
mobileTeaserAudioUnlocked?: boolean mobileTeaserAudioUnlocked?: boolean
onUnlockMobileTeaserAudio?: () => void onUnlockMobileTeaserAudio?: () => void
renderIntegrityBadge?: (job: RecordJob) => ReactNode
} }
function CardBlurWrapper({ function CardBlurWrapper({
@ -326,6 +334,7 @@ type FinishedDownloadsDesktopCardProps = {
jobForDetails: (job: RecordJob) => RecordJob jobForDetails: (job: RecordJob) => RecordJob
handleDuration: (job: RecordJob, seconds: number) => void handleDuration: (job: RecordJob, seconds: number) => void
handleResolution: (job: RecordJob, w: number, h: number) => void
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
onToggleSelected: (job: RecordJob) => void onToggleSelected: (job: RecordJob) => void
@ -349,6 +358,7 @@ type FinishedDownloadsDesktopCardProps = {
keepVideo: (job: RecordJob) => Promise<boolean> keepVideo: (job: RecordJob) => Promise<boolean>
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode
renderIntegrityBadge?: (job: RecordJob) => ReactNode
} }
function samePostworkSummaryLite( function samePostworkSummaryLite(
@ -415,7 +425,7 @@ function SelectionCheckboxOverlay({
) )
} }
const FinishedDownloadsDesktopCard = React.memo( const FinishedDownloadsDesktopCard = memo(
function FinishedDownloadsDesktopCard({ function FinishedDownloadsDesktopCard({
job: j, job: j,
selectionStore, selectionStore,
@ -438,6 +448,7 @@ const FinishedDownloadsDesktopCard = React.memo(
lower, lower,
jobForDetails, jobForDetails,
handleDuration, handleDuration,
handleResolution,
onToggleSelected, onToggleSelected,
onHoverPreviewKeyChange, onHoverPreviewKeyChange,
onOpenPlayer, onOpenPlayer,
@ -456,6 +467,7 @@ const FinishedDownloadsDesktopCard = React.memo(
deleteVideo, deleteVideo,
keepVideo, keepVideo,
renderPostworkBadge, renderPostworkBadge,
renderIntegrityBadge,
}: FinishedDownloadsDesktopCardProps) { }: FinishedDownloadsDesktopCardProps) {
const k = keyFor(j) const k = keyFor(j)
const busy = isDeleting || isKeeping || isRemoving const busy = isDeleting || isKeeping || isRemoving
@ -469,7 +481,7 @@ const FinishedDownloadsDesktopCard = React.memo(
disabled: false, disabled: false,
}) })
const handleSelectOrOpen = React.useCallback(() => { const handleSelectOrOpen = useCallback(() => {
if (selectionStore.hasAnySelection()) { if (selectionStore.hasAnySelection()) {
onToggleSelected(j) onToggleSelected(j)
return true return true
@ -585,6 +597,7 @@ const FinishedDownloadsDesktopCard = React.memo(
variant="fill" variant="fill"
durationSeconds={durationSeconds ?? (j as any)?.meta?.file?.durationSeconds} durationSeconds={durationSeconds ?? (j as any)?.meta?.file?.durationSeconds}
onDuration={handleDuration} onDuration={handleDuration}
onResolution={handleResolution}
showPopover={false} showPopover={false}
blur={inlineActive ? false : Boolean(blurPreviews)} blur={inlineActive ? false : Boolean(blurPreviews)}
animated={shouldAnimateTeaser({ animated={shouldAnimateTeaser({
@ -627,9 +640,10 @@ const FinishedDownloadsDesktopCard = React.memo(
<CountryFlag country={modelCountry} size="sm" className="shrink-0" /> <CountryFlag country={modelCountry} size="sm" className="shrink-0" />
</div> </div>
{renderPostworkBadge && postworkBadge ? ( {(renderIntegrityBadge || (renderPostworkBadge && postworkBadge)) ? (
<div className="relative z-20 shrink-0"> <div className="relative z-20 shrink-0 flex items-center gap-2">
{renderPostworkBadge(postworkBadge)} {renderIntegrityBadge ? renderIntegrityBadge(j) : null}
{renderPostworkBadge && postworkBadge ? renderPostworkBadge(postworkBadge) : null}
</div> </div>
) : null} ) : null}
</div> </div>
@ -709,6 +723,7 @@ const FinishedDownloadsDesktopCard = React.memo(
prev.isRemoving === next.isRemoving && prev.isRemoving === next.isRemoving &&
samePostworkSummaryLite(prev.postworkBadge, next.postworkBadge) && samePostworkSummaryLite(prev.postworkBadge, next.postworkBadge) &&
prev.durationSeconds === next.durationSeconds && prev.durationSeconds === next.durationSeconds &&
prev.assetNonce === next.assetNonce &&
prev.forcePreviewMuted === next.forcePreviewMuted && prev.forcePreviewMuted === next.forcePreviewMuted &&
prev.blurPreviews === next.blurPreviews && prev.blurPreviews === next.blurPreviews &&
prev.teaserState.mode === next.teaserState.mode && prev.teaserState.mode === next.teaserState.mode &&
@ -759,6 +774,7 @@ function FinishedDownloadsCardsView({
tryAutoplayInline, tryAutoplayInline,
registerTeaserHost, registerTeaserHost,
handleDuration, handleDuration,
handleResolution,
deleteVideo, deleteVideo,
keepVideo, keepVideo,
@ -779,6 +795,7 @@ function FinishedDownloadsCardsView({
forcePreviewMuted, forcePreviewMuted,
mobileTeaserAudioUnlocked, mobileTeaserAudioUnlocked,
onUnlockMobileTeaserAudio, onUnlockMobileTeaserAudio,
renderIntegrityBadge,
}: Props) { }: Props) {
const footerTapRef = useRef<Record<string, boolean>>({}) const footerTapRef = useRef<Record<string, boolean>>({})
@ -943,6 +960,17 @@ function FinishedDownloadsCardsView({
const scrubberCount = hasScrubberUi ? sprite.count : 0 const scrubberCount = hasScrubberUi ? sprite.count : 0
const scrubberStepSeconds = hasScrubberUi ? sprite.stepSeconds : 0 const scrubberStepSeconds = hasScrubberUi ? sprite.stepSeconds : 0
const teaserPreloadEnabled =
opts?.mobileStackTopOnlyVideo
? false
: (opts?.preloadTeaserWhenStill ? true : !isSmall)
const shouldPreloadSprite =
Boolean(teaserPreloadEnabled && hasSpriteScrubber && spriteUrl)
const spriteScrubbingActive =
hasSpriteScrubber && scrubHovering && typeof scrubActiveIndex === 'number'
const scrubProgressRatio = scrubProgressRatioFromIndex(scrubActiveIndex, scrubberCount) const scrubProgressRatio = scrubProgressRatioFromIndex(scrubActiveIndex, scrubberCount)
const spriteFrameStyle = buildSpriteFrameStyle({ const spriteFrameStyle = buildSpriteFrameStyle({
@ -1116,9 +1144,10 @@ function FinishedDownloadsCardsView({
variant="fill" variant="fill"
durationSeconds={durations[k] ?? (j as any)?.meta?.file?.durationSeconds} durationSeconds={durations[k] ?? (j as any)?.meta?.file?.durationSeconds}
onDuration={handleDuration} onDuration={handleDuration}
onResolution={handleResolution}
showPopover={false} showPopover={false}
blur={inlineActive ? false : Boolean(blurPreviews)} blur={inlineActive ? false : Boolean(blurPreviews)}
animated={allowTeaserAnimation} animated={allowTeaserAnimation && !spriteScrubbingActive}
animatedMode="teaser" animatedMode="teaser"
animatedTrigger="always" animatedTrigger="always"
inlineVideo={!opts?.disableInline && inlineActive ? 'always' : false} inlineVideo={!opts?.disableInline && inlineActive ? 'always' : false}
@ -1129,18 +1158,16 @@ function FinishedDownloadsCardsView({
popoverMuted={previewMuted} popoverMuted={previewMuted}
assetNonce={assetNonce ?? 0} assetNonce={assetNonce ?? 0}
alwaysLoadStill={forceLoadStill} alwaysLoadStill={forceLoadStill}
teaserPreloadEnabled={ teaserPreloadEnabled={teaserPreloadEnabled}
opts?.mobileStackTopOnlyVideo
? false
: (opts?.preloadTeaserWhenStill ? true : !isSmall)
}
teaserPreloadRootMargin={ teaserPreloadRootMargin={
opts?.preloadTeaserWhenStill opts?.preloadTeaserWhenStill
? '1200px 0px' ? '1200px 0px'
: (isSmall ? '900px 0px' : '700px 0px') : (isSmall ? '900px 0px' : '700px 0px')
} }
scrubProgressRatio={scrubProgressRatio} scrubProgressRatio={hasSpriteScrubber ? undefined : scrubProgressRatio}
preferScrubProgress={scrubHovering && typeof scrubActiveIndex === 'number'} preferScrubProgress={
!hasSpriteScrubber && scrubHovering && typeof scrubActiveIndex === 'number'
}
forceActive={Boolean(isSmall && opts?.mobileStackTopOnlyVideo && !opts?.forceStill)} forceActive={Boolean(isSmall && opts?.mobileStackTopOnlyVideo && !opts?.forceStill)}
activationNonce={ activationNonce={
previewRenderRole === 'top' previewRenderRole === 'top'
@ -1180,18 +1207,11 @@ function FinishedDownloadsCardsView({
<span>Ton aktivieren</span> <span>Ton aktivieren</span>
</button> </button>
) : null} ) : null}
{/* ✅ Sprite einmal vorladen, damit der erste Scrub-Move sofort sichtbar ist */} <SpritePreloader
{hasSpriteScrubber && spriteUrl ? ( src={spriteUrl}
<img enabled={shouldPreloadSprite}
src={spriteUrl} />
alt=""
className="hidden"
loading="lazy"
decoding="async"
aria-hidden="true"
/>
) : null}
{/* ✅ Scrub-Frame Overlay via Sprite (kein Request pro Move) */} {/* ✅ Scrub-Frame Overlay via Sprite (kein Request pro Move) */}
{hasSpriteScrubber && spriteFrameStyle && !inlineActive ? ( {hasSpriteScrubber && spriteFrameStyle && !inlineActive ? (
@ -1263,6 +1283,8 @@ function FinishedDownloadsCardsView({
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
if (isSmall) return
if (selectionStore.hasAnySelection()) { if (selectionStore.hasAnySelection()) {
onToggleSelected(j) onToggleSelected(j)
return return
@ -1291,9 +1313,10 @@ function FinishedDownloadsCardsView({
/> />
</div> </div>
{renderPostworkBadge && livePostwork ? ( {(renderIntegrityBadge || (renderPostworkBadge && livePostwork)) ? (
<div className="relative z-20 shrink-0"> <div className="relative z-20 shrink-0 flex items-center gap-2">
{renderPostworkBadge(livePostwork)} {renderIntegrityBadge ? renderIntegrityBadge(j) : null}
{renderPostworkBadge && livePostwork ? renderPostworkBadge(livePostwork) : null}
</div> </div>
) : null} ) : null}
</div> </div>
@ -1549,6 +1572,7 @@ function FinishedDownloadsCardsView({
lower={lower} lower={lower}
jobForDetails={jobForDetails} jobForDetails={jobForDetails}
handleDuration={handleDuration} handleDuration={handleDuration}
handleResolution={handleResolution}
handleScrubberClickIndex={handleScrubberClickIndex} handleScrubberClickIndex={handleScrubberClickIndex}
onToggleSelected={onToggleSelected} onToggleSelected={onToggleSelected}
onSelectOnly={onSelectOnly} onSelectOnly={onSelectOnly}
@ -1569,6 +1593,7 @@ function FinishedDownloadsCardsView({
deleteVideo={deleteVideo} deleteVideo={deleteVideo}
keepVideo={keepVideo} keepVideo={keepVideo}
renderPostworkBadge={renderPostworkBadge} renderPostworkBadge={renderPostworkBadge}
renderIntegrityBadge={renderIntegrityBadge}
/> />
) )
})} })}
@ -1783,5 +1808,5 @@ function FinishedDownloadsCardsView({
) )
} }
const MemoFinishedDownloadsCardsView = React.memo(FinishedDownloadsCardsView) const MemoFinishedDownloadsCardsView = memo(FinishedDownloadsCardsView)
export default MemoFinishedDownloadsCardsView export default MemoFinishedDownloadsCardsView

View File

@ -2,7 +2,7 @@
'use client' 'use client'
import * as React from 'react' import { useState, useMemo, useCallback, memo, type ReactNode, type SyntheticEvent } from 'react'
import type { import type {
RecordJob, RecordJob,
GalleryModelFlags, GalleryModelFlags,
@ -12,7 +12,6 @@ import FinishedVideoPreview from './FinishedVideoPreview'
import RecordJobActions from './RecordJobActions' import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow' import TagOverflowRow from './TagOverflowRow'
import { isHotName, stripHotPrefix } from './hotName' import { isHotName, stripHotPrefix } from './hotName'
import { formatResolution } from './formatters'
import PreviewScrubber from './PreviewScrubber' import PreviewScrubber from './PreviewScrubber'
import type { FinishedDownloadsTeaserState } from './teaserPlayback' import type { FinishedDownloadsTeaserState } from './teaserPlayback'
import { import {
@ -29,6 +28,7 @@ import {
import ModelGenderIcon from './ModelGenderIcon' import ModelGenderIcon from './ModelGenderIcon'
import { resolveFinishedModelFlags } from './finishedModelFlags' import { resolveFinishedModelFlags } from './finishedModelFlags'
import CountryFlag, { resolveFinishedCountry } from './CountryFlag' import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
import SpritePreloader from './SpritePreloader'
function normalizeDurationSeconds(value: unknown): number | undefined { function normalizeDurationSeconds(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
@ -49,7 +49,7 @@ type Props = {
output: string | undefined, output: string | undefined,
summaries: Record<string, FinishedPostworkSummary> summaries: Record<string, FinishedPostworkSummary>
) => FinishedPostworkSummary | undefined ) => FinishedPostworkSummary | undefined
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode
durationSeconds?: number durationSeconds?: number
teaserState: FinishedDownloadsTeaserState teaserState: FinishedDownloadsTeaserState
@ -76,6 +76,8 @@ type Props = {
onToggleTagFilter: (tag: string) => void onToggleTagFilter: (tag: string) => void
onOpenPlayer: (job: RecordJob) => void onOpenPlayer: (job: RecordJob) => void
handleDuration: (job: RecordJob, seconds: number) => void handleDuration: (job: RecordJob, seconds: number) => void
handleResolution: (job: RecordJob, w: number, h: number) => void
resolutionLabelOf: (job: RecordJob) => string
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
deleteVideo: (job: RecordJob) => Promise<boolean> deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean> keepVideo: (job: RecordJob) => Promise<boolean>
@ -86,11 +88,13 @@ type Props = {
onSplit?: (job: RecordJob) => void | Promise<void> onSplit?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean> onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
jobForDetails: (job: RecordJob) => RecordJob jobForDetails: (job: RecordJob) => RecordJob
renderIntegrityBadge?: (job: RecordJob) => ReactNode
contentPaddingClass: string contentPaddingClass: string
footerMinHeightClass: string footerMinHeightClass: string
titleClass: string titleClass: string
sublineClass: string sublineClass: string
assetNonce?: number
} }
function FinishedDownloadsGalleryCardInner({ function FinishedDownloadsGalleryCardInner({
@ -119,11 +123,12 @@ function FinishedDownloadsGalleryCardInner({
removingKeys, removingKeys,
registerTeaserHost, registerTeaserHost,
onToggleSelected, onToggleSelected,
onSelectOnly,
onHoverPreviewKeyChange, onHoverPreviewKeyChange,
onToggleTagFilter, onToggleTagFilter,
onOpenPlayer, onOpenPlayer,
handleDuration, handleDuration,
handleResolution,
resolutionLabelOf,
handleScrubberClickIndex, handleScrubberClickIndex,
deleteVideo, deleteVideo,
keepVideo, keepVideo,
@ -134,19 +139,21 @@ function FinishedDownloadsGalleryCardInner({
onSplit, onSplit,
onAddToDownloads, onAddToDownloads,
jobForDetails, jobForDetails,
renderIntegrityBadge,
contentPaddingClass, contentPaddingClass,
footerMinHeightClass, footerMinHeightClass,
titleClass, titleClass,
sublineClass, sublineClass,
assetNonce
}: Props) { }: Props) {
const k = rowKey const k = rowKey
const checked = selectedKeys.has(rowKey) const checked = selectedKeys.has(rowKey)
const [scrubIndexByKey, setScrubIndexByKey] = React.useState<Record<string, number | undefined>>({}) const [scrubIndexByKey, setScrubIndexByKey] = useState<Record<string, number | undefined>>({})
const [hoveredThumbKey, setHoveredThumbKey] = React.useState<string | null>(null) const [hoveredThumbKey, setHoveredThumbKey] = useState<string | null>(null)
const handlePrimaryClick = React.useCallback( const handlePrimaryClick = useCallback(
(e?: React.SyntheticEvent) => { (e?: SyntheticEvent) => {
e?.preventDefault() e?.preventDefault()
e?.stopPropagation() e?.stopPropagation()
@ -157,10 +164,10 @@ function FinishedDownloadsGalleryCardInner({
onOpenPlayer(j) onOpenPlayer(j)
}, },
[selectionModeActive, onSelectOnly, onOpenPlayer, j] [selectionModeActive, onToggleSelected, onOpenPlayer, j]
) )
const setScrubIndexForKey = React.useCallback((key: string, index: number | undefined) => { const setScrubIndexForKey = useCallback((key: string, index: number | undefined) => {
setScrubIndexByKey((prev) => { setScrubIndexByKey((prev) => {
if (index === undefined) { if (index === undefined) {
if (!(key in prev)) return prev if (!(key in prev)) return prev
@ -173,7 +180,7 @@ function FinishedDownloadsGalleryCardInner({
}) })
}, []) }, [])
const clearScrubIndex = React.useCallback((key: string) => { const clearScrubIndex = useCallback((key: string) => {
setScrubIndexForKey(key, undefined) setScrubIndexForKey(key, undefined)
}, [setScrubIndexForKey]) }, [setScrubIndexForKey])
@ -192,7 +199,7 @@ function FinishedDownloadsGalleryCardInner({
lower lower
) )
const tags = React.useMemo(() => { const tags = useMemo(() => {
const s = String(flags?.tags ?? '').trim() const s = String(flags?.tags ?? '').trim()
if (!s) return [] as string[] if (!s) return [] as string[]
const parts = s.split(/[\n,;|]+/g).map((p) => p.trim()).filter(Boolean) const parts = s.split(/[\n,;|]+/g).map((p) => p.trim()).filter(Boolean)
@ -223,9 +230,9 @@ function FinishedDownloadsGalleryCardInner({
const dur = runtimeOf(j) const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j)) const size = formatBytes(sizeBytesOf(j))
const meta = React.useMemo(() => parseJobMeta((j as any)?.meta), [(j as any)?.meta]) const meta = useMemo(() => parseJobMeta((j as any)?.meta), [(j as any)?.meta])
const resObj = React.useMemo(() => { const resObj = useMemo(() => {
const w = meta?.media?.video?.width ?? meta?.file?.video?.width const w = meta?.media?.video?.width ?? meta?.file?.video?.width
const h = meta?.media?.video?.height ?? meta?.file?.video?.height const h = meta?.media?.video?.height ?? meta?.file?.video?.height
if ( if (
@ -237,20 +244,22 @@ function FinishedDownloadsGalleryCardInner({
return null return null
}, [meta]) }, [meta])
const resLabel = formatResolution(resObj) const resLabelRaw = resolutionLabelOf(j)
const resLabel = resLabelRaw && resLabelRaw !== '—' ? resLabelRaw : ''
const isRemoving = removingKeys.has(k) const isRemoving = removingKeys.has(k)
const isDeleting = deletingKeys.has(k) const isDeleting = deletingKeys.has(k)
const isKeeping = keepingKeys.has(k) const isKeeping = keepingKeys.has(k)
const busy = isDeleting || isKeeping || isRemoving const busy = isDeleting || isKeeping || isRemoving
const sprite = React.useMemo( const sprite = useMemo(
() => () =>
readPreviewSpriteInfo((j as any)?.meta, { readPreviewSpriteInfo((j as any)?.meta, {
fallbackPath: flags?.previewScrubberPath, fallbackPath: flags?.previewScrubberPath,
fallbackCount: flags?.previewScrubberCount, fallbackCount: flags?.previewScrubberCount,
versionFallback: assetNonce ?? 0,
}), }),
[(j as any)?.meta, flags?.previewScrubberPath, flags?.previewScrubberCount] [(j as any)?.meta, flags?.previewScrubberPath, flags?.previewScrubberCount, assetNonce]
) )
const spriteUrl = sprite.url const spriteUrl = sprite.url
@ -259,11 +268,12 @@ function FinishedDownloadsGalleryCardInner({
const scrubberCount = sprite.count const scrubberCount = sprite.count
const scrubberStepSeconds = sprite.stepSeconds const scrubberStepSeconds = sprite.stepSeconds
const hasScrubber = hasScrubberUi const hasScrubber = hasScrubberUi
const shouldPreloadSprite = Boolean(hasSpriteScrubber && spriteUrl)
const activeScrubIndex = scrubIndexByKey[k] const activeScrubIndex = scrubIndexByKey[k]
const scrubProgressRatio = scrubProgressRatioFromIndex(activeScrubIndex, scrubberCount) const scrubProgressRatio = scrubProgressRatioFromIndex(activeScrubIndex, scrubberCount)
const spriteFrameStyle = React.useMemo( const spriteFrameStyle = useMemo(
() => () =>
buildSpriteFrameStyle({ buildSpriteFrameStyle({
spriteUrl, spriteUrl,
@ -276,13 +286,15 @@ function FinishedDownloadsGalleryCardInner({
) )
const showScrubberSpriteInThumb = Boolean(spriteFrameStyle) const showScrubberSpriteInThumb = Boolean(spriteFrameStyle)
const hideTeaserUnderOverlay = showScrubberSpriteInThumb
const previewDurationSeconds = const previewDurationSeconds =
durationSeconds ?? durationSeconds ??
normalizeDurationSeconds(meta?.media?.durationSeconds) ?? normalizeDurationSeconds(meta?.media?.durationSeconds) ??
normalizeDurationSeconds(meta?.file?.durationSeconds) normalizeDurationSeconds(meta?.file?.durationSeconds)
const spriteScrubbingActive =
hasSpriteScrubber && typeof activeScrubIndex === 'number'
return ( return (
<div className="relative"> <div className="relative">
<div <div
@ -348,35 +360,31 @@ function FinishedDownloadsGalleryCardInner({
getFileName={(p) => stripHotPrefix(baseName(p))} getFileName={(p) => stripHotPrefix(baseName(p))}
durationSeconds={previewDurationSeconds} durationSeconds={previewDurationSeconds}
onDuration={handleDuration} onDuration={handleDuration}
onResolution={handleResolution}
variant="fill" variant="fill"
showPopover={false} showPopover={false}
blur={blurPreviews} blur={blurPreviews}
animated={shouldAnimateTeaser({ animated={shouldAnimateTeaser({
state: teaserState, state: teaserState,
itemKey: k, itemKey: k,
disabled: hideTeaserUnderOverlay, disabled: spriteScrubbingActive,
})} })}
animatedMode="teaser" animatedMode="teaser"
animatedTrigger="always" animatedTrigger="always"
muted={previewMuted} muted={previewMuted}
popoverMuted={previewMuted} popoverMuted={previewMuted}
scrubProgressRatio={scrubProgressRatio} assetNonce={assetNonce ?? 0}
preferScrubProgress={typeof activeScrubIndex === 'number'} scrubProgressRatio={hasSpriteScrubber ? undefined : scrubProgressRatio}
preferScrubProgress={!hasSpriteScrubber && typeof activeScrubIndex === 'number'}
forceActive={isPreviewActive} forceActive={isPreviewActive}
teaserPreloadEnabled teaserPreloadEnabled
/> />
</div> </div>
{hasSpriteScrubber && spriteUrl ? ( <SpritePreloader
<img src={spriteUrl}
src={spriteUrl} enabled={shouldPreloadSprite}
alt="" />
className="hidden"
loading="lazy"
decoding="async"
aria-hidden="true"
/>
) : null}
{showScrubberSpriteInThumb && spriteFrameStyle ? ( {showScrubberSpriteInThumb && spriteFrameStyle ? (
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true"> <div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
@ -448,9 +456,10 @@ function FinishedDownloadsGalleryCardInner({
/> />
</div> </div>
{showPostworkBadge && renderPostworkBadge ? ( {(renderIntegrityBadge || (showPostworkBadge && renderPostworkBadge)) ? (
<div className="relative z-20 shrink-0"> <div className="relative z-20 shrink-0 flex items-center gap-2">
{renderPostworkBadge(postworkBadge)} {renderIntegrityBadge ? renderIntegrityBadge(j) : null}
{showPostworkBadge && renderPostworkBadge ? renderPostworkBadge(postworkBadge) : null}
</div> </div>
) : null} ) : null}
</div> </div>
@ -519,5 +528,5 @@ function FinishedDownloadsGalleryCardInner({
) )
} }
const FinishedDownloadsGalleryCard = React.memo(FinishedDownloadsGalleryCardInner) const FinishedDownloadsGalleryCard = memo(FinishedDownloadsGalleryCardInner)
export default FinishedDownloadsGalleryCard export default FinishedDownloadsGalleryCard

View File

@ -1,7 +1,7 @@
// frontend\src\components\ui\FinishedDownloadsGalleryView.tsx // frontend\src\components\ui\FinishedDownloadsGalleryView.tsx
'use client' 'use client'
import * as React from 'react' import { useEffect, useRef, useMemo, useCallback, memo, type ReactNode } from 'react'
import type { import type {
RecordJob, RecordJob,
GalleryModelFlags, GalleryModelFlags,
@ -28,10 +28,12 @@ type Props = {
summaries: Record<string, FinishedPostworkSummary> summaries: Record<string, FinishedPostworkSummary>
) => FinishedPostworkSummary | undefined ) => FinishedPostworkSummary | undefined
teaserState: FinishedDownloadsTeaserState teaserState: FinishedDownloadsTeaserState
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode
cardScale?: number cardScale?: number
handleDuration: (job: RecordJob, seconds: number) => void handleDuration: (job: RecordJob, seconds: number) => void
handleResolution: (job: RecordJob, w: number, h: number) => void
resolutionLabelOf: (job: RecordJob) => string
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
jobForDetails: (job: RecordJob) => RecordJob jobForDetails: (job: RecordJob) => RecordJob
@ -68,6 +70,8 @@ type Props = {
onRecommendedPageSizeChange?: (size: number) => void onRecommendedPageSizeChange?: (size: number) => void
forcePreviewMuted?: boolean forcePreviewMuted?: boolean
assetNonce?: number
renderIntegrityBadge?: (job: RecordJob) => ReactNode
} }
function snapTo(value: number, step: number, base = 0) { function snapTo(value: number, step: number, base = 0) {
@ -96,6 +100,8 @@ function FinishedDownloadsGalleryView({
onToggleSelected, onToggleSelected,
onSelectOnly, onSelectOnly,
handleDuration, handleDuration,
handleResolution,
resolutionLabelOf,
handleScrubberClickIndex, handleScrubberClickIndex,
jobForDetails, jobForDetails,
keyFor, keyFor,
@ -124,10 +130,12 @@ function FinishedDownloadsGalleryView({
onAddToDownloads, onAddToDownloads,
onRecommendedPageSizeChange, onRecommendedPageSizeChange,
forcePreviewMuted, forcePreviewMuted,
assetNonce,
renderIntegrityBadge,
}: Props) { }: Props) {
const observeTeasers = shouldObserveTeasers(teaserState.mode) const observeTeasers = shouldObserveTeasers(teaserState.mode)
const registerTeaserHostIfNeeded = React.useCallback( const registerTeaserHostIfNeeded = useCallback(
(key: string) => (el: HTMLDivElement | null) => { (key: string) => (el: HTMLDivElement | null) => {
if (!observeTeasers) { if (!observeTeasers) {
registerTeaserHost(key)(null) registerTeaserHost(key)(null)
@ -145,26 +153,26 @@ function FinishedDownloadsGalleryView({
const effectiveScale = clamp(effectiveScaleRaw, 0.8, 1.35) const effectiveScale = clamp(effectiveScaleRaw, 0.8, 1.35)
const layoutScale = React.useMemo( const layoutScale = useMemo(
() => snapTo(effectiveScale, 0.05, 0.8), () => snapTo(effectiveScale, 0.05, 0.8),
[effectiveScale] [effectiveScale]
) )
const sizePreset = React.useMemo<'sm' | 'md' | 'lg' | 'xl'>(() => { const sizePreset = useMemo<'sm' | 'md' | 'lg' | 'xl'>(() => {
if (layoutScale >= 1.14) return 'xl' if (layoutScale >= 1.14) return 'xl'
if (layoutScale >= 0.99) return 'lg' if (layoutScale >= 0.99) return 'lg'
if (layoutScale >= 0.88) return 'md' if (layoutScale >= 0.88) return 'md'
return 'sm' return 'sm'
}, [layoutScale]) }, [layoutScale])
const minCardWidth = React.useMemo(() => { const minCardWidth = useMemo(() => {
return Math.round(270 * layoutScale) return Math.round(270 * layoutScale)
}, [layoutScale]) }, [layoutScale])
const rootRef = React.useRef<HTMLDivElement | null>(null) const rootRef = useRef<HTMLDivElement | null>(null)
const lastRecommendedPageSizeRef = React.useRef<number | null>(null) const lastRecommendedPageSizeRef = useRef<number | null>(null)
const recommendPageSize = React.useCallback(() => { const recommendPageSize = useCallback(() => {
if (!onRecommendedPageSizeChange) return if (!onRecommendedPageSizeChange) return
const el = rootRef.current const el = rootRef.current
@ -190,7 +198,7 @@ function FinishedDownloadsGalleryView({
onRecommendedPageSizeChange(nextPageSize) onRecommendedPageSizeChange(nextPageSize)
}, [minCardWidth, onRecommendedPageSizeChange]) }, [minCardWidth, onRecommendedPageSizeChange])
React.useEffect(() => { useEffect(() => {
recommendPageSize() recommendPageSize()
const el = rootRef.current const el = rootRef.current
@ -291,6 +299,8 @@ function FinishedDownloadsGalleryView({
onToggleTagFilter={onToggleTagFilter} onToggleTagFilter={onToggleTagFilter}
onOpenPlayer={onOpenPlayer} onOpenPlayer={onOpenPlayer}
handleDuration={handleDuration} handleDuration={handleDuration}
handleResolution={handleResolution}
resolutionLabelOf={resolutionLabelOf}
handleScrubberClickIndex={handleScrubberClickIndex} handleScrubberClickIndex={handleScrubberClickIndex}
deleteVideo={deleteVideo} deleteVideo={deleteVideo}
keepVideo={keepVideo} keepVideo={keepVideo}
@ -305,6 +315,8 @@ function FinishedDownloadsGalleryView({
footerMinHeightClass={footerMinHeightClass} footerMinHeightClass={footerMinHeightClass}
titleClass={titleClass} titleClass={titleClass}
sublineClass={sublineClass} sublineClass={sublineClass}
assetNonce={assetNonce ?? 0}
renderIntegrityBadge={renderIntegrityBadge}
/> />
</div> </div>
) )
@ -323,5 +335,5 @@ function FinishedDownloadsGalleryView({
) )
} }
const MemoFinishedDownloadsGalleryView = React.memo(FinishedDownloadsGalleryView) const MemoFinishedDownloadsGalleryView = memo(FinishedDownloadsGalleryView)
export default MemoFinishedDownloadsGalleryView export default MemoFinishedDownloadsGalleryView

View File

@ -2,7 +2,7 @@
'use client' 'use client'
import * as React from 'react' import { useState, useMemo, useCallback, type Dispatch, type ReactNode, type SetStateAction } from 'react'
import Table, { type Column, type SortState } from './Table' import Table, { type Column, type SortState } from './Table'
import type { import type {
RecordJob, RecordJob,
@ -50,12 +50,12 @@ type Props = {
summaries: Record<string, FinishedPostworkSummary> summaries: Record<string, FinishedPostworkSummary>
) => FinishedPostworkSummary | undefined ) => FinishedPostworkSummary | undefined
jobForDetails: (job: RecordJob) => RecordJob jobForDetails: (job: RecordJob) => RecordJob
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode
// teaser/preview // teaser/preview
canHover: boolean canHover: boolean
teaserState: FinishedDownloadsTeaserState teaserState: FinishedDownloadsTeaserState
setHoverTeaserKey: React.Dispatch<React.SetStateAction<string | null>> setHoverTeaserKey: Dispatch<SetStateAction<string | null>>
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
handleDuration: (job: RecordJob, seconds: number) => void handleDuration: (job: RecordJob, seconds: number) => void
handleResolution: (job: RecordJob, w: number, h: number) => void handleResolution: (job: RecordJob, w: number, h: number) => void
@ -88,6 +88,7 @@ type Props = {
deleteVideo: (job: RecordJob) => Promise<boolean> deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean> keepVideo: (job: RecordJob) => Promise<boolean>
forcePreviewMuted?: boolean forcePreviewMuted?: boolean
renderIntegrityBadge?: (job: RecordJob) => ReactNode
} }
export default function FinishedDownloadsTableView({ export default function FinishedDownloadsTableView({
@ -143,10 +144,11 @@ export default function FinishedDownloadsTableView({
deleteVideo, deleteVideo,
keepVideo, keepVideo,
forcePreviewMuted, forcePreviewMuted,
renderIntegrityBadge,
}: Props) { }: Props) {
const [sort, setSort] = React.useState<SortState>(null) const [sort, setSort] = useState<SortState>(null)
const runtimeSecondsForSort = React.useCallback((job: RecordJob) => { const runtimeSecondsForSort = useCallback((job: RecordJob) => {
// 1) Prefer real video duration (ffprobe / backend) // 1) Prefer real video duration (ffprobe / backend)
const sec = (job as any)?.meta?.file?.durationSeconds const sec = (job as any)?.meta?.file?.durationSeconds
if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) return sec if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) return sec
@ -162,7 +164,7 @@ export default function FinishedDownloadsTableView({
return Number.POSITIVE_INFINITY return Number.POSITIVE_INFINITY
}, []) }, [])
const parseTags = React.useCallback((raw?: string): string[] => { const parseTags = useCallback((raw?: string): string[] => {
const s = String(raw ?? '').trim() const s = String(raw ?? '').trim()
if (!s) return [] if (!s) return []
const parts = s const parts = s
@ -182,11 +184,11 @@ export default function FinishedDownloadsTableView({
return out return out
}, []) }, [])
const parseMeta = React.useCallback((j: RecordJob): any | null => { const parseMeta = useCallback((j: RecordJob): any | null => {
return parseJobMeta((j as any)?.meta) return parseJobMeta((j as any)?.meta)
}, []) }, [])
const previewSpriteInfoOf = React.useCallback( const previewSpriteInfoOf = useCallback(
(j: RecordJob): { count: number; stepSeconds: number } | null => { (j: RecordJob): { count: number; stepSeconds: number } | null => {
const meta = parseMeta(j) const meta = parseMeta(j)
let ps: any = meta?.previewSprite ?? meta?.preview?.sprite ?? null let ps: any = meta?.previewSprite ?? meta?.preview?.sprite ?? null
@ -212,9 +214,9 @@ export default function FinishedDownloadsTableView({
[parseMeta] [parseMeta]
) )
const [scrubActiveByKey, setScrubActiveByKey] = React.useState<Record<string, number | undefined>>({}) const [scrubActiveByKey, setScrubActiveByKey] = useState<Record<string, number | undefined>>({})
const setScrubActiveIndex = React.useCallback((key: string, index: number | undefined) => { const setScrubActiveIndex = useCallback((key: string, index: number | undefined) => {
setScrubActiveByKey((prev) => { setScrubActiveByKey((prev) => {
if (prev[key] === index) return prev if (prev[key] === index) return prev
return { ...prev, [key]: index } return { ...prev, [key]: index }
@ -227,7 +229,7 @@ export default function FinishedDownloadsTableView({
const someSelectedOnPage = const someSelectedOnPage =
rows.some((j) => selectedKeys.has(keyFor(j))) && !allSelectedOnPage rows.some((j) => selectedKeys.has(keyFor(j))) && !allSelectedOnPage
const handlePrimaryAction = React.useCallback( const handlePrimaryAction = useCallback(
(job: RecordJob) => { (job: RecordJob) => {
if (selectionModeActive) { if (selectionModeActive) {
onToggleSelected(job) onToggleSelected(job)
@ -239,7 +241,7 @@ export default function FinishedDownloadsTableView({
[selectionModeActive, onToggleSelected, onOpenPlayer] [selectionModeActive, onToggleSelected, onOpenPlayer]
) )
const columns = React.useMemo<Column<RecordJob>[]>(() => { const columns = useMemo<Column<RecordJob>[]>(() => {
return [ return [
{ {
key: 'select', key: 'select',
@ -411,9 +413,10 @@ export default function FinishedDownloadsTableView({
/> />
</div> </div>
{showPostworkBadge && renderPostworkBadge ? ( {(renderIntegrityBadge || (showPostworkBadge && renderPostworkBadge)) ? (
<div className="relative z-20 shrink-0"> <div className="relative z-20 shrink-0 flex items-center gap-2">
{renderPostworkBadge(postworkBadge)} {renderIntegrityBadge ? renderIntegrityBadge(j) : null}
{showPostworkBadge && renderPostworkBadge ? renderPostworkBadge(postworkBadge) : null}
</div> </div>
) : null} ) : null}
</div> </div>
@ -604,9 +607,10 @@ export default function FinishedDownloadsTableView({
onToggleSelectAllPage, onToggleSelectAllPage,
allSelectedOnPage, allSelectedOnPage,
someSelectedOnPage, someSelectedOnPage,
renderIntegrityBadge
]) ])
const sortStateToMode = React.useCallback((s: SortState): DoneSortMode => { const sortStateToMode = useCallback((s: SortState): DoneSortMode => {
if (!s) return 'completed_desc' if (!s) return 'completed_desc'
const anyS = s as any const anyS = s as any
const key = String(anyS.key ?? anyS.columnKey ?? anyS.id ?? '') const key = String(anyS.key ?? anyS.columnKey ?? anyS.id ?? '')
@ -621,7 +625,7 @@ export default function FinishedDownloadsTableView({
return asc ? 'completed_asc' : 'completed_desc' return asc ? 'completed_asc' : 'completed_desc'
}, []) }, [])
const handleSortChange = React.useCallback( const handleSortChange = useCallback(
(s: SortState) => { (s: SortState) => {
setSort(s) // Table Pfeil setSort(s) // Table Pfeil
onSortModeChange(sortStateToMode(s)) onSortModeChange(sortStateToMode(s))
@ -630,7 +634,7 @@ export default function FinishedDownloadsTableView({
[onSortModeChange, sortStateToMode, page, onPageChange] [onSortModeChange, sortStateToMode, page, onPageChange]
) )
const rowClassName = React.useCallback( const rowClassName = useCallback(
(j: RecordJob) => { (j: RecordJob) => {
const k = keyFor(j) const k = keyFor(j)
return [ return [

View File

@ -150,8 +150,7 @@ export default function FinishedVideoPreview({
return m return m
}, [job]) }, [job])
// ✅ merge statt "meta ?? fetchedMeta" // meta aus job normalisiert; dient als Basis für Preview-/Teaser-Daten
// job.meta bleibt Basis, fetchedMeta ergänzt fehlende Felder (z.B. previewClips)
const metaForPreview = meta const metaForPreview = meta
const [progressMountTick, setProgressMountTick] = useState(0) const [progressMountTick, setProgressMountTick] = useState(0)
@ -685,8 +684,8 @@ export default function FinishedVideoPreview({
const teaserSrc = useMemo(() => { const teaserSrc = useMemo(() => {
if (!previewId) return '' if (!previewId) return ''
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1` return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1&v=${v}`
}, [previewId]) }, [previewId, v])
// --- Inline Video sichtbar? // --- Inline Video sichtbar?
const showingInlineVideo = const showingInlineVideo =
@ -1403,7 +1402,7 @@ export default function FinishedVideoPreview({
{/* ✅ Teaser prewarm (lädt Datei/Metadata vor, bleibt unsichtbar) */} {/* ✅ Teaser prewarm (lädt Datei/Metadata vor, bleibt unsichtbar) */}
{!showingInlineVideo && teaserCanPrewarm && !(teaserActive && animatedMode === 'teaser') ? ( {!showingInlineVideo && teaserCanPrewarm && !(teaserActive && animatedMode === 'teaser') ? (
<video <video
key={`teaser-prewarm-${previewId}`} key={`teaser-prewarm-${previewId}-${v}`}
src={teaserSrc} src={teaserSrc}
className="hidden" className="hidden"
muted muted
@ -1427,6 +1426,7 @@ export default function FinishedVideoPreview({
{/* ✅ Teaser MP4 */} {/* ✅ Teaser MP4 */}
{!showingInlineVideo && teaserActive && animatedMode === 'teaser' ? ( {!showingInlineVideo && teaserActive && animatedMode === 'teaser' ? (
<video <video
key={`teaser-${previewId}-${v}`}
ref={(el) => { ref={(el) => {
teaserMp4Ref.current = el teaserMp4Ref.current = el
bumpMountTickIfNew('teaser', el) bumpMountTickIfNew('teaser', el)

View File

@ -0,0 +1,409 @@
// frontend\src\components\ui\Icons.tsx
'use client'
import type { JSX, ReactNode, SVGProps } from 'react'
export type IconProps = SVGProps<SVGSVGElement> & {
title?: string
}
type IconComponent = (props: IconProps) => JSX.Element
function IconBase({
title,
children,
viewBox = '0 0 24 24',
className,
...props
}: IconProps & {
children: ReactNode
}) {
return (
<svg
viewBox={viewBox}
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden={title ? undefined : true}
role={title ? 'img' : undefined}
{...props}
>
{title ? <title>{title}</title> : null}
{children}
</svg>
)
}
/* ---------------------------------- */
/* Einzelne Icons */
/* ---------------------------------- */
export function FemaleBreastIcon(props: IconProps) {
return (
<IconBase {...props}>
<g transform="translate(0 1.5)">
<path d="M4.5 6.5C2.7 8.1 2.4 11.5 4.1 13.7C5.8 15.9 8.8 16.2 10.7 12.7C11.1 11.9 11.5 10.9 12 9.7C12.5 10.9 12.9 11.9 13.3 12.7C15.2 16.2 18.2 15.9 19.9 13.7C21.6 11.5 21.3 8.1 19.5 6.5" />
<circle cx="8" cy="12.2" r="0.8" fill="currentColor" stroke="none" />
<circle cx="16" cy="12.2" r="0.8" fill="currentColor" stroke="none" />
</g>
</IconBase>
)
}
export function ButtocksIcon(props: IconProps) {
return (
<IconBase {...props}>
{/* obere Kante */}
<path d="M8 4.5H16" />
{/* linke Außenkontur */}
<path d="M8 4.5C8 6.3 7.3 7.7 6.5 9.2C5.5 11 5 13.1 5 15.4C5 17.8 5.8 19.9 7 21H10" />
{/* rechte Außenkontur */}
<path d="M16 4.5C16 6.3 16.7 7.7 17.5 9.2C18.5 11 19 13.1 19 15.4C19 17.8 18.2 19.9 17 21H14" />
{/* mittlere Falte */}
<path d="M12 9.8V21" />
{/* innere Po-Linien */}
<path d="M8.8 13.7C10.2 14.6 11.3 13.9 12 11.4" />
<path d="M15.2 13.7C13.8 14.6 12.7 13.9 12 11.4" />
</IconBase>
)
}
export function FemaleGenitaliaIcon(props: IconProps) {
return (
<IconBase {...props}>
{/* äußere Form */}
<path d="M12 2.6C10.7 2.8 9.5 4 8.3 5.9C6.9 8.1 6 10.6 6 13.3C6 18.4 8.8 21.5 12 22C15.2 21.5 18 18.4 18 13.3C18 10.6 17.1 8.1 15.7 5.9C14.5 4 13.3 2.8 12 2.6Z" />
{/* mittlere Form */}
<path d="M12 4.6C11 4.8 10.2 5.8 9.5 7.3C8.8 8.9 8.4 10.9 8.4 13C8.4 17.2 9.9 20.1 12 20.8C14.1 20.1 15.6 17.2 15.6 13C15.6 10.9 15.2 8.9 14.5 7.3C13.8 5.8 13 4.8 12 4.6Z" />
{/* obere kleine Form */}
<path d="M12 4.8C11.4 4.9 10.9 5.5 10.6 6.4C10.2 7.4 10.3 8.5 10.8 9.3C11.1 9.8 11.5 10.1 12 10.2C12.5 10.1 12.9 9.8 13.2 9.3C13.7 8.5 13.8 7.4 13.4 6.4C13.1 5.5 12.6 4.9 12 4.8Z" />
{/* innere längliche Form */}
<path d="M12 10.8C11.4 10.9 10.9 11.6 10.5 12.8C10.1 14 9.9 15.5 9.9 17C9.9 19.7 10.8 21.5 12 22C13.2 21.5 14.1 19.7 14.1 17C14.1 15.5 13.9 14 13.5 12.8C13.1 11.6 12.6 10.9 12 10.8Z" />
</IconBase>
)
}
export function MaleGenitaliaIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M12 4.5C13.9 4.5 15.5 6.1 15.5 8V12.8C15.5 14.7 13.9 16.3 12 16.3C10.1 16.3 8.5 14.7 8.5 12.8V8C8.5 6.1 10.1 4.5 12 4.5Z" />
<circle cx="9.2" cy="18.2" r="1.8" />
<circle cx="14.8" cy="18.2" r="1.8" />
</IconBase>
)
}
export function AnusIcon(props: IconProps) {
return (
<IconBase {...props}>
<circle cx="12" cy="12" r="4.2" />
<circle cx="12" cy="12" r="1.2" />
<path d="M12 3.5V5.2" />
<path d="M12 18.8V20.5" />
<path d="M3.5 12H5.2" />
<path d="M18.8 12H20.5" />
<path d="M6 6L7.2 7.2" />
<path d="M16.8 16.8L18 18" />
<path d="M18 6L16.8 7.2" />
<path d="M7.2 16.8L6 18" />
</IconBase>
)
}
export function CoveredContentIcon(props: IconProps) {
return (
<IconBase {...props}>
<circle cx="12" cy="12" r="8.5" />
<path d="M6 6L18 18" />
</IconBase>
)
}
export function MaleBreastIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M6 9.5C7.2 8.2 9.1 7.5 12 7.5C14.9 7.5 16.8 8.2 18 9.5" />
<path d="M6.5 15.5C8 14.3 9.8 13.8 12 13.8C14.2 13.8 16 14.3 17.5 15.5" />
<circle cx="12" cy="11.1" r="0.9" fill="currentColor" stroke="none" />
</IconBase>
)
}
export function BellyIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M9 4.5C8.4 6.6 8 8.8 8 11C8 14.8 9.2 17.8 12 19.5C14.8 17.8 16 14.8 16 11C16 8.8 15.6 6.6 15 4.5" />
<circle cx="12" cy="12.7" r="0.8" fill="currentColor" stroke="none" />
</IconBase>
)
}
export function FeetIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M8.2 6.5C8.2 9.8 7.6 12.1 6 14.5C5.2 15.7 5.2 17.3 6.3 18.1C7.1 18.7 8.2 18.7 9.1 18.2L11.2 17" />
<path d="M15.8 6.5C15.8 9.8 16.4 12.1 18 14.5C18.8 15.7 18.8 17.3 17.7 18.1C16.9 18.7 15.8 18.7 14.9 18.2L12.8 17" />
<path d="M9.3 7.2H9.31" />
<path d="M14.7 7.2H14.71" />
</IconBase>
)
}
export function FaceIcon(props: IconProps) {
return (
<IconBase {...props}>
<circle cx="12" cy="12" r="8.5" />
<circle cx="9" cy="10" r="0.8" fill="currentColor" stroke="none" />
<circle cx="15" cy="10" r="0.8" fill="currentColor" stroke="none" />
<path d="M9.2 15C10 15.7 10.9 16 12 16C13.1 16 14 15.7 14.8 15" />
</IconBase>
)
}
export function UnknownContentIcon(props: IconProps) {
return (
<IconBase {...props}>
<circle cx="12" cy="12" r="9" />
<path d="M12 8V12" />
<circle cx="12" cy="16" r="0.7" fill="currentColor" stroke="none" />
</IconBase>
)
}
/* ---------------------------------- */
/* Label-Mapping */
/* ---------------------------------- */
type SegmentLabelMeta = {
match: string[]
text: string
icon: IconComponent
}
const SEGMENT_LABEL_META: SegmentLabelMeta[] = [
{
match: ['anus_exposed', 'anus'],
text: 'Anus',
icon: AnusIcon,
},
{
match: ['female_genitalia_exposed', 'vulva_exposed', 'labia_exposed'],
text: 'Vagina',
icon: FemaleGenitaliaIcon,
},
{
match: ['male_genitalia_exposed', 'penis_exposed'],
text: 'Penis',
icon: MaleGenitaliaIcon,
},
{
match: ['female_breast_exposed', 'breast_exposed'],
text: 'Brüste',
icon: FemaleBreastIcon,
},
{
match: ['male_breast_exposed'],
text: 'Männliche Brust',
icon: MaleBreastIcon,
},
{
match: ['buttocks_exposed', 'buttocks'],
text: 'Hintern',
icon: ButtocksIcon,
},
{
match: ['belly_exposed', 'stomach_exposed', 'abdomen_exposed', 'navel_exposed'],
text: 'Bauch',
icon: BellyIcon,
},
{
match: ['feet_exposed', 'foot_exposed'],
text: 'Füße',
icon: FeetIcon,
},
{
match: ['face_female', 'face_male', 'face', 'female_face', 'male_face'],
text: 'Gesicht',
icon: FaceIcon,
},
]
function normalizeSegmentLabel(label?: string): string {
return String(label || '').trim().toLowerCase()
}
function findSegmentLabelMeta(label?: string): SegmentLabelMeta | null {
const normalized = normalizeSegmentLabel(label)
if (!normalized) return null
for (const item of SEGMENT_LABEL_META) {
if (item.match.some((key) => normalized === key || normalized.includes(key))) {
return item
}
}
if (normalized.includes('_covered') || normalized.includes('covered_')) {
return {
match: [],
text: prettifyUnknownLabel(normalized).replace(/\bExposed\b/g, '').trim() || 'Bedeckt',
icon: CoveredContentIcon,
}
}
if (normalized.includes('face')) {
return { match: [], text: 'Gesicht', icon: FaceIcon }
}
if (normalized.includes('belly') || normalized.includes('stomach') || normalized.includes('abdomen') || normalized.includes('navel')) {
return { match: [], text: 'Bauch', icon: BellyIcon }
}
if (normalized.includes('foot') || normalized.includes('feet')) {
return { match: [], text: 'Füße', icon: FeetIcon }
}
if (normalized.includes('male_breast')) {
return { match: [], text: 'Männliche Brust', icon: MaleBreastIcon }
}
return null
}
function prettifyUnknownLabel(label?: string): string {
const normalized = normalizeSegmentLabel(label)
if (!normalized) return 'Unbekannt'
return normalized
.replaceAll('_', ' ')
.replace(/\bmale\b/g, 'männlich')
.replace(/\bfemale\b/g, 'weiblich')
.replace(/\bgenitalia\b/g, 'Genitalien')
.replace(/\bbreast\b/g, 'Brust')
.replace(/\bbuttocks\b/g, 'Gesäß')
.replace(/\banus\b/g, 'Anus')
.replace(/\bbelly\b/g, 'Bauch')
.replace(/\bstomach\b/g, 'Bauch')
.replace(/\babdomen\b/g, 'Bauch')
.replace(/\bnavel\b/g, 'Bauchnabel')
.replace(/\bfeet\b/g, 'Füße')
.replace(/\bfoot\b/g, 'Fuß')
.replace(/\bface\b/g, 'Gesicht')
.replace(/\bcovered\b/g, 'bedeckt')
.replace(/\bexposed\b/g, 'erkannt')
.replace(/\s+/g, ' ')
.trim()
.replace(/^./, (char) => char.toUpperCase())
}
export function getSegmentIcon(label?: string): IconComponent {
return findSegmentLabelMeta(label)?.icon ?? UnknownContentIcon
}
export function getSegmentLabelText(label?: string): string {
return findSegmentLabelMeta(label)?.text ?? prettifyUnknownLabel(label)
}
export function SegmentLabelIcon({
label,
...props
}: IconProps & {
label?: string
}) {
const Icon = getSegmentIcon(label)
return <Icon {...props} />
}
export type SegmentLabelItem = {
key: string
text: string
icon: IconComponent
}
export function getSegmentLabelItem(label?: string): SegmentLabelItem {
const meta = findSegmentLabelMeta(label)
if (meta) {
return {
key: normalizeSegmentLabel(label) || meta.text,
text: meta.text,
icon: meta.icon,
}
}
return {
key: normalizeSegmentLabel(label) || 'unknown',
text: prettifyUnknownLabel(label),
icon: UnknownContentIcon,
}
}
export function getSegmentLabelItems(labels: Array<string | undefined | null>): SegmentLabelItem[] {
const out: SegmentLabelItem[] = []
const seen = new Set<string>()
for (const raw of labels) {
const item = getSegmentLabelItem(raw || undefined)
const dedupeKey = `${item.text}::${item.icon.name}`
if (seen.has(dedupeKey)) continue
seen.add(dedupeKey)
out.push(item)
}
return out
}
export function getHighestPrioritySegmentLabelItem(
labels: Array<string | undefined | null>
): SegmentLabelItem {
const items = getSegmentLabelItems(labels)
if (items.length === 0) {
return getSegmentLabelItem(undefined)
}
const priority = (item: SegmentLabelItem): number => {
switch (item.text) {
case 'Vagina':
return 0
case 'Brüste':
return 1
case 'Gesäß':
return 2
case 'Penis':
return 3
case 'Anus':
return 4
case 'Männliche Brust':
return 5
case 'Bauch':
return 6
case 'Füße':
return 7
case 'Gesicht':
return 8
default:
return 999
}
}
return [...items].sort((a, b) => {
const pa = priority(a)
const pb = priority(b)
if (pa !== pb) return pa - pb
return a.text.localeCompare(b.text, 'de')
})[0]
}

View File

@ -1,17 +1,14 @@
// components/ui/LabeledSwitch.tsx // components/ui/LabeledSwitch.tsx
'use client' 'use client'
import * as React from 'react' import { useId, type ReactNode } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import Switch, { type SwitchProps } from './Switch' import Switch, { type SwitchProps } from './Switch'
type Props = Omit<SwitchProps, 'ariaLabelledby' | 'ariaDescribedby' | 'ariaLabel'> & { type Props = Omit<SwitchProps, 'ariaLabelledby' | 'ariaDescribedby' | 'ariaLabel'> & {
label: React.ReactNode label: ReactNode
description?: React.ReactNode description?: ReactNode
/** "left" = Label links / Switch rechts (wie Beispiel) */
labelPosition?: 'left' | 'right' labelPosition?: 'left' | 'right'
/** Layout wrapper classes */
className?: string className?: string
} }
@ -21,30 +18,50 @@ export default function LabeledSwitch({
labelPosition = 'left', labelPosition = 'left',
id, id,
className, className,
compact = false,
...switchProps ...switchProps
}: Props) { }: Props) {
const reactId = React.useId() const reactId = useId()
const switchId = id ?? `sw-${reactId}` const switchId = id ?? `sw-${reactId}`
const labelId = `${switchId}-label` const labelId = `${switchId}-label`
const descId = `${switchId}-desc` const descId = `${switchId}-desc`
if (labelPosition === 'right') { if (labelPosition === 'right') {
// With right label Beispiel
return ( return (
<div className={clsx('flex items-center justify-between gap-3', className)}> <div
className={clsx(
'flex items-center justify-between',
compact ? 'gap-2' : 'gap-3',
className
)}
>
<Switch <Switch
{...switchProps} {...switchProps}
compact={compact}
id={switchId} id={switchId}
ariaLabelledby={labelId} ariaLabelledby={labelId}
ariaDescribedby={description ? descId : undefined} ariaDescribedby={description ? descId : undefined}
/> />
<div className="text-sm"> <div className={compact ? 'text-xs' : 'text-sm'}>
<label id={labelId} htmlFor={switchId} className="font-medium text-gray-900 dark:text-white"> <label
id={labelId}
htmlFor={switchId}
className={clsx(
'font-medium text-gray-900 dark:text-white',
compact ? 'leading-5' : ''
)}
>
{label} {label}
</label>{' '} </label>{' '}
{description ? ( {description ? (
<span id={descId} className="text-gray-500 dark:text-gray-400"> <span
id={descId}
className={clsx(
'text-gray-500 dark:text-gray-400',
compact ? 'text-[11px]' : ''
)}
>
{description} {description}
</span> </span>
) : null} ) : null}
@ -53,15 +70,34 @@ export default function LabeledSwitch({
) )
} }
// With left label and description Beispiel
return ( return (
<div className={clsx('flex items-center justify-between', className)}> <div
<span className="flex grow flex-col pr-2"> className={clsx(
<label id={labelId} htmlFor={switchId} className="text-sm/6 font-medium text-gray-900 dark:text-white"> 'flex items-center justify-between',
compact ? 'gap-2' : 'gap-3',
className
)}
>
<span className={clsx('flex grow flex-col min-w-0', compact ? 'pr-1' : 'pr-2')}>
<label
id={labelId}
htmlFor={switchId}
className={clsx(
'font-medium text-gray-900 dark:text-white truncate',
compact ? 'text-xs leading-5' : 'text-sm/6'
)}
>
{label} {label}
</label> </label>
{description ? ( {description ? (
<span id={descId} className="text-sm text-gray-500 dark:text-gray-400"> <span
id={descId}
className={clsx(
'text-gray-500 dark:text-gray-400',
compact ? 'text-[11px]' : 'text-sm'
)}
>
{description} {description}
</span> </span>
) : null} ) : null}
@ -69,10 +105,11 @@ export default function LabeledSwitch({
<Switch <Switch
{...switchProps} {...switchProps}
compact={compact}
id={switchId} id={switchId}
ariaLabelledby={labelId} ariaLabelledby={labelId}
ariaDescribedby={description ? descId : undefined} ariaDescribedby={description ? descId : undefined}
/> />
</div> </div>
) )
} }

View File

@ -1,15 +1,15 @@
'use client' 'use client'
import * as React from 'react' import { useEffect, useRef, useState, type ReactNode } from 'react'
type Props = { type Props = {
children: React.ReactNode children: ReactNode
/** Wenn true: sofort mounten (ohne IntersectionObserver) */ /** Wenn true: sofort mounten (ohne IntersectionObserver) */
force?: boolean force?: boolean
/** Vorladen bevor es wirklich sichtbar ist */ /** Vorladen bevor es wirklich sichtbar ist */
rootMargin?: string rootMargin?: string
/** Optional: Platzhalter bis mounted */ /** Optional: Platzhalter bis mounted */
placeholder?: React.ReactNode placeholder?: ReactNode
className?: string className?: string
} }
@ -20,15 +20,15 @@ export default function LazyMount({
placeholder = null, placeholder = null,
className, className,
}: Props) { }: Props) {
const ref = React.useRef<HTMLDivElement | null>(null) const ref = useRef<HTMLDivElement | null>(null)
const [mounted, setMounted] = React.useState<boolean>(force) const [mounted, setMounted] = useState<boolean>(force)
// Wenn force später true wird (z.B. inlinePlay startet) -> sofort mounten // Wenn force später true wird (z.B. inlinePlay startet) -> sofort mounten
React.useEffect(() => { useEffect(() => {
if (force && !mounted) setMounted(true) if (force && !mounted) setMounted(true)
}, [force, mounted]) }, [force, mounted])
React.useEffect(() => { useEffect(() => {
if (mounted || force) return if (mounted || force) return
const el = ref.current const el = ref.current
if (!el) return if (!el) return

View File

@ -73,6 +73,9 @@ export default function LiveVideo({
useEffect(() => { useEffect(() => {
let watchdogTimer: number | null = null let watchdogTimer: number | null = null
let readySent = false let readySent = false
let stalledRetries = 0
let attemptStartedAt = Date.now()
let lastProgressAt = Date.now()
const video = ref.current const video = ref.current
if (!video) return if (!video) return
@ -92,6 +95,31 @@ export default function LiveVideo({
} catch {} } catch {}
} }
const markBroken = () => {
const reason = normalizeBrokenReason(roomStatus)
setBroken(true)
setBrokenReason(reason)
}
const beginLoad = () => {
readySent = false
attemptStartedAt = Date.now()
lastProgressAt = Date.now()
onLoadingStartRef.current?.()
hardReset()
video.src = src
video.load()
const playPromise = video.play()
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(() => {
// Nicht sofort abbrechen Watchdog entscheidet nach Timeout.
})
}
}
hardReset() hardReset()
if (!src) { if (!src) {
@ -100,90 +128,83 @@ export default function LiveVideo({
return return
} }
onLoadingStartRef.current?.()
const markReady = () => { const markReady = () => {
lastProgressAt = Date.now()
if (readySent) return if (readySent) return
readySent = true readySent = true
onReadyRef.current?.() onReadyRef.current?.()
} }
video.src = src
video.load()
video.play().catch(() => {})
let lastT = Date.now()
const onTime = () => { const onTime = () => {
lastT = Date.now() lastProgressAt = Date.now()
markReady() markReady()
} }
const onLoadedData = () => { const onLoadedData = () => {
lastProgressAt = Date.now()
markReady() markReady()
} }
const onCanPlay = () => { const onCanPlay = () => {
lastProgressAt = Date.now()
markReady() markReady()
} }
const onPlaying = () => { const onPlaying = () => {
lastProgressAt = Date.now()
markReady() markReady()
} }
const onError = () => {
markBroken()
}
video.addEventListener('timeupdate', onTime) video.addEventListener('timeupdate', onTime)
video.addEventListener('loadeddata', onLoadedData) video.addEventListener('loadeddata', onLoadedData)
video.addEventListener('canplay', onCanPlay) video.addEventListener('canplay', onCanPlay)
video.addEventListener('playing', onPlaying) video.addEventListener('playing', onPlaying)
video.addEventListener('error', onError)
let stalledRetries = 0 beginLoad()
watchdogTimer = window.setInterval(() => { watchdogTimer = window.setInterval(() => {
if (!video.paused && Date.now() - lastT > 12_000) { const now = Date.now()
// Stream wurde nie ready -> nicht ewig Spinner zeigen
if (!readySent && now - attemptStartedAt > 12_000) {
if (stalledRetries < 1) { if (stalledRetries < 1) {
stalledRetries += 1 stalledRetries += 1
readySent = false beginLoad()
onLoadingStartRef.current?.()
hardReset()
video.src = src
video.load()
video.play().catch(() => {})
lastT = Date.now()
return return
} }
const reason = normalizeBrokenReason(roomStatus) markBroken()
if (reason === 'unknown') {
return
}
setBroken(true)
setBrokenReason(reason)
}
}, 4_000)
const onError = () => {
const reason = normalizeBrokenReason(roomStatus)
if (reason === 'unknown') {
return return
} }
setBroken(true) // Stream war ready, hängt aber später
setBrokenReason(reason) if (readySent && !video.paused && now - lastProgressAt > 12_000) {
} if (stalledRetries < 1) {
stalledRetries += 1
beginLoad()
return
}
video.addEventListener('error', onError) markBroken()
}
}, 1_000)
return () => { return () => {
if (watchdogTimer) window.clearInterval(watchdogTimer) if (watchdogTimer) window.clearInterval(watchdogTimer)
watchdogTimer = null watchdogTimer = null
video.removeEventListener('timeupdate', onTime) video.removeEventListener('timeupdate', onTime)
video.removeEventListener('loadeddata', onLoadedData) video.removeEventListener('loadeddata', onLoadedData)
video.removeEventListener('canplay', onCanPlay) video.removeEventListener('canplay', onCanPlay)
video.removeEventListener('playing', onPlaying) video.removeEventListener('playing', onPlaying)
video.removeEventListener('error', onError) video.removeEventListener('error', onError)
hardReset() hardReset()
} }
}, [src, roomStatus]) }, [src, roomStatus])

View File

@ -2,7 +2,7 @@
'use client' 'use client'
import * as React from 'react' import { type ReactNode } from 'react'
type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | number type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | number
@ -12,7 +12,7 @@ export type LoadingSpinnerProps = {
/** Farbe via Tailwind (z.B. "text-indigo-500") */ /** Farbe via Tailwind (z.B. "text-indigo-500") */
className?: string className?: string
/** Optionaler Text neben dem Spinner (sichtbar) */ /** Optionaler Text neben dem Spinner (sichtbar) */
label?: React.ReactNode label?: ReactNode
/** Screenreader-Text (wenn label nicht gesetzt ist) */ /** Screenreader-Text (wenn label nicht gesetzt ist) */
srLabel?: string srLabel?: string
/** Zentriert Spinner + Label als Inline-Flex */ /** Zentriert Spinner + Label als Inline-Flex */

View File

@ -1,5 +1,5 @@
// frontend/src/components/ui/LoginPage.tsx // frontend/src/components/ui/LoginPage.tsx
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ClipboardEvent } from 'react'
import Button from './Button' import Button from './Button'
type Props = { type Props = {
@ -170,7 +170,7 @@ export default function LoginPage({ onLoggedIn }: Props) {
if (i < 5) window.setTimeout(() => focusDigit(i + 1), 0) if (i < 5) window.setTimeout(() => focusDigit(i + 1), 0)
} }
function handleDigitKeyDown(i: number, ev: React.KeyboardEvent<HTMLInputElement>) { function handleDigitKeyDown(i: number, ev: KeyboardEvent<HTMLInputElement>) {
if (ev.key === 'Backspace') { if (ev.key === 'Backspace') {
const cur = codeDigits[i] const cur = codeDigits[i]
if (cur) { if (cur) {
@ -201,7 +201,7 @@ export default function LoginPage({ onLoggedIn }: Props) {
} }
} }
function handleDigitPaste(i: number, ev: React.ClipboardEvent<HTMLInputElement>) { function handleDigitPaste(i: number, ev: ClipboardEvent<HTMLInputElement>) {
const text = ev.clipboardData.getData('text') || '' const text = ev.clipboardData.getData('text') || ''
const only = text.replace(/\D/g, '').slice(0, 6) const only = text.replace(/\D/g, '').slice(0, 6)
if (!only) return if (!only) return

View File

@ -2,7 +2,7 @@
'use client' 'use client'
import * as React from 'react' import { useEffect, useRef, useState, useMemo, useCallback, type ReactNode } from 'react'
import type { RecordJob } from '../../types' import type { RecordJob } from '../../types'
import Modal from './Modal' import Modal from './Modal'
@ -518,61 +518,61 @@ export default function ModelDetails({
//const isDesktop = useMediaQuery('(min-width: 640px)') //const isDesktop = useMediaQuery('(min-width: 640px)')
const [models, setModels] = React.useState<StoredModel[]>([]) const [models, setModels] = useState<StoredModel[]>([])
const [room, setRoom] = React.useState<ChaturbateRoom | null>(null) const [room, setRoom] = useState<ChaturbateRoom | null>(null)
const [roomMeta, setRoomMeta] = React.useState<Pick<OnlineResp, 'enabled' | 'fetchedAt' | 'lastError'> | null>(null) const [roomMeta, setRoomMeta] = useState<Pick<OnlineResp, 'enabled' | 'fetchedAt' | 'lastError'> | null>(null)
const [bio, setBio] = React.useState<BioContext | null>(null) const [bio, setBio] = useState<BioContext | null>(null)
const [bioMeta, setBioMeta] = React.useState<Pick<BioResp, 'enabled' | 'fetchedAt' | 'lastError'> | null>(null) const [bioMeta, setBioMeta] = useState<Pick<BioResp, 'enabled' | 'fetchedAt' | 'lastError'> | null>(null)
const [bioLoading, setBioLoading] = React.useState(false) const [bioLoading, setBioLoading] = useState(false)
const [done, setDone] = React.useState<RecordJob[]>([]) const [done, setDone] = useState<RecordJob[]>([])
const [doneLoading, setDoneLoading] = React.useState(false) const [doneLoading, setDoneLoading] = useState(false)
const [running, setRunning] = React.useState<RecordJob[]>([]) const [running, setRunning] = useState<RecordJob[]>([])
const [runningLoading, setRunningLoading] = React.useState(false) const [runningLoading, setRunningLoading] = useState(false)
const runningReqSeqRef = React.useRef(0) const runningReqSeqRef = useRef(0)
const [, setBioRefreshSeq] = React.useState(0) const [, setBioRefreshSeq] = useState(0)
const [imgViewer, setImgViewer] = React.useState<{ src: string; alt?: string } | null>(null) const [imgViewer, setImgViewer] = useState<{ src: string; alt?: string } | null>(null)
const [, setRunningHover] = React.useState(false) const [, setRunningHover] = useState(false)
const [stopPending, setStopPending] = React.useState(false) const [stopPending, setStopPending] = useState(false)
const [doneTotalCount, setDoneTotalCount] = React.useState(0) const [doneTotalCount, setDoneTotalCount] = useState(0)
const [donePage, setDonePage] = React.useState(1) const [donePage, setDonePage] = useState(1)
const DONE_PAGE_SIZE = 4 const DONE_PAGE_SIZE = 4
const key = normalizeModelKey(modelKey) const key = normalizeModelKey(modelKey)
type TabKey = 'info' | 'downloads' | 'running' type TabKey = 'info' | 'downloads' | 'running'
const [tab, setTab] = React.useState<TabKey>('info') const [tab, setTab] = useState<TabKey>('info')
const bioReqRef = React.useRef<AbortController | null>(null) const bioReqRef = useRef<AbortController | null>(null)
// ===== Gallery UI State (wie FinishedDownloadsGalleryView) ===== // ===== Gallery UI State (wie FinishedDownloadsGalleryView) =====
const [durations, setDurations] = React.useState<Record<string, number>>({}) const [durations, setDurations] = useState<Record<string, number>>({})
const [hoverTeaserKey, setHoverTeaserKey] = React.useState<string | null>(null) const [hoverTeaserKey, setHoverTeaserKey] = useState<string | null>(null)
const [teaserKey, setTeaserKey] = React.useState<string | null>(null) const [teaserKey, setTeaserKey] = useState<string | null>(null)
const [hoveredModelPreviewKey, setHoveredModelPreviewKey] = React.useState<string | null>(null) const [hoveredModelPreviewKey, setHoveredModelPreviewKey] = useState<string | null>(null)
const [scrubIndexByKey, setScrubIndexByKey] = React.useState<Record<string, number | undefined>>({}) const [scrubIndexByKey, setScrubIndexByKey] = useState<Record<string, number | undefined>>({})
const [hoveredThumbKey, setHoveredThumbKey] = React.useState<string | null>(null) const [hoveredThumbKey, setHoveredThumbKey] = useState<string | null>(null)
const lower = React.useCallback((s: string) => String(s ?? '').toLowerCase(), []) const lower = useCallback((s: string) => String(s ?? '').toLowerCase(), [])
const deletingKeys = React.useMemo(() => new Set<string>(), []) const deletingKeys = useMemo(() => new Set<string>(), [])
const keepingKeys = React.useMemo(() => new Set<string>(), []) const keepingKeys = useMemo(() => new Set<string>(), [])
const removingKeys = React.useMemo(() => new Set<string>(), []) const removingKeys = useMemo(() => new Set<string>(), [])
const deletedKeys = React.useMemo(() => new Set<string>(), []) const deletedKeys = useMemo(() => new Set<string>(), [])
React.useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
bioReqRef.current?.abort() bioReqRef.current?.abort()
bioReqRef.current = null bioReqRef.current = null
@ -603,7 +603,7 @@ export default function ModelDetails({
}, [open, key]) }, [open, key])
// ✅ 1) Beim Öffnen sofort aus Cache rendern // ✅ 1) Beim Öffnen sofort aus Cache rendern
React.useEffect(() => { useEffect(() => {
if (!open || !key) return if (!open || !key) return
// Online // Online
@ -634,12 +634,12 @@ export default function ModelDetails({
} }
}, [open, key]) }, [open, key])
React.useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
setStopPending(false) setStopPending(false)
}, [open, key]) }, [open, key])
const refreshBio = React.useCallback(async () => { const refreshBio = useCallback(async () => {
if (!key) return if (!key) return
// vorherigen abbrechen // vorherigen abbrechen
@ -681,13 +681,13 @@ export default function ModelDetails({
} }
}, [key, cookies]) }, [key, cookies])
React.useEffect(() => { useEffect(() => {
if (open) return if (open) return
bioReqRef.current?.abort() bioReqRef.current?.abort()
bioReqRef.current = null bioReqRef.current = null
}, [open]) }, [open])
const refetchModels = React.useCallback(async () => { const refetchModels = useCallback(async () => {
try { try {
const r = await fetch('/api/models', { cache: 'no-store' }) const r = await fetch('/api/models', { cache: 'no-store' })
const data = (await r.json().catch(() => null)) as any const data = (await r.json().catch(() => null)) as any
@ -697,7 +697,7 @@ export default function ModelDetails({
} }
}, []) }, [])
const refetchDone = React.useCallback(async () => { const refetchDone = useCallback(async () => {
if (!key) return if (!key) return
setDoneLoading(true) setDoneLoading(true)
try { try {
@ -721,13 +721,13 @@ export default function ModelDetails({
} }
}, [key, donePage]) }, [key, donePage])
const refetchDoneRef = React.useRef(refetchDone) const refetchDoneRef = useRef(refetchDone)
React.useEffect(() => { useEffect(() => {
refetchDoneRef.current = refetchDone refetchDoneRef.current = refetchDone
}, [refetchDone]) }, [refetchDone])
React.useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const es = new EventSource('/api/stream') const es = new EventSource('/api/stream')
@ -760,39 +760,39 @@ export default function ModelDetails({
} as any } as any
} }
const openImage = React.useCallback((src?: string | null, alt?: string) => { const openImage = useCallback((src?: string | null, alt?: string) => {
const s = String(src ?? '').trim() const s = String(src ?? '').trim()
if (!s) return if (!s) return
setImgViewer({ src: s, alt }) setImgViewer({ src: s, alt })
}, []) }, [])
// wenn Modal zu geht, wieder "normal" (kein force refresh) // wenn Modal zu geht, wieder "normal" (kein force refresh)
React.useEffect(() => { useEffect(() => {
if (!open) setBioRefreshSeq(0) if (!open) setBioRefreshSeq(0)
}, [open]) }, [open])
const runningList = React.useMemo(() => { const runningList = useMemo(() => {
return Array.isArray(runningJobs) ? runningJobs : running return Array.isArray(runningJobs) ? runningJobs : running
}, [runningJobs, running]) }, [runningJobs, running])
React.useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
setDonePage(1) setDonePage(1)
}, [open, modelKey]) }, [open, modelKey])
React.useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
void refetchModels() void refetchModels()
}, [open, refetchModels]) }, [open, refetchModels])
// Done downloads (inkl. keep/<model>/) -> serverseitig paginiert laden // Done downloads (inkl. keep/<model>/) -> serverseitig paginiert laden
React.useEffect(() => { useEffect(() => {
if (!open || !key) return if (!open || !key) return
void refetchDone() void refetchDone()
}, [open, key, refetchDone]) }, [open, key, refetchDone])
// Running jobs // Running jobs
React.useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
if (Array.isArray(runningJobs)) return if (Array.isArray(runningJobs)) return
@ -824,12 +824,12 @@ export default function ModelDetails({
} }
}, [open, runningJobs]) }, [open, runningJobs])
const model = React.useMemo(() => { const model = useMemo(() => {
if (!key) return null if (!key) return null
return models.find((m) => (m.modelKey || '').toLowerCase() === key) ?? null return models.find((m) => (m.modelKey || '').toLowerCase() === key) ?? null
}, [models, key]) }, [models, key])
const storedRoomFromSnap = React.useMemo<ChaturbateRoom | null>(() => { const storedRoomFromSnap = useMemo<ChaturbateRoom | null>(() => {
const raw = (model as any)?.cbOnlineJson const raw = (model as any)?.cbOnlineJson
if (!raw || typeof raw !== 'string') return null if (!raw || typeof raw !== 'string') return null
try { try {
@ -839,7 +839,7 @@ export default function ModelDetails({
} }
}, [model]) }, [model])
const storedRoomMeta = React.useMemo(() => { const storedRoomMeta = useMemo(() => {
const fetchedAt = (model as any)?.cbOnlineFetchedAt const fetchedAt = (model as any)?.cbOnlineFetchedAt
const lastError = (model as any)?.cbOnlineLastError const lastError = (model as any)?.cbOnlineLastError
if (!fetchedAt && !lastError) return null if (!fetchedAt && !lastError) return null
@ -851,7 +851,7 @@ export default function ModelDetails({
const doneMatches = done const doneMatches = done
const runningMatches = React.useMemo(() => { const runningMatches = useMemo(() => {
if (!key) return [] if (!key) return []
return runningList.filter((j) => { return runningList.filter((j) => {
const m = modelNameFromOutput(j.output) const m = modelNameFromOutput(j.output)
@ -861,7 +861,7 @@ export default function ModelDetails({
const titleName = effectiveRoom?.display_name || model?.modelKey || key || 'Model' const titleName = effectiveRoom?.display_name || model?.modelKey || key || 'Model'
const titleUsername = React.useMemo(() => { const titleUsername = useMemo(() => {
const raw = String( const raw = String(
effectiveRoom?.username || effectiveRoom?.username ||
model?.modelKey || model?.modelKey ||
@ -877,7 +877,7 @@ export default function ModelDetails({
const heroImgFull = effectiveRoom?.image_url || heroImg const heroImgFull = effectiveRoom?.image_url || heroImg
const roomUrl = effectiveRoom?.chat_room_url_revshare || effectiveRoom?.chat_room_url || '' const roomUrl = effectiveRoom?.chat_room_url_revshare || effectiveRoom?.chat_room_url || ''
const profileUsername = React.useMemo(() => { const profileUsername = useMemo(() => {
const raw = String( const raw = String(
effectiveRoom?.username || effectiveRoom?.username ||
model?.modelKey || model?.modelKey ||
@ -888,7 +888,7 @@ export default function ModelDetails({
return raw.replace(/^@/, '') return raw.replace(/^@/, '')
}, [effectiveRoom?.username, model?.modelKey, key]) }, [effectiveRoom?.username, model?.modelKey, key])
const profileHref = React.useMemo(() => { const profileHref = useMemo(() => {
if (roomUrl) return roomUrl if (roomUrl) return roomUrl
if (!profileUsername) return '' if (!profileUsername) return ''
return absCbUrl(`/${profileUsername}/`) return absCbUrl(`/${profileUsername}/`)
@ -914,7 +914,7 @@ export default function ModelDetails({
const about = stripHtmlToText(bio?.about_me) const about = stripHtmlToText(bio?.about_me)
const wish = stripHtmlToText(bio?.wish_list) const wish = stripHtmlToText(bio?.wish_list)
const effectiveLocationParts = React.useMemo(() => { const effectiveLocationParts = useMemo(() => {
const rawLocation = String(effectiveRoom?.location ?? bioLocation ?? '').trim() const rawLocation = String(effectiveRoom?.location ?? bioLocation ?? '').trim()
const rawCountry = normalizeCountryName(effectiveRoom?.country) const rawCountry = normalizeCountryName(effectiveRoom?.country)
@ -998,11 +998,11 @@ export default function ModelDetails({
const effectiveLocationText = effectiveLocationParts.locationText const effectiveLocationText = effectiveLocationParts.locationText
const effectiveCountryText = effectiveLocationParts.countryText const effectiveCountryText = effectiveLocationParts.countryText
const effectiveLocationSubText = React.useMemo(() => { const effectiveLocationSubText = useMemo(() => {
return effectiveCountryText || '' return effectiveCountryText || ''
}, [effectiveCountryText]) }, [effectiveCountryText])
const effectiveLocationTitle = React.useMemo(() => { const effectiveLocationTitle = useMemo(() => {
if (effectiveLocationText && effectiveLocationSubText) { if (effectiveLocationText && effectiveLocationSubText) {
return `${effectiveLocationText} · ${effectiveLocationSubText}` return `${effectiveLocationText} · ${effectiveLocationSubText}`
} }
@ -1018,7 +1018,7 @@ export default function ModelDetails({
? effectiveRoom.age ? effectiveRoom.age
: null : null
const effectiveBirthday = React.useMemo(() => { const effectiveBirthday = useMemo(() => {
const bioBirthday = String(bio?.display_birthday ?? '').trim() const bioBirthday = String(bio?.display_birthday ?? '').trim()
if (bioBirthday) return bioBirthday if (bioBirthday) return bioBirthday
@ -1028,15 +1028,15 @@ export default function ModelDetails({
return '' return ''
}, [bio?.display_birthday, effectiveRoom?.birthday]) }, [bio?.display_birthday, effectiveRoom?.birthday])
const effectiveAgeText = React.useMemo(() => { const effectiveAgeText = useMemo(() => {
return effectiveAge != null ? String(effectiveAge) : '' return effectiveAge != null ? String(effectiveAge) : ''
}, [effectiveAge]) }, [effectiveAge])
const effectiveBirthdayText = React.useMemo(() => { const effectiveBirthdayText = useMemo(() => {
return fmtBirthDate(effectiveBirthday) return fmtBirthDate(effectiveBirthday)
}, [effectiveBirthday]) }, [effectiveBirthday])
const effectiveAgeTitle = React.useMemo(() => { const effectiveAgeTitle = useMemo(() => {
if (effectiveAgeText && effectiveBirthdayText) { if (effectiveAgeText && effectiveBirthdayText) {
return `${effectiveAgeText} · ${effectiveBirthdayText}` return `${effectiveAgeText} · ${effectiveBirthdayText}`
} }
@ -1049,7 +1049,7 @@ export default function ModelDetails({
const photos = Array.isArray(bio?.photo_sets) ? bio!.photo_sets! : [] const photos = Array.isArray(bio?.photo_sets) ? bio!.photo_sets! : []
const interested = Array.isArray(bio?.interested_in) ? bio!.interested_in! : [] const interested = Array.isArray(bio?.interested_in) ? bio!.interested_in! : []
const allTags = React.useMemo(() => { const allTags = useMemo(() => {
const a = splitTags(model?.tags) const a = splitTags(model?.tags)
const b = Array.isArray(effectiveRoom?.tags) ? (effectiveRoom!.tags as string[]) : [] const b = Array.isArray(effectiveRoom?.tags) ? (effectiveRoom!.tags as string[]) : []
const map = new Map<string, string>() const map = new Map<string, string>()
@ -1066,9 +1066,9 @@ export default function ModelDetails({
label, label,
value, value,
}: { }: {
icon: React.ReactNode icon: ReactNode
label: string label: string
value: React.ReactNode value: ReactNode
}) => ( }) => (
<div className="rounded-lg border border-gray-200/70 bg-white/60 px-2.5 py-2 sm:px-3 dark:border-white/10 dark:bg-white/5"> <div className="rounded-lg border border-gray-200/70 bg-white/60 px-2.5 py-2 sm:px-3 dark:border-white/10 dark:bg-white/5">
<div className="flex items-center gap-1.5 text-[11px] sm:text-xs text-gray-600 dark:text-gray-300"> <div className="flex items-center gap-1.5 text-[11px] sm:text-xs text-gray-600 dark:text-gray-300">
@ -1079,14 +1079,14 @@ export default function ModelDetails({
</div> </div>
) )
const keyFor = React.useCallback((j: RecordJob) => { const keyFor = useCallback((j: RecordJob) => {
// stabil, auch wenn id fehlt // stabil, auch wenn id fehlt
const id = String((j as any)?.id ?? '') const id = String((j as any)?.id ?? '')
const out = String(j.output ?? '') const out = String(j.output ?? '')
return id ? `${id}::${out}` : out return id ? `${id}::${out}` : out
}, []) }, [])
const handleToggleHot = React.useCallback( const handleToggleHot = useCallback(
async (job: RecordJob) => { async (job: RecordJob) => {
const out = job.output || '' const out = job.output || ''
const oldFile = baseName(out) const oldFile = baseName(out)
@ -1118,7 +1118,7 @@ export default function ModelDetails({
[onToggleHot, refetchDone] [onToggleHot, refetchDone]
) )
const handleScrubberClickIndex = React.useCallback( const handleScrubberClickIndex = useCallback(
(job: RecordJob, segmentIndex: number, _segmentCount: number) => { (job: RecordJob, segmentIndex: number, _segmentCount: number) => {
const sprite = readPreviewSpriteInfo((job as any)?.meta) const sprite = readPreviewSpriteInfo((job as any)?.meta)
const step = sprite.stepSeconds > 0 ? sprite.stepSeconds : 5 const step = sprite.stepSeconds > 0 ? sprite.stepSeconds : 5
@ -1128,7 +1128,7 @@ export default function ModelDetails({
[onOpenPlayer] [onOpenPlayer]
) )
const handleToggleFavoriteModel = React.useCallback(async () => { const handleToggleFavoriteModel = useCallback(async () => {
if (!key) return if (!key) return
// ✅ UI sofort (optimistisch) // ✅ UI sofort (optimistisch)
@ -1147,7 +1147,7 @@ export default function ModelDetails({
refetchModels() refetchModels()
}, [key, onToggleFavorite, refetchModels]) }, [key, onToggleFavorite, refetchModels])
const handleToggleLikeModel = React.useCallback(async () => { const handleToggleLikeModel = useCallback(async () => {
if (!key) return if (!key) return
// liked ist bei dir boolean | null -> wir togglen "true <-> false" // liked ist bei dir boolean | null -> wir togglen "true <-> false"
@ -1163,7 +1163,7 @@ export default function ModelDetails({
refetchModels() refetchModels()
}, [key, onToggleLike, refetchModels]) }, [key, onToggleLike, refetchModels])
const handleToggleWatchModel = React.useCallback(async () => { const handleToggleWatchModel = useCallback(async () => {
if (!key) return if (!key) return
// ✅ UI sofort (optimistisch) // ✅ UI sofort (optimistisch)
@ -1182,7 +1182,7 @@ export default function ModelDetails({
refetchModels() refetchModels()
}, [key, onToggleWatch, refetchModels]) }, [key, onToggleWatch, refetchModels])
React.useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setHoverTeaserKey(null) setHoverTeaserKey(null)
setTeaserKey(null) setTeaserKey(null)
@ -1193,7 +1193,7 @@ export default function ModelDetails({
} }
}, [open, key]) }, [open, key])
const handleDuration = React.useCallback( const handleDuration = useCallback(
(job: RecordJob, seconds: number) => { (job: RecordJob, seconds: number) => {
const k = keyFor(job) const k = keyFor(job)
setDurations((prev) => (prev[k] === seconds ? prev : { ...prev, [k]: seconds })) setDurations((prev) => (prev[k] === seconds ? prev : { ...prev, [k]: seconds }))
@ -1201,7 +1201,7 @@ export default function ModelDetails({
[keyFor] [keyFor]
) )
const handleHoverPreviewKeyChange = React.useCallback( const handleHoverPreviewKeyChange = useCallback(
(k: string | null) => { (k: string | null) => {
setHoverTeaserKey(k) setHoverTeaserKey(k)
if (teaserPlayback === 'hover') setTeaserKey(k) if (teaserPlayback === 'hover') setTeaserKey(k)
@ -1209,7 +1209,7 @@ export default function ModelDetails({
[teaserPlayback] [teaserPlayback]
) )
const runtimeOf = React.useCallback( const runtimeOf = useCallback(
(job: RecordJob) => { (job: RecordJob) => {
const k = keyFor(job) const k = keyFor(job)
const raw = const raw =
@ -1229,7 +1229,7 @@ export default function ModelDetails({
[durations, keyFor] [durations, keyFor]
) )
const sizeBytesOf = React.useCallback((job: RecordJob) => { const sizeBytesOf = useCallback((job: RecordJob) => {
const v = const v =
(job as any)?.sizeBytes ?? (job as any)?.sizeBytes ??
(job as any)?.size ?? (job as any)?.size ??
@ -1238,9 +1238,9 @@ export default function ModelDetails({
return typeof v === 'number' && Number.isFinite(v) ? v : null return typeof v === 'number' && Number.isFinite(v) ? v : null
}, []) }, [])
const formatBytes = React.useCallback((bytes?: number | null) => fmtBytes(bytes ?? null), []) const formatBytes = useCallback((bytes?: number | null) => fmtBytes(bytes ?? null), [])
const setScrubIndexForKey = React.useCallback((key: string, index: number | undefined) => { const setScrubIndexForKey = useCallback((key: string, index: number | undefined) => {
setScrubIndexByKey((prev) => { setScrubIndexByKey((prev) => {
if (index === undefined) { if (index === undefined) {
if (!(key in prev)) return prev if (!(key in prev)) return prev
@ -1253,12 +1253,12 @@ export default function ModelDetails({
}) })
}, []) }, [])
const clearScrubIndex = React.useCallback( const clearScrubIndex = useCallback(
(key: string) => setScrubIndexForKey(key, undefined), (key: string) => setScrubIndexForKey(key, undefined),
[setScrubIndexForKey] [setScrubIndexForKey]
) )
React.useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
setTab('info') setTab('info')
}, [open, key]) }, [open, key])

View File

@ -2,7 +2,7 @@
'use client' 'use client'
import * as React from 'react' import { type ComponentType, type ReactNode } from 'react'
function cn(...parts: Array<string | false | null | undefined>) { function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ') return parts.filter(Boolean).join(' ')
@ -59,7 +59,7 @@ function BaseIcon({
children, children,
className, className,
}: { }: {
children: React.ReactNode children: ReactNode
className?: string className?: string
}) { }) {
return ( return (
@ -140,7 +140,7 @@ export default function ModelGenderIcon({
if (!kind) return null if (!kind) return null
const map: Record<Exclude<GenderKind, null>, { label: string; Icon: React.ComponentType<IconProps> }> = { const map: Record<Exclude<GenderKind, null>, { label: string; Icon: ComponentType<IconProps> }> = {
female: { label: 'Female', Icon: FemaleIcon }, female: { label: 'Female', Icon: FemaleIcon },
male: { label: 'Male', Icon: MaleIcon }, male: { label: 'Male', Icon: MaleIcon },
trans: { label: 'Trans', Icon: TransIcon }, trans: { label: 'Trans', Icon: TransIcon },

View File

@ -10,6 +10,7 @@ import {
SpeakerWaveIcon, SpeakerWaveIcon,
} from '@heroicons/react/24/outline' } from '@heroicons/react/24/outline'
import LoadingSpinner from './LoadingSpinner' import LoadingSpinner from './LoadingSpinner'
import { apiUrl, apiFetch } from '../../lib/api'
type Props = { type Props = {
jobId: string jobId: string
@ -53,7 +54,12 @@ export default function ModelPreview({
const rootRef = useRef<HTMLDivElement | null>(null) const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false) const [inView, setInView] = useState(false)
const [imgError, setImgError] = useState(false) const [imgError, setImgError] = useState(false)
const [imgSrc, setImgSrc] = useState('') const fadeTimerRef = useRef<number | null>(null)
const [displaySrc, setDisplaySrc] = useState('')
const [pendingDisplaySrc, setPendingDisplaySrc] = useState('')
const [incomingSrc, setIncomingSrc] = useState('')
const [showIncoming, setShowIncoming] = useState(false)
const [liveSrc, setLiveSrc] = useState('') const [liveSrc, setLiveSrc] = useState('')
const [hoverOpen, setHoverOpen] = useState(false) const [hoverOpen, setHoverOpen] = useState(false)
@ -121,6 +127,12 @@ export default function ModelPreview({
: `${base}?v=${encodeURIComponent(version)}` : `${base}?v=${encodeURIComponent(version)}`
}, [fallbackSrc, jobId, refreshKey, localRefreshNonce]) }, [fallbackSrc, jobId, refreshKey, localRefreshNonce])
useEffect(() => {
return () => {
if (fadeTimerRef.current) window.clearTimeout(fadeTimerRef.current)
}
}, [])
useEffect(() => { useEffect(() => {
if (!inView) return if (!inView) return
@ -148,26 +160,31 @@ export default function ModelPreview({
const cookieHeader = buildChaturbateCookieHeader() const cookieHeader = buildChaturbateCookieHeader()
try { try {
const prepareRes = await fetch( const prepareUrl = apiUrl(
`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&prepare=1`, `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&prepare=1`
{
method: 'GET',
cache: 'no-store',
signal: ac.signal,
headers: cookieHeader
? { 'X-Chaturbate-Cookie': cookieHeader }
: undefined,
}
) )
const prepareRes = await apiFetch(prepareUrl, {
method: 'GET',
cache: 'no-store',
signal: ac.signal,
headers: cookieHeader
? { 'X-Chaturbate-Cookie': cookieHeader }
: undefined,
})
if (ac.signal.aborted) return if (ac.signal.aborted) return
await prepareRes.json().catch(() => null) await prepareRes.json().catch(() => null)
if (ac.signal.aborted) return if (ac.signal.aborted) return
setLiveSrc(`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&ts=${Date.now()}`) setLiveSrc(
apiUrl(`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&ts=${Date.now()}`)
)
} catch { } catch {
if (ac.signal.aborted) return if (ac.signal.aborted) return
setLiveSrc(`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&ts=${Date.now()}`) setLiveSrc(
apiUrl(`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&ts=${Date.now()}`)
)
} finally { } finally {
if (!ac.signal.aborted) setLivePreparing(false) if (!ac.signal.aborted) setLivePreparing(false)
} }
@ -197,21 +214,113 @@ export default function ModelPreview({
}, []) }, [])
useEffect(() => { useEffect(() => {
if (fadeTimerRef.current) window.clearTimeout(fadeTimerRef.current)
setImgError(false) setImgError(false)
setUsingFallback(false) setUsingFallback(false)
setImgSrc(previewSrc) setPendingDisplaySrc('')
setDisplaySrc('')
setIncomingSrc('')
setShowIncoming(false)
setLiveSrc('') setLiveSrc('')
setLivePreparing(false) setLivePreparing(false)
setLiveReady(false) setLiveReady(false)
}, [jobId, previewSrc]) }, [jobId])
useEffect(() => { useEffect(() => {
if (!inView) return
if (!previewSrc) return
if (!displaySrc) {
setDisplaySrc(previewSrc)
return
}
if (previewSrc === displaySrc || previewSrc === incomingSrc) return
setIncomingSrc(previewSrc)
setShowIncoming(false)
}, [inView, previewSrc, displaySrc, incomingSrc])
useEffect(() => {
if (!inView) return
if (!usingFallback) return if (!usingFallback) return
if (!fallbackImgSrc) return if (!fallbackImgSrc) return
if (!displaySrc) return
if (fallbackImgSrc === displaySrc || fallbackImgSrc === incomingSrc) return
setIncomingSrc(fallbackImgSrc)
setShowIncoming(false)
}, [inView, usingFallback, fallbackImgSrc, displaySrc, incomingSrc])
const handleDisplayLoad = useCallback(() => {
setImgError(false) setImgError(false)
setImgSrc(fallbackImgSrc)
}, [usingFallback, fallbackImgSrc]) const current = String(displaySrc || '').trim()
const fallback = String(fallbackImgSrc || '').trim()
setUsingFallback(Boolean(fallback && current === fallback))
if (pendingDisplaySrc && current === pendingDisplaySrc) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setIncomingSrc('')
setShowIncoming(false)
setPendingDisplaySrc('')
})
})
}
}, [displaySrc, fallbackImgSrc, pendingDisplaySrc])
const handleDisplayError = useCallback(() => {
const current = String(displaySrc || '').trim()
const fallback = String(fallbackImgSrc || '').trim()
if (fallback && current !== fallback) {
setUsingFallback(true)
setImgError(false)
setDisplaySrc(fallback)
return
}
setImgError(true)
}, [displaySrc, fallbackImgSrc])
const handleIncomingLoad = useCallback(() => {
const next = String(incomingSrc || '').trim()
if (!next) return
if (fadeTimerRef.current) window.clearTimeout(fadeTimerRef.current)
requestAnimationFrame(() => {
setShowIncoming(true)
})
fadeTimerRef.current = window.setTimeout(() => {
setPendingDisplaySrc(next)
setDisplaySrc(next)
}, 320)
}, [incomingSrc])
const handleIncomingError = useCallback(() => {
const next = String(incomingSrc || '').trim()
const fallback = String(fallbackImgSrc || '').trim()
if (fallback && next !== fallback) {
setIncomingSrc(fallback)
setShowIncoming(false)
return
}
setIncomingSrc('')
setShowIncoming(false)
if (!displaySrc) {
setImgError(true)
}
}, [incomingSrc, fallbackImgSrc, displaySrc])
return ( return (
<HoverPopover <HoverPopover
@ -319,34 +428,48 @@ export default function ModelPreview({
> >
{inView ? ( {inView ? (
!imgError ? ( !imgError ? (
<img <div className="relative w-full h-full">
src={imgSrc || previewSrc} {displaySrc ? (
loading="lazy" <img
decoding="async" src={displaySrc}
alt="" loading="lazy"
referrerPolicy="no-referrer" decoding="async"
className={['block w-full h-full', objectFitCls, 'object-center', blurCls].filter(Boolean).join(' ')} alt=""
onLoad={() => { referrerPolicy="no-referrer"
setImgError(false) className={[
'block w-full h-full',
objectFitCls,
'object-center',
blurCls,
]
.filter(Boolean)
.join(' ')}
onLoad={handleDisplayLoad}
onError={handleDisplayError}
/>
) : null}
const current = String(imgSrc || '').trim() {incomingSrc ? (
const fallback = String(fallbackImgSrc || '').trim() <img
setUsingFallback(Boolean(fallback && current === fallback)) src={incomingSrc}
}} loading="lazy"
onError={() => { decoding="async"
const current = String(imgSrc || '').trim() alt=""
const fallback = String(fallbackImgSrc || '').trim() referrerPolicy="no-referrer"
className={[
if (fallback && current !== fallback) { 'absolute inset-0 block w-full h-full transition-opacity duration-300 ease-in-out',
setUsingFallback(true) objectFitCls,
setImgError(false) 'object-center',
setImgSrc(fallback) blurCls,
return showIncoming ? 'opacity-100' : 'opacity-0',
} ]
.filter(Boolean)
setImgError(true) .join(' ')}
}} onLoad={handleIncomingLoad}
/> onError={handleIncomingError}
/>
) : null}
</div>
) : ( ) : (
<div className={['block w-full h-full bg-gray-100 dark:bg-white/5', blurCls].filter(Boolean).join(' ')} /> <div className={['block w-full h-full bg-gray-100 dark:bg-white/5', blurCls].filter(Boolean).join(' ')} />
) )

View File

@ -2,7 +2,16 @@
'use client' 'use client'
import * as React from 'react' import {
useEffect,
useRef,
useState,
useMemo,
useCallback,
useDeferredValue,
type ReactNode,
type MouseEvent as ReactMouseEvent,
} from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import Card from './Card' import Card from './Card'
import Button from './Button' import Button from './Button'
@ -197,8 +206,8 @@ function IconToggle({
title: string title: string
active?: boolean active?: boolean
hiddenUntilHover?: boolean hiddenUntilHover?: boolean
onClick: (e: React.MouseEvent) => void onClick: (e: ReactMouseEvent<HTMLButtonElement>) => void
icon: React.ReactNode icon: ReactNode
}) { }) {
return ( return (
<span <span
@ -286,32 +295,32 @@ type ModelsTabProps = {
} }
export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) { export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
const [models, setModels] = React.useState<StoredModel[]>([]) const [models, setModels] = useState<StoredModel[]>([])
const flagsInFlightRef = React.useRef<Record<string, true>>({}) const flagsInFlightRef = useRef<Record<string, true>>({})
const [loading, setLoading] = React.useState(false) const [loading, setLoading] = useState(false)
const [err, setErr] = React.useState<string | null>(null) const [err, setErr] = useState<string | null>(null)
const [q, setQ] = React.useState('') const [q, setQ] = useState('')
const [page, setPage] = React.useState(1) const [page, setPage] = useState(1)
const pageSize = 10 const pageSize = 10
const [viewMode, setViewMode] = React.useState<ModelsViewMode>('gallery') const [viewMode, setViewMode] = useState<ModelsViewMode>('gallery')
const [tagFilter, setTagFilter] = React.useState<string[]>([]) const [tagFilter, setTagFilter] = useState<string[]>([])
type WatchFilterMode = 'all' | 'watched' | 'unwatched' type WatchFilterMode = 'all' | 'watched' | 'unwatched'
type StatusFilterKey = 'favorite' | 'liked' type StatusFilterKey = 'favorite' | 'liked'
const [watchFilter, setWatchFilter] = React.useState<WatchFilterMode>('all') const [watchFilter, setWatchFilter] = useState<WatchFilterMode>('all')
const [statusFilter, setStatusFilter] = React.useState<StatusFilterKey[]>([]) const [statusFilter, setStatusFilter] = useState<StatusFilterKey[]>([])
const activeStatusSet = React.useMemo( const activeStatusSet = useMemo(
() => new Set(statusFilter), () => new Set(statusFilter),
[statusFilter] [statusFilter]
) )
const toggleStatusFilter = React.useCallback((key: StatusFilterKey) => { const toggleStatusFilter = useCallback((key: StatusFilterKey) => {
setStatusFilter((prev) => setStatusFilter((prev) =>
prev.includes(key) prev.includes(key)
? prev.filter((x) => x !== key) ? prev.filter((x) => x !== key)
@ -319,20 +328,20 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
) )
}, []) }, [])
const clearStatusFilter = React.useCallback(() => { const clearStatusFilter = useCallback(() => {
setWatchFilter('all') setWatchFilter('all')
setStatusFilter([]) setStatusFilter([])
}, []) }, [])
const [videoCounts, setVideoCounts] = React.useState<Record<string, number>>({}) const [videoCounts, setVideoCounts] = useState<Record<string, number>>({})
const [videoCountsLoading, setVideoCountsLoading] = React.useState(false) const [videoCountsLoading, setVideoCountsLoading] = useState(false)
const [sort, setSort] = React.useState<{ key: string; direction: 'asc' | 'desc' } | null>({ const [sort, setSort] = useState<{ key: string; direction: 'asc' | 'desc' } | null>({
key: 'createdAt', key: 'createdAt',
direction: 'desc', direction: 'desc',
}) })
const refreshVideoCounts = React.useCallback(async () => { const refreshVideoCounts = useCallback(async () => {
setVideoCountsLoading(true) setVideoCountsLoading(true)
try { try {
const res = await fetch('/api/record/done?all=1&includeKeep=1', { cache: 'no-store' as any }) const res = await fetch('/api/record/done?all=1&includeKeep=1', { cache: 'no-store' as any })
@ -361,9 +370,9 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
} }
}, []) }, [])
const activeTagSet = React.useMemo(() => new Set(tagFilter.map(lowerTag)), [tagFilter]) const activeTagSet = useMemo(() => new Set(tagFilter.map(lowerTag)), [tagFilter])
const allKnownTags = React.useMemo(() => { const allKnownTags = useMemo(() => {
const seen = new Set<string>() const seen = new Set<string>()
const out: string[] = [] const out: string[] = []
@ -380,7 +389,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
return out return out
}, [models]) }, [models])
const toggleTagFilter = React.useCallback((tag: string) => { const toggleTagFilter = useCallback((tag: string) => {
const k = tag.toLowerCase() const k = tag.toLowerCase()
setTagFilter((prev) => { setTagFilter((prev) => {
const has = prev.some((t) => t.toLowerCase() === k) const has = prev.some((t) => t.toLowerCase() === k)
@ -388,9 +397,9 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
}) })
}, []) }, [])
const clearTagFilter = React.useCallback(() => setTagFilter([]), []) const clearTagFilter = useCallback(() => setTagFilter([]), [])
React.useEffect(() => { useEffect(() => {
const onSet = (ev: Event) => { const onSet = (ev: Event) => {
const e = ev as CustomEvent<{ tags?: string[] }> const e = ev as CustomEvent<{ tags?: string[] }>
const tags = Array.isArray(e.detail?.tags) ? e.detail.tags : [] const tags = Array.isArray(e.detail?.tags) ? e.detail.tags : []
@ -401,7 +410,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
return () => window.removeEventListener('models:set-tag-filter', onSet as any) return () => window.removeEventListener('models:set-tag-filter', onSet as any)
}, []) }, [])
React.useEffect(() => { useEffect(() => {
try { try {
const raw = localStorage.getItem('models_pendingTags') const raw = localStorage.getItem('models_pendingTags')
if (!raw) return if (!raw) return
@ -413,7 +422,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
} }
}, []) }, [])
React.useEffect(() => { useEffect(() => {
try { try {
const saved = localStorage.getItem('models_viewMode') const saved = localStorage.getItem('models_viewMode')
if (saved === 'table' || saved === 'gallery') setViewMode(saved) if (saved === 'table' || saved === 'gallery') setViewMode(saved)
@ -422,7 +431,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
} }
}, []) }, [])
React.useEffect(() => { useEffect(() => {
try { try {
localStorage.setItem('models_viewMode', viewMode) localStorage.setItem('models_viewMode', viewMode)
} catch { } catch {
@ -430,18 +439,18 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
} }
}, [viewMode]) }, [viewMode])
const [input, setInput] = React.useState('') const [input, setInput] = useState('')
const [parsed, setParsed] = React.useState<ParsedModel | null>(null) const [parsed, setParsed] = useState<ParsedModel | null>(null)
const [parseError, setParseError] = React.useState<string | null>(null) const [parseError, setParseError] = useState<string | null>(null)
const [adding, setAdding] = React.useState(false) const [adding, setAdding] = useState(false)
const [importOpen, setImportOpen] = React.useState(false) const [importOpen, setImportOpen] = useState(false)
const [importFile, setImportFile] = React.useState<File | null>(null) const [importFile, setImportFile] = useState<File | null>(null)
const [importMsg, setImportMsg] = React.useState<string | null>(null) const [importMsg, setImportMsg] = useState<string | null>(null)
const [importKind, setImportKind] = React.useState<ImportKind>('favorite') const [importKind, setImportKind] = useState<ImportKind>('favorite')
const [importing, setImporting] = React.useState(false) const [importing, setImporting] = useState(false)
const [importErr, setImportErr] = React.useState<string | null>(null) const [importErr, setImportErr] = useState<string | null>(null)
async function doImport() { async function doImport() {
if (!importFile) return if (!importFile) return
@ -484,7 +493,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
setImportOpen(true) setImportOpen(true)
} }
const refresh = React.useCallback(async () => { const refresh = useCallback(async () => {
setLoading(true) setLoading(true)
setErr(null) setErr(null)
try { try {
@ -509,15 +518,15 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
} }
}, [refreshVideoCounts]) }, [refreshVideoCounts])
React.useEffect(() => { useEffect(() => {
void refreshVideoCounts() void refreshVideoCounts()
}, [refreshVideoCounts]) }, [refreshVideoCounts])
React.useEffect(() => { useEffect(() => {
void refresh() void refresh()
}, [refresh]) }, [refresh])
React.useEffect(() => { useEffect(() => {
const onChanged = (ev: Event) => { const onChanged = (ev: Event) => {
const e = ev as CustomEvent<any> const e = ev as CustomEvent<any>
const detail = e?.detail ?? {} const detail = e?.detail ?? {}
@ -547,7 +556,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
return () => window.removeEventListener('models-changed', onChanged as any) return () => window.removeEventListener('models-changed', onChanged as any)
}, [refresh]) }, [refresh])
React.useEffect(() => { useEffect(() => {
const onDbChanged = () => { const onDbChanged = () => {
setPage(1) setPage(1)
setModels([]) setModels([])
@ -558,7 +567,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
return () => window.removeEventListener('models-db-changed', onDbChanged as any) return () => window.removeEventListener('models-db-changed', onDbChanged as any)
}, [refresh]) }, [refresh])
React.useEffect(() => { useEffect(() => {
const raw = input.trim() const raw = input.trim()
if (!raw) { if (!raw) {
setParsed(null) setParsed(null)
@ -598,14 +607,14 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
return () => window.clearTimeout(t) return () => window.clearTimeout(t)
}, [input]) }, [input])
const modelsWithHay = React.useMemo(() => { const modelsWithHay = useMemo(() => {
return models.map((m) => ({ return models.map((m) => ({
m, m,
hay: `${m.modelKey} ${m.host ?? ''} ${m.input ?? ''} ${m.tags ?? ''}`.toLowerCase(), hay: `${m.modelKey} ${m.host ?? ''} ${m.input ?? ''} ${m.tags ?? ''}`.toLowerCase(),
})) }))
}, [models]) }, [models])
const deferredQ = React.useDeferredValue(q) const deferredQ = useDeferredValue(q)
async function patch(id: string, body: any) { async function patch(id: string, body: any) {
setErr(null) setErr(null)
@ -674,7 +683,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
} }
} }
const columns = React.useMemo<Column<StoredModel>[]>(() => { const columns = useMemo<Column<StoredModel>[]>(() => {
return [ return [
{ {
key: 'statusAll', key: 'statusAll',
@ -936,7 +945,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
] ]
}, [activeTagSet, toggleTagFilter, videoCounts, videoCountsLoading]) }, [activeTagSet, toggleTagFilter, videoCounts, videoCountsLoading])
const filtered = React.useMemo(() => { const filtered = useMemo(() => {
const needle = deferredQ.trim().toLowerCase() const needle = deferredQ.trim().toLowerCase()
let base = !needle let base = !needle
@ -976,7 +985,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
}) })
}, [models, modelsWithHay, deferredQ, watchFilter, activeStatusSet, activeTagSet]) }, [models, modelsWithHay, deferredQ, watchFilter, activeStatusSet, activeTagSet])
const sortedAll = React.useMemo(() => { const sortedAll = useMemo(() => {
if (!sort) return filtered if (!sort) return filtered
const col = columns.find((c) => c.key === sort.key) const col = columns.find((c) => c.key === sort.key)
@ -1010,18 +1019,18 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
return decorated.map((d) => d.r) return decorated.map((d) => d.r)
}, [filtered, sort, columns]) }, [filtered, sort, columns])
React.useEffect(() => { useEffect(() => {
setPage(1) setPage(1)
}, [q, tagFilter, watchFilter, statusFilter, viewMode, sort]) }, [q, tagFilter, watchFilter, statusFilter, viewMode, sort])
const totalItems = sortedAll.length const totalItems = sortedAll.length
const totalPages = React.useMemo(() => Math.max(1, Math.ceil(totalItems / pageSize)), [totalItems, pageSize]) const totalPages = useMemo(() => Math.max(1, Math.ceil(totalItems / pageSize)), [totalItems, pageSize])
React.useEffect(() => { useEffect(() => {
if (page > totalPages) setPage(totalPages) if (page > totalPages) setPage(totalPages)
}, [page, totalPages]) }, [page, totalPages])
const pageRows = React.useMemo(() => { const pageRows = useMemo(() => {
const start = (page - 1) * pageSize const start = (page - 1) * pageSize
return sortedAll.slice(start, start + pageSize) return sortedAll.slice(start, start + pageSize)
}, [sortedAll, page, pageSize]) }, [sortedAll, page, pageSize])

View File

@ -2,7 +2,7 @@
'use client' 'use client'
import * as React from 'react' import { type ReactNode } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { import {
ChevronLeftIcon, ChevronLeftIcon,
@ -42,7 +42,7 @@ function PageButton({
disabled?: boolean disabled?: boolean
rounded?: 'l' | 'r' | 'none' rounded?: 'l' | 'r' | 'none'
onClick?: () => void onClick?: () => void
children: React.ReactNode children: ReactNode
title?: string title?: string
}) { }) {
const roundedCls = const roundedCls =

View File

@ -1,6 +1,6 @@
// frontend\src\components\ui\PerformanceMonitor.tsx // frontend\src\components\ui\PerformanceMonitor.tsx
import React from 'react' import { useState, useEffect, useRef } from 'react'
import { subscribeSSE } from '../../lib/sseSingleton' import { subscribeSSE } from '../../lib/sseSingleton'
type Props = { type Props = {
@ -64,9 +64,9 @@ function formatBytesCompact(bytes: number | null) {
} }
function useFps(sampleMs = 1000) { function useFps(sampleMs = 1000) {
const [fps, setFps] = React.useState<number | null>(null) const [fps, setFps] = useState<number | null>(null)
React.useEffect(() => { useEffect(() => {
let raf = 0 let raf = 0
let last = performance.now() let last = performance.now()
let frames = 0 let frames = 0
@ -129,18 +129,18 @@ export default function PerformanceMonitor({
}: Props) { }: Props) {
const fps = useFps(1000) const fps = useFps(1000)
const [ping, setPing] = React.useState<number | null>(null) const [ping, setPing] = useState<number | null>(null)
const [cpu, setCpu] = React.useState<number | null>(null) const [cpu, setCpu] = useState<number | null>(null)
const [diskFreeBytes, setDiskFreeBytes] = React.useState<number | null>(null) const [diskFreeBytes, setDiskFreeBytes] = useState<number | null>(null)
const [diskTotalBytes, setDiskTotalBytes] = React.useState<number | null>(null) const [diskTotalBytes, setDiskTotalBytes] = useState<number | null>(null)
const [diskUsedPercent, setDiskUsedPercent] = React.useState<number | null>(null) const [diskUsedPercent, setDiskUsedPercent] = useState<number | null>(null)
const LOW_FREE_BYTES = 5 * 1024 * 1024 * 1024 // 5 GB const LOW_FREE_BYTES = 5 * 1024 * 1024 * 1024 // 5 GB
const RESET_BYTES = 8 * 1024 * 1024 * 1024 // Hysterese (8 GB) const RESET_BYTES = 8 * 1024 * 1024 * 1024 // Hysterese (8 GB)
const emergencyRef = React.useRef(false) const emergencyRef = useRef(false)
React.useEffect(() => { useEffect(() => {
const url = `/api/perf/stream?ms=${encodeURIComponent(String(pollMs))}` const url = `/api/perf/stream?ms=${encodeURIComponent(String(pollMs))}`
const unsub = subscribeSSE<any>(url, 'perf', (data) => { const unsub = subscribeSSE<any>(url, 'perf', (data) => {

View File

@ -1,7 +1,15 @@
// frontend\src\components\ui\Player.tsx // frontend\src\components\ui\Player.tsx
'use client' 'use client'
import * as React from 'react' import {
useEffect,
useRef,
useState,
useMemo,
useCallback,
useLayoutEffect,
type PointerEvent as ReactPointerEvent,
} from 'react'
import type { RecordJob } from '../../types' import type { RecordJob } from '../../types'
import Card from './Card' import Card from './Card'
import videojs from 'video.js' import videojs from 'video.js'
@ -167,9 +175,9 @@ function cn(...parts: Array<string | false | null | undefined>) {
} }
function useMediaQuery(query: string) { function useMediaQuery(query: string) {
const [matches, setMatches] = React.useState(false) const [matches, setMatches] = useState(false)
React.useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
const mql = window.matchMedia(query) const mql = window.matchMedia(query)
const onChange = () => setMatches(mql.matches) const onChange = () => setMatches(mql.matches)
@ -298,17 +306,17 @@ export default function Player({
startMuted = DEFAULT_PLAYER_START_MUTED, startMuted = DEFAULT_PLAYER_START_MUTED,
startAtSec = 0 startAtSec = 0
}: PlayerProps) { }: PlayerProps) {
const title = React.useMemo( const title = useMemo(
() => baseName(job.output?.trim() || '') || job.id, () => baseName(job.output?.trim() || '') || job.id,
[job.output, job.id] [job.output, job.id]
) )
const fileRaw = React.useMemo(() => baseName(job.output?.trim() || ''), [job.output]) const fileRaw = useMemo(() => baseName(job.output?.trim() || ''), [job.output])
const playName = React.useMemo(() => baseName(job.output?.trim() || ''), [job.output]) const playName = useMemo(() => baseName(job.output?.trim() || ''), [job.output])
const sizeLabel = React.useMemo(() => formatBytes(sizeBytesOf(job)), [job]) const sizeLabel = useMemo(() => formatBytes(sizeBytesOf(job)), [job])
const anyJob = job as any const anyJob = job as any
const [fullDurationSec, setFullDurationSec] = React.useState<number>(() => { const [fullDurationSec, setFullDurationSec] = useState<number>(() => {
return ( return (
Number((job as any)?.meta?.durationSeconds) || Number((job as any)?.meta?.durationSeconds) ||
Number((job as any)?.durationSeconds) || Number((job as any)?.durationSeconds) ||
@ -316,12 +324,12 @@ export default function Player({
) )
}) })
const [metaReady, setMetaReady] = React.useState<boolean>(() => { const [metaReady, setMetaReady] = useState<boolean>(() => {
// live ist egal, finished: erst mal false (wir holen gleich) // live ist egal, finished: erst mal false (wir holen gleich)
return job.status === 'running' return job.status === 'running'
}) })
const [metaDims, setMetaDims] = React.useState<{ h: number; fps: number | null }>(() => ({ const [metaDims, setMetaDims] = useState<{ h: number; fps: number | null }>(() => ({
h: 0, h: 0,
fps: null, fps: null,
})) }))
@ -329,23 +337,23 @@ export default function Player({
// ✅ Live nur, wenn es wirklich Preview/HLS-Assets gibt (nicht nur status==="running") // ✅ Live nur, wenn es wirklich Preview/HLS-Assets gibt (nicht nur status==="running")
const isLive = job.status === 'running' const isLive = job.status === 'running'
const [liveMuted, setLiveMuted] = React.useState(startMuted) const [liveMuted, setLiveMuted] = useState(startMuted)
const [liveVolume, setLiveVolume] = React.useState(startMuted ? 0 : 1) const [liveVolume, setLiveVolume] = useState(startMuted ? 0 : 1)
const [livePreparing, setLivePreparing] = React.useState(false) const [livePreparing, setLivePreparing] = useState(false)
const [livePreparedSrc, setLivePreparedSrc] = React.useState('') const [livePreparedSrc, setLivePreparedSrc] = useState('')
const [liveReady, setLiveReady] = React.useState(false) const [liveReady, setLiveReady] = useState(false)
// ✅ Backend erwartet "id=" (nicht "name=") // ✅ Backend erwartet "id=" (nicht "name=")
// running: echte job.id (jobs-map lookup) // running: echte job.id (jobs-map lookup)
// finished: Dateiname ohne Extension als Stem (wenn dein Backend finished so mapped) // finished: Dateiname ohne Extension als Stem (wenn dein Backend finished so mapped)
const finishedStem = React.useMemo(() => (playName || '').replace(/\.[^.]+$/, ''), [playName]) const finishedStem = useMemo(() => (playName || '').replace(/\.[^.]+$/, ''), [playName])
const previewId = React.useMemo( const previewId = useMemo(
() => (isLive ? job.id : finishedStem || job.id), () => (isLive ? job.id : finishedStem || job.id),
[isLive, job.id, finishedStem] [isLive, job.id, finishedStem]
) )
React.useEffect(() => { useEffect(() => {
if (isLive) return if (isLive) return
if (fullDurationSec > 0) return if (fullDurationSec > 0) return
@ -388,19 +396,19 @@ export default function Player({
}, [isLive, fullDurationSec, job.output]) }, [isLive, fullDurationSec, job.output])
const isHotFile = fileRaw.startsWith('HOT ') const isHotFile = fileRaw.startsWith('HOT ')
const model = React.useMemo(() => { const model = useMemo(() => {
const k = (modelKey || '').trim() const k = (modelKey || '').trim()
return k ? k : modelNameFromOutput(job.output) return k ? k : modelNameFromOutput(job.output)
}, [modelKey, job.output]) }, [modelKey, job.output])
const file = React.useMemo(() => stripHotPrefix(fileRaw), [fileRaw]) const file = useMemo(() => stripHotPrefix(fileRaw), [fileRaw])
const runtimeLabel = React.useMemo(() => { const runtimeLabel = useMemo(() => {
const sec = Number(fullDurationSec || 0) || 0 const sec = Number(fullDurationSec || 0) || 0
return sec > 0 ? formatDuration(sec * 1000) : '—' return sec > 0 ? formatDuration(sec * 1000) : '—'
}, [fullDurationSec]) }, [fullDurationSec])
// Datum: bevorzugt aus Dateiname, sonst startedAt/endedAt/createdAt — ✅ inkl. Uhrzeit // Datum: bevorzugt aus Dateiname, sonst startedAt/endedAt/createdAt — ✅ inkl. Uhrzeit
const dateLabel = React.useMemo(() => { const dateLabel = useMemo(() => {
const fromName = parseDateFromOutput(job.output) const fromName = parseDateFromOutput(job.output)
if (fromName) return formatDateTime(fromName) if (fromName) return formatDateTime(fromName)
@ -417,28 +425,28 @@ export default function Player({
}, [job.output, anyJob.startedAt, anyJob.endedAt, anyJob.createdAt, anyJob.fileCreatedAt, anyJob.ctime]) }, [job.output, anyJob.startedAt, anyJob.endedAt, anyJob.createdAt, anyJob.fileCreatedAt, anyJob.ctime])
// ✅ Tags wie in FinishedDownloads: aus ModelStore (modelsByKey) // ✅ Tags wie in FinishedDownloads: aus ModelStore (modelsByKey)
const effectiveModelKey = React.useMemo(() => lower((modelKey || model || '').trim()), [modelKey, model]) const effectiveModelKey = useMemo(() => lower((modelKey || model || '').trim()), [modelKey, model])
const tags = React.useMemo(() => { const tags = useMemo(() => {
const flags = modelsByKey?.[effectiveModelKey] const flags = modelsByKey?.[effectiveModelKey]
return parseTags(flags?.tags) return parseTags(flags?.tags)
}, [modelsByKey, effectiveModelKey]) }, [modelsByKey, effectiveModelKey])
// Vorschaubild oben // Vorschaubild oben
const previewA = React.useMemo( const previewA = useMemo(
() => apiUrl(`/api/preview?id=${encodeURIComponent(previewId)}&file=preview.jpg`), () => apiUrl(`/api/preview?id=${encodeURIComponent(previewId)}&file=preview.jpg`),
[previewId] [previewId]
) )
// ✅ Live-Stream URL (Playback) -> play=1 hält Preview sicher am Leben // ✅ Live-Stream URL (Playback) -> play=1 hält Preview sicher am Leben
const liveHlsSrc = React.useMemo( const liveHlsSrc = useMemo(
() => apiUrl(`/api/preview/live?id=${encodeURIComponent(previewId)}&play=1`), () => apiUrl(`/api/preview/live?id=${encodeURIComponent(previewId)}&play=1`),
[previewId] [previewId]
) )
const [previewSrc, setPreviewSrc] = React.useState(previewA) const [previewSrc, setPreviewSrc] = useState(previewA)
React.useEffect(() => { useEffect(() => {
if (!isLive) { if (!isLive) {
setLivePreparing(false) setLivePreparing(false)
setLivePreparedSrc('') setLivePreparedSrc('')
@ -497,28 +505,28 @@ export default function Player({
} }
}, [isLive, previewId, liveHlsSrc]) }, [isLive, previewId, liveHlsSrc])
React.useEffect(() => { useEffect(() => {
setPreviewSrc(previewA) setPreviewSrc(previewA)
}, [previewA]) }, [previewA])
const fps = React.useMemo( const fps = useMemo(
() => pickNum(metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate), () => pickNum(metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate),
[metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate] [metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate]
) )
const [intrSize, setIntrSize] = React.useState<{ w: number; h: number } | null>(null) const [intrSize, setIntrSize] = useState<{ w: number; h: number } | null>(null)
const videoW = React.useMemo( const videoW = useMemo(
() => pickNum(anyJob.videoWidth, anyJob.width, anyJob.meta?.width), () => pickNum(anyJob.videoWidth, anyJob.width, anyJob.meta?.width),
[anyJob.videoWidth, anyJob.width, anyJob.meta?.width] [anyJob.videoWidth, anyJob.width, anyJob.meta?.width]
) )
const videoH = React.useMemo( const videoH = useMemo(
() => pickNum(metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height), () => pickNum(metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height),
[metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height] [metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height]
) )
const resolutionLabel = React.useMemo( const resolutionLabel = useMemo(
() => () =>
formatResolution( formatResolution(
intrSize ?? intrSize ??
@ -526,15 +534,15 @@ export default function Player({
), ),
[intrSize, videoW, videoH] [intrSize, videoW, videoH]
) )
const fpsLabel = React.useMemo(() => formatFps(fps), [fps]) const fpsLabel = useMemo(() => formatFps(fps), [fps])
React.useEffect(() => { useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && onClose() const onKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && onClose()
window.addEventListener('keydown', onKeyDown) window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown)
}, [onClose]) }, [onClose])
const buildVideoSrc = React.useCallback( const buildVideoSrc = useCallback(
(params: { file?: string; id?: string }) => { (params: { file?: string; id?: string }) => {
const qp = new URLSearchParams() const qp = new URLSearchParams()
if (params.file) qp.set('file', params.file) if (params.file) qp.set('file', params.file)
@ -544,7 +552,7 @@ export default function Player({
[] []
) )
const media = React.useMemo(() => { const media = useMemo(() => {
// ✅ Live wird NICHT mehr über Video.js gespielt // ✅ Live wird NICHT mehr über Video.js gespielt
if (isLive) return { src: '', type: '' } if (isLive) return { src: '', type: '' }
@ -563,13 +571,13 @@ export default function Player({
return { src: buildVideoSrc({ id: job.id }), type: 'video/mp4' } return { src: buildVideoSrc({ id: job.id }), type: 'video/mp4' }
}, [isLive, metaReady, job.output, job.id, buildVideoSrc]) }, [isLive, metaReady, job.output, job.id, buildVideoSrc])
const containerRef = React.useRef<HTMLDivElement | null>(null) const containerRef = useRef<HTMLDivElement | null>(null)
const playerRef = React.useRef<VideoJsPlayer | null>(null) const playerRef = useRef<VideoJsPlayer | null>(null)
const videoNodeRef = React.useRef<HTMLVideoElement | null>(null) const videoNodeRef = useRef<HTMLVideoElement | null>(null)
const [mounted, setMounted] = React.useState(false) const [mounted, setMounted] = useState(false)
const updateIntrinsicDims = React.useCallback(() => { const updateIntrinsicDims = useCallback(() => {
const p: any = playerRef.current const p: any = playerRef.current
if (!p || p.isDisposed?.()) return if (!p || p.isDisposed?.()) return
@ -584,7 +592,7 @@ export default function Player({
} }
}, []) }, [])
const captureGhostFrame = React.useCallback(() => { const captureGhostFrame = useCallback(() => {
try { try {
// Bevorzugt das echte HTMLVideoElement von Video.js // Bevorzugt das echte HTMLVideoElement von Video.js
const v = const v =
@ -628,19 +636,19 @@ export default function Player({
} }
}, []) }, [])
const playbackKey = React.useMemo(() => { const playbackKey = useMemo(() => {
return baseName(job.output?.trim() || '') || job.id return baseName(job.output?.trim() || '') || job.id
}, [job.output, job.id]) }, [job.output, job.id])
const normalizedStartAtSec = React.useMemo(() => { const normalizedStartAtSec = useMemo(() => {
const n = Number(startAtSec) const n = Number(startAtSec)
return Number.isFinite(n) && n >= 0 ? n : 0 return Number.isFinite(n) && n >= 0 ? n : 0
}, [startAtSec]) }, [startAtSec])
// Merkt sich, für welchen "Open-Zustand" wir den initialen Seek schon angewendet haben // Merkt sich, für welchen "Open-Zustand" wir den initialen Seek schon angewendet haben
const appliedStartSeekRef = React.useRef<string>('') const appliedStartSeekRef = useRef<string>('')
React.useEffect(() => { useEffect(() => {
if (isLive) { if (isLive) {
setMetaReady(true) setMetaReady(true)
return return
@ -711,9 +719,9 @@ export default function Player({
}, [isLive, playbackKey, job.output]) }, [isLive, playbackKey, job.output])
// ✅ iOS Safari: visualViewport changes (address bar / bottom bar / keyboard) need a rerender // ✅ iOS Safari: visualViewport changes (address bar / bottom bar / keyboard) need a rerender
const [, setVvTick] = React.useState(0) const [, setVvTick] = useState(0)
React.useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
const vv = window.visualViewport const vv = window.visualViewport
if (!vv) return if (!vv) return
@ -734,8 +742,8 @@ export default function Player({
} }
}, []) }, [])
const [controlBarH, setControlBarH] = React.useState(30) const [controlBarH, setControlBarH] = useState(30)
const [portalTarget, setPortalTarget] = React.useState<HTMLElement | null>(null) const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null)
const mini = !expanded const mini = !expanded
@ -754,7 +762,7 @@ export default function Player({
const MIN_W = 320 const MIN_W = 320
const MIN_H = 200 const MIN_H = 200
React.useEffect(() => { useEffect(() => {
if (!mounted) return if (!mounted) return
const p = playerRef.current const p = playerRef.current
if (!p || (p as any).isDisposed?.()) return if (!p || (p as any).isDisposed?.()) return
@ -785,9 +793,9 @@ export default function Player({
} }
}, [mounted, expanded]) }, [mounted, expanded])
React.useEffect(() => setMounted(true), []) useEffect(() => setMounted(true), [])
React.useEffect(() => { useEffect(() => {
if (!usePortal) { if (!usePortal) {
setPortalTarget(null) setPortalTarget(null)
return return
@ -816,7 +824,7 @@ export default function Player({
setPortalTarget(el) setPortalTarget(el)
}, [isDesktop, usePortal]) }, [isDesktop, usePortal])
React.useEffect(() => { useEffect(() => {
const p: any = playerRef.current const p: any = playerRef.current
if (!p || p.isDisposed?.()) return if (!p || p.isDisposed?.()) return
if (isLive) return // live nutzt Video.js nicht if (isLive) return // live nutzt Video.js nicht
@ -852,7 +860,7 @@ export default function Player({
} }
}, [job.output, isLive]) }, [job.output, isLive])
React.useLayoutEffect(() => { useLayoutEffect(() => {
if (!mounted) return if (!mounted) return
if (!containerRef.current) return if (!containerRef.current) return
if (playerRef.current) return if (playerRef.current) return
@ -927,7 +935,7 @@ export default function Player({
} }
}, [mounted, startMuted, isLive, metaReady, updateIntrinsicDims]) }, [mounted, startMuted, isLive, metaReady, updateIntrinsicDims])
React.useEffect(() => { useEffect(() => {
const p = playerRef.current const p = playerRef.current
if (!p || (p as any).isDisposed?.()) return if (!p || (p as any).isDisposed?.()) return
@ -937,7 +945,7 @@ export default function Player({
el.classList.toggle('is-live-download', Boolean(isLive)) el.classList.toggle('is-live-download', Boolean(isLive))
}, [isLive]) }, [isLive])
const releaseMedia = React.useCallback(() => { const releaseMedia = useCallback(() => {
const p = playerRef.current const p = playerRef.current
if (!p || (p as any).isDisposed?.()) return if (!p || (p as any).isDisposed?.()) return
@ -952,7 +960,7 @@ export default function Player({
} catch {} } catch {}
}, []) }, [])
const seekPlayerToAbsolute = React.useCallback((absSec: number) => { const seekPlayerToAbsolute = useCallback((absSec: number) => {
const p: any = playerRef.current const p: any = playerRef.current
if (!p || p.isDisposed?.()) return if (!p || p.isDisposed?.()) return
@ -971,7 +979,7 @@ export default function Player({
} }
}, []) }, [])
React.useEffect(() => { useEffect(() => {
if (!mounted) return if (!mounted) return
if (!isLive && !metaReady) { if (!isLive && !metaReady) {
releaseMedia() releaseMedia()
@ -1056,7 +1064,7 @@ export default function Player({
tryPlay() tryPlay()
}, [mounted, isLive, metaReady, media.src, media.type, startMuted, updateIntrinsicDims, fullDurationSec, releaseMedia]) }, [mounted, isLive, metaReady, media.src, media.type, startMuted, updateIntrinsicDims, fullDurationSec, releaseMedia])
React.useEffect(() => { useEffect(() => {
if (!mounted) return if (!mounted) return
if (isLive) return // Live spielt nicht über Video.js if (isLive) return // Live spielt nicht über Video.js
if (!metaReady) return if (!metaReady) return
@ -1137,13 +1145,13 @@ export default function Player({
seekPlayerToAbsolute, seekPlayerToAbsolute,
]) ])
React.useEffect(() => { useEffect(() => {
const p = playerRef.current const p = playerRef.current
if (!p || (p as any).isDisposed?.()) return if (!p || (p as any).isDisposed?.()) return
queueMicrotask(() => p.trigger('resize')) queueMicrotask(() => p.trigger('resize'))
}, [expanded]) }, [expanded])
React.useEffect(() => { useEffect(() => {
const onRelease = (ev: Event) => { const onRelease = (ev: Event) => {
const detail = (ev as CustomEvent<{ file?: string }>).detail const detail = (ev as CustomEvent<{ file?: string }>).detail
const file = (detail?.file ?? '').trim() const file = (detail?.file ?? '').trim()
@ -1157,7 +1165,7 @@ export default function Player({
return () => window.removeEventListener('player:release', onRelease as EventListener) return () => window.removeEventListener('player:release', onRelease as EventListener)
}, [job.output, releaseMedia]) }, [job.output, releaseMedia])
React.useEffect(() => { useEffect(() => {
const onCloseIfFile = (ev: Event) => { const onCloseIfFile = (ev: Event) => {
const detail = (ev as CustomEvent<{ file?: string }>).detail const detail = (ev as CustomEvent<{ file?: string }>).detail
const file = (detail?.file ?? '').trim() const file = (detail?.file ?? '').trim()
@ -1197,15 +1205,15 @@ export default function Player({
return { w, h, ox: 0, oy: 0, bottomInset: 0 } return { w, h, ox: 0, oy: 0, bottomInset: 0 }
} }
const prevViewportRef = React.useRef<{ w: number; h: number } | null>(null) const prevViewportRef = useRef<{ w: number; h: number } | null>(null)
React.useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
const { w, h } = getViewport() const { w, h } = getViewport()
prevViewportRef.current = { w, h } prevViewportRef.current = { w, h }
}, []) }, [])
const getVideoAspectRatio = React.useCallback(() => { const getVideoAspectRatio = useCallback(() => {
// bevorzugt echtes Video (intrinsic), dann Meta, fallback 16:9 // bevorzugt echtes Video (intrinsic), dann Meta, fallback 16:9
const anyJob = job as any const anyJob = job as any
@ -1233,7 +1241,7 @@ export default function Player({
return 16 / 9 return 16 / 9
}, [job, intrSize]) }, [job, intrSize])
const clampMiniRect = React.useCallback( const clampMiniRect = useCallback(
(r: { x: number; y: number; w: number; h: number }) => { (r: { x: number; y: number; w: number; h: number }) => {
if (typeof window === 'undefined') return r if (typeof window === 'undefined') return r
@ -1276,7 +1284,7 @@ export default function Player({
[getVideoAspectRatio, isLive] [getVideoAspectRatio, isLive]
) )
const loadRect = React.useCallback(() => { const loadRect = useCallback(() => {
if (typeof window === 'undefined') return { x: MARGIN, y: MARGIN, w: DEFAULT_W, h: DEFAULT_H } if (typeof window === 'undefined') return { x: MARGIN, y: MARGIN, w: DEFAULT_W, h: DEFAULT_H }
try { try {
@ -1298,27 +1306,27 @@ export default function Player({
return clampMiniRect({ x, y, w, h }) return clampMiniRect({ x, y, w, h })
}, [clampMiniRect]) }, [clampMiniRect])
const [win, setWin] = React.useState<WinRect>(() => loadRect()) const [win, setWin] = useState<WinRect>(() => loadRect())
const isNarrowMini = miniDesktop && win.w < 380 const isNarrowMini = miniDesktop && win.w < 380
const saveRect = React.useCallback((r: WinRect) => { const saveRect = useCallback((r: WinRect) => {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
try { try {
window.localStorage.setItem(WIN_KEY, JSON.stringify(r)) window.localStorage.setItem(WIN_KEY, JSON.stringify(r))
} catch {} } catch {}
}, []) }, [])
const winRef = React.useRef(win) const winRef = useRef(win)
React.useEffect(() => { useEffect(() => {
winRef.current = win winRef.current = win
}, [win]) }, [win])
React.useEffect(() => { useEffect(() => {
if (!miniDesktop) return if (!miniDesktop) return
setWin(loadRect()) setWin(loadRect())
}, [miniDesktop, loadRect]) }, [miniDesktop, loadRect])
React.useEffect(() => { useEffect(() => {
if (!miniDesktop) return if (!miniDesktop) return
const onResize = () => { const onResize = () => {
@ -1372,8 +1380,8 @@ export default function Player({
}, [miniDesktop, clampMiniRect]) }, [miniDesktop, clampMiniRect])
// Video.js resize triggern, wenn sich Fenstergröße ändert // Video.js resize triggern, wenn sich Fenstergröße ändert
const vjsResizeRafRef = React.useRef<number | null>(null) const vjsResizeRafRef = useRef<number | null>(null)
React.useEffect(() => { useEffect(() => {
const p = playerRef.current const p = playerRef.current
if (!p || (p as any).isDisposed?.()) return if (!p || (p as any).isDisposed?.()) return
@ -1393,18 +1401,18 @@ export default function Player({
} }
}, [miniDesktop, win.w, win.h]) }, [miniDesktop, win.w, win.h])
const [isResizing, setIsResizing] = React.useState(false) const [isResizing, setIsResizing] = useState(false)
const [isDragging, setIsDragging] = React.useState(false) const [isDragging, setIsDragging] = useState(false)
const [snapPreviewRect, setSnapPreviewRect] = React.useState<WinRect | null>(null) const [snapPreviewRect, setSnapPreviewRect] = useState<WinRect | null>(null)
const [ghostFrameSrc, setGhostFrameSrc] = React.useState<string | null>(null) const [ghostFrameSrc, setGhostFrameSrc] = useState<string | null>(null)
const ghostFrameCanvasRef = React.useRef<HTMLCanvasElement | null>(null) const ghostFrameCanvasRef = useRef<HTMLCanvasElement | null>(null)
// pointermove sehr häufig -> 1x pro Frame committen // pointermove sehr häufig -> 1x pro Frame committen
const dragRafRef = React.useRef<number | null>(null) const dragRafRef = useRef<number | null>(null)
const pendingPosRef = React.useRef<{ x: number; y: number } | null>(null) const pendingPosRef = useRef<{ x: number; y: number } | null>(null)
const draggingRef = React.useRef<null | { sx: number; sy: number; start: WinRect }>(null) const draggingRef = useRef<null | { sx: number; sy: number; start: WinRect }>(null)
const applySnap = React.useCallback((r: WinRect): WinRect => { const applySnap = useCallback((r: WinRect): WinRect => {
const { w: vw, h: vh } = getViewport() const { w: vw, h: vh } = getViewport()
const leftEdge = MARGIN const leftEdge = MARGIN
const rightEdge = vw - r.w - MARGIN const rightEdge = vw - r.w - MARGIN
@ -1414,8 +1422,8 @@ export default function Player({
return { ...r, x: dockLeft ? leftEdge : rightEdge, y: bottomEdge } return { ...r, x: dockLeft ? leftEdge : rightEdge, y: bottomEdge }
}, []) }, [])
const onDragMove = React.useCallback( const onDragMove = useCallback(
(ev: PointerEvent) => { (ev: globalThis.PointerEvent) => {
const s = draggingRef.current const s = draggingRef.current
if (!s) return if (!s) return
@ -1442,7 +1450,7 @@ export default function Player({
[clampMiniRect, applySnap] [clampMiniRect, applySnap]
) )
const endDrag = React.useCallback(() => { const endDrag = useCallback(() => {
if (!draggingRef.current) return if (!draggingRef.current) return
setIsDragging(false) setIsDragging(false)
setSnapPreviewRect(null) setSnapPreviewRect(null)
@ -1463,8 +1471,8 @@ export default function Player({
}) })
}, [onDragMove, applySnap, clampMiniRect, saveRect]) }, [onDragMove, applySnap, clampMiniRect, saveRect])
const beginDrag = React.useCallback( const beginDrag = useCallback(
(e: React.PointerEvent) => { (e: ReactPointerEvent<HTMLElement>) => {
if (!miniDesktop) return if (!miniDesktop) return
if (isResizing) return if (isResizing) return
if (e.button !== 0) return if (e.button !== 0) return
@ -1490,12 +1498,12 @@ export default function Player({
) )
// pointermove kommt extrem oft -> wir committen max. 1x pro Frame // pointermove kommt extrem oft -> wir committen max. 1x pro Frame
const resizeRafRef = React.useRef<number | null>(null) const resizeRafRef = useRef<number | null>(null)
const pendingRectRef = React.useRef<WinRect | null>(null) const pendingRectRef = useRef<WinRect | null>(null)
const resizingRef = React.useRef<null | { dir: ResizeDir; sx: number; sy: number; start: WinRect; ratio: number }>(null) const resizingRef = useRef<null | { dir: ResizeDir; sx: number; sy: number; start: WinRect; ratio: number }>(null)
const onResizeMove = React.useCallback( const onResizeMove = useCallback(
(ev: PointerEvent) => { (ev: globalThis.PointerEvent) => {
const s = resizingRef.current const s = resizingRef.current
if (!s) return if (!s) return
@ -1590,7 +1598,7 @@ export default function Player({
[clampMiniRect] [clampMiniRect]
) )
const endResize = React.useCallback(() => { const endResize = useCallback(() => {
if (!resizingRef.current) return if (!resizingRef.current) return
setIsResizing(false) setIsResizing(false)
setSnapPreviewRect(null) setSnapPreviewRect(null)
@ -1607,8 +1615,8 @@ export default function Player({
saveRect(winRef.current) saveRect(winRef.current)
}, [onResizeMove, saveRect]) }, [onResizeMove, saveRect])
const beginResize = React.useCallback( const beginResize = useCallback(
(dir: ResizeDir) => (e: React.PointerEvent) => { (dir: ResizeDir) => (e: ReactPointerEvent<HTMLDivElement>) => {
if (!miniDesktop) return if (!miniDesktop) return
if (e.button !== 0) return if (e.button !== 0) return
e.preventDefault() e.preventDefault()
@ -1624,10 +1632,10 @@ export default function Player({
[miniDesktop, onResizeMove, endResize] [miniDesktop, onResizeMove, endResize]
) )
const [canHover, setCanHover] = React.useState(false) const [canHover, setCanHover] = useState(false)
const [chromeHover, setChromeHover] = React.useState(false) const [chromeHover, setChromeHover] = useState(false)
React.useEffect(() => { useEffect(() => {
const mq = window.matchMedia?.('(hover: hover) and (pointer: fine)') const mq = window.matchMedia?.('(hover: hover) and (pointer: fine)')
const update = () => setCanHover(Boolean(mq?.matches)) const update = () => setCanHover(Boolean(mq?.matches))
update() update()
@ -1636,9 +1644,9 @@ export default function Player({
}, []) }, [])
const dragUiActive = miniDesktop && (chromeHover || isDragging || isResizing) const dragUiActive = miniDesktop && (chromeHover || isDragging || isResizing)
const [stopPending, setStopPending] = React.useState(false) const [stopPending, setStopPending] = useState(false)
React.useEffect(() => { useEffect(() => {
if (job.status !== 'running') setStopPending(false) if (job.status !== 'running') setStopPending(false)
}, [job.id, job.status]) }, [job.id, job.status])

View File

@ -2,7 +2,7 @@
'use client' 'use client'
import * as React from 'react' import { useEffect, useRef, useCallback, type PointerEvent, type MouseEvent } from 'react'
type Props = { type Props = {
imageCount: number imageCount: number
@ -32,17 +32,17 @@ export default function PreviewScrubber({
className, className,
stepSeconds = 0, stepSeconds = 0,
}: Props) { }: Props) {
const rootRef = React.useRef<HTMLDivElement | null>(null) const rootRef = useRef<HTMLDivElement | null>(null)
const rafRef = React.useRef<number | null>(null) const rafRef = useRef<number | null>(null)
const pendingIndexRef = React.useRef<number | undefined>(undefined) const pendingIndexRef = useRef<number | undefined>(undefined)
const flushPending = React.useCallback(() => { const flushPending = useCallback(() => {
rafRef.current = null rafRef.current = null
onActiveIndexChange(pendingIndexRef.current) onActiveIndexChange(pendingIndexRef.current)
}, [onActiveIndexChange]) }, [onActiveIndexChange])
const setIndexThrottled = React.useCallback( const setIndexThrottled = useCallback(
(index: number | undefined) => { (index: number | undefined) => {
pendingIndexRef.current = index pendingIndexRef.current = index
if (rafRef.current != null) return if (rafRef.current != null) return
@ -51,7 +51,7 @@ export default function PreviewScrubber({
[flushPending] [flushPending]
) )
React.useEffect(() => { useEffect(() => {
return () => { return () => {
if (rafRef.current != null) { if (rafRef.current != null) {
window.cancelAnimationFrame(rafRef.current) window.cancelAnimationFrame(rafRef.current)
@ -60,7 +60,7 @@ export default function PreviewScrubber({
} }
}, []) }, [])
const indexFromClientX = React.useCallback( const indexFromClientX = useCallback(
(clientX: number) => { (clientX: number) => {
if (!rootRef.current || imageCount < 1) return undefined if (!rootRef.current || imageCount < 1) return undefined
@ -75,28 +75,28 @@ export default function PreviewScrubber({
[imageCount] [imageCount]
) )
const handlePointerMove = React.useCallback( const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => { (e: PointerEvent<HTMLDivElement>) => {
const idx = indexFromClientX(e.clientX) const idx = indexFromClientX(e.clientX)
setIndexThrottled(idx) setIndexThrottled(idx)
}, },
[indexFromClientX, setIndexThrottled] [indexFromClientX, setIndexThrottled]
) )
const handlePointerEnter = React.useCallback( const handlePointerEnter = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => { (e: PointerEvent<HTMLDivElement>) => {
const idx = indexFromClientX(e.clientX) const idx = indexFromClientX(e.clientX)
setIndexThrottled(idx) setIndexThrottled(idx)
}, },
[indexFromClientX, setIndexThrottled] [indexFromClientX, setIndexThrottled]
) )
const handlePointerLeave = React.useCallback(() => { const handlePointerLeave = useCallback(() => {
setIndexThrottled(undefined) setIndexThrottled(undefined)
}, [setIndexThrottled]) }, [setIndexThrottled])
const handlePointerDown = React.useCallback( const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => { (e: PointerEvent<HTMLDivElement>) => {
e.stopPropagation() e.stopPropagation()
const idx = indexFromClientX(e.clientX) const idx = indexFromClientX(e.clientX)
setIndexThrottled(idx) setIndexThrottled(idx)
@ -107,8 +107,8 @@ export default function PreviewScrubber({
[indexFromClientX, setIndexThrottled] [indexFromClientX, setIndexThrottled]
) )
const handleClick = React.useCallback( const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => { (e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation() e.stopPropagation()
if (!onIndexClick) return if (!onIndexClick) return
const idx = indexFromClientX(e.clientX) const idx = indexFromClientX(e.clientX)

View File

@ -1,13 +1,13 @@
'use client' 'use client'
import * as React from 'react' import { type ReactNode } from 'react'
type ProgressBarProps = { type ProgressBarProps = {
label?: React.ReactNode label?: ReactNode
value?: number | null // 0..100 value?: number | null // 0..100
indeterminate?: boolean // wenn true -> “läuft…” ohne Prozent indeterminate?: boolean // wenn true -> “läuft…” ohne Prozent
showPercent?: boolean // zeigt “xx%” (nur determinate) showPercent?: boolean // zeigt “xx%” (nur determinate)
rightLabel?: React.ReactNode // optionaler Text unter der Bar (z.B. 3/10) rightLabel?: ReactNode // optionaler Text unter der Bar (z.B. 3/10)
steps?: string[] // optional: Step-Labels steps?: string[] // optional: Step-Labels
currentStep?: number // 0-basiert currentStep?: number // 0-basiert
size?: 'sm' | 'md' size?: 'sm' | 'md'

View File

@ -2,7 +2,16 @@
'use client' 'use client'
import * as React from 'react' import {
useEffect,
useRef,
useState,
useMemo,
useLayoutEffect,
Fragment,
type ReactNode,
type MouseEvent as ReactMouseEvent,
} from 'react'
import type { RecordJob } from '../../types' import type { RecordJob } from '../../types'
import { import {
FireIcon as FireOutlineIcon, FireIcon as FireOutlineIcon,
@ -179,10 +188,10 @@ export default function RecordJobActions({
// Add: nur über order steuern (disabled wenn keine URL/Handler) // Add: nur über order steuern (disabled wenn keine URL/Handler)
const wantAdd = inOrder('add') const wantAdd = inOrder('add')
const [addState, setAddState] = React.useState<'idle' | 'busy' | 'ok'>('idle') const [addState, setAddState] = useState<'idle' | 'busy' | 'ok'>('idle')
const addTimerRef = React.useRef<number | null>(null) const addTimerRef = useRef<number | null>(null)
React.useEffect(() => { useEffect(() => {
return () => { return () => {
if (addTimerRef.current) window.clearTimeout(addTimerRef.current) if (addTimerRef.current) window.clearTimeout(addTimerRef.current)
} }
@ -226,10 +235,10 @@ export default function RecordJobActions({
} }
// ✅ Auto-Fit: verfügbare Breite + tatsächlicher gap (Tailwind gap-1/gap-2/…) // ✅ Auto-Fit: verfügbare Breite + tatsächlicher gap (Tailwind gap-1/gap-2/…)
const [rootW, setRootW] = React.useState(0) const [rootW, setRootW] = useState(0)
const [gapPx, setGapPx] = React.useState(4) const [gapPx, setGapPx] = useState(4)
const run = (fn?: ActionFn) => (e: React.MouseEvent) => { const run = (fn?: ActionFn) => (e: ReactMouseEvent<HTMLButtonElement>) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
if (busy || !fn) return if (busy || !fn) return
@ -452,7 +461,7 @@ export default function RecordJobActions({
</button> </button>
) : null ) : null
const byKey: Record<ActionKey, React.ReactNode> = { const byKey: Record<ActionKey, ReactNode> = {
details: DetailsBtn, details: DetailsBtn,
add: AddBtn, add: AddBtn,
split: SplitBtn, split: SplitBtn,
@ -479,7 +488,7 @@ export default function RecordJobActions({
const fallbackLimit = compact ? 2 : 3 const fallbackLimit = compact ? 2 : 3
const fitLimit = React.useMemo(() => { const fitLimit = useMemo(() => {
if (!autoFit) return (inlineCount ?? fallbackLimit) if (!autoFit) return (inlineCount ?? fallbackLimit)
// rootW kann initial 0 sein -> brauchbarer Fallback // rootW kann initial 0 sein -> brauchbarer Fallback
@ -498,10 +507,10 @@ export default function RecordJobActions({
const inlineKeys = collapse ? presentKeys.slice(0, fitLimit) : presentKeys const inlineKeys = collapse ? presentKeys.slice(0, fitLimit) : presentKeys
const menuKeys = collapse ? presentKeys.slice(fitLimit) : [] const menuKeys = collapse ? presentKeys.slice(fitLimit) : []
const [menuOpen, setMenuOpen] = React.useState(false) const [menuOpen, setMenuOpen] = useState(false)
const menuRef = React.useRef<HTMLDivElement | null>(null) const menuRef = useRef<HTMLDivElement | null>(null)
React.useLayoutEffect(() => { useLayoutEffect(() => {
const el = menuRef.current const el = menuRef.current
if (!el || typeof window === 'undefined') return if (!el || typeof window === 'undefined') return
@ -532,16 +541,16 @@ export default function RecordJobActions({
}, []) }, [])
// ✅ neu // ✅ neu
const menuBtnRef = React.useRef<HTMLButtonElement | null>(null) const menuBtnRef = useRef<HTMLButtonElement | null>(null)
const menuPopupRef = React.useRef<HTMLDivElement | null>(null) const menuPopupRef = useRef<HTMLDivElement | null>(null)
const MENU_W = 208 // entspricht Tailwind w-52 (13rem) const MENU_W = 208 // entspricht Tailwind w-52 (13rem)
const GAP = 4 const GAP = 4
const MARGIN = 8 const MARGIN = 8
const [menuStyle, setMenuStyle] = React.useState<{ top: number; left: number; maxH: number } | null>(null) const [menuStyle, setMenuStyle] = useState<{ top: number; left: number; maxH: number } | null>(null)
React.useEffect(() => { useEffect(() => {
if (!menuOpen) return if (!menuOpen) return
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@ -566,7 +575,7 @@ export default function RecordJobActions({
} }
}, [menuOpen]) }, [menuOpen])
React.useLayoutEffect(() => { useLayoutEffect(() => {
if (!menuOpen) { if (!menuOpen) {
setMenuStyle(null) setMenuStyle(null)
return return
@ -812,7 +821,7 @@ export default function RecordJobActions({
<div ref={menuRef} className={cn('relative flex items-center flex-nowrap', className ?? 'gap-2')}> <div ref={menuRef} className={cn('relative flex items-center flex-nowrap', className ?? 'gap-2')}>
{inlineKeys.map((k) => { {inlineKeys.map((k) => {
const node = byKey[k] const node = byKey[k]
return node ? <React.Fragment key={k}>{node}</React.Fragment> : null return node ? <Fragment key={k}>{node}</Fragment> : null
})} })}
{collapse && menuKeys.length > 0 ? ( {collapse && menuKeys.length > 0 ? (

View File

@ -1,7 +1,7 @@
// frontend\src\components\ui\RecorderSettings.tsx // frontend\src\components\ui\RecorderSettings.tsx
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import Button from './Button' import Button from './Button'
import Card from './Card' import Card from './Card'
import LabeledSwitch from './LabeledSwitch' import LabeledSwitch from './LabeledSwitch'
@ -29,6 +29,12 @@ type RecorderSettings = {
teaserAudio?: boolean teaserAudio?: boolean
lowDiskPauseBelowGB?: number lowDiskPauseBelowGB?: number
enableNotifications?: boolean enableNotifications?: boolean
generateAssetsMeta?: boolean
generateAssetsThumb?: boolean
generateAssetsTeaser?: boolean
generateAssetsSprites?: boolean
generateAssetsAnalyze?: boolean
} }
type DiskStatus = { type DiskStatus = {
@ -59,12 +65,40 @@ const DEFAULTS: RecorderSettings = {
teaserAudio: false, teaserAudio: false,
lowDiskPauseBelowGB: 5, lowDiskPauseBelowGB: 5,
enableNotifications: true, enableNotifications: true,
generateAssetsMeta: true,
generateAssetsThumb: true,
generateAssetsTeaser: true,
generateAssetsSprites: true,
generateAssetsAnalyze: true,
} }
type Props = { type Props = {
onAssetsGenerated?: () => void onAssetsGenerated?: () => void
} }
function regeneratePhaseOrder(queue?: string, phase?: string) {
const q = String(queue || '').trim().toLowerCase()
const p = String(phase || '').trim().toLowerCase()
const key = `${q}:${p}`
switch (key) {
case 'postwork:meta':
return 0
case 'postwork:thumb':
return 1
case 'postwork:teaser':
return 2
case 'postwork:sprites':
return 3
case 'enrich:analyze':
return 4
default:
return 999
}
}
function shortTaskFilename(name?: string, max = 52) { function shortTaskFilename(name?: string, max = 52) {
const s = String(name ?? '').trim() const s = String(name ?? '').trim()
if (!s) return '' if (!s) return ''
@ -94,7 +128,7 @@ function assetsPhaseLabel(label?: string, phase?: string) {
case 'sprites': case 'sprites':
return 'Sprites' return 'Sprites'
case 'analyze': case 'analyze':
return 'KI-Analyse' return 'Analyse'
default: default:
return '' return ''
} }
@ -109,12 +143,21 @@ function assetsTaskText(file?: string, label?: string, phase?: string) {
return phaseText return phaseText
} }
function parseTaskTimeMs(v: unknown) {
if (typeof v === 'number' && Number.isFinite(v)) return v
const s = String(v ?? '').trim()
if (!s) return 0
const ms = Date.parse(s)
return Number.isFinite(ms) ? ms : 0
}
export default function RecorderSettings({ onAssetsGenerated }: Props) { export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [value, setValue] = useState<RecorderSettings>(DEFAULTS) const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState<number>(0) const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState<number>(0)
const saveSuccessTimerRef = useRef<number | null>(null) const saveSuccessTimerRef = useRef<number | null>(null)
const [cleaning, setCleaning] = useState(false)
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null) const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
const [msg, setMsg] = useState<string | null>(null) const [msg, setMsg] = useState<string | null>(null)
const [err, setErr] = useState<string | null>(null) const [err, setErr] = useState<string | null>(null)
@ -171,6 +214,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
text: '', text: '',
cancellable: true, cancellable: true,
fading: false, fading: false,
queuedAtMs: 0,
startedAtMs: 0, startedAtMs: 0,
}) })
@ -181,6 +225,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
text: '', text: '',
cancellable: true, cancellable: true,
fading: false, fading: false,
queuedAtMs: 0,
startedAtMs: 0, startedAtMs: 0,
}) })
@ -190,6 +235,20 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const uiPauseGB = diskStatus?.pauseGB ?? pauseGB const uiPauseGB = diskStatus?.pauseGB ?? pauseGB
const uiResumeGB = diskStatus?.resumeGB ?? (pauseGB + 3) const uiResumeGB = diskStatus?.resumeGB ?? (pauseGB + 3)
const checkVideosAbortRef = useRef<AbortController | null>(null)
const checkVideosCancelRequestedRef = useRef(false)
const [checkVideosTask, setCheckVideosTask] = useState<TaskItem>({
id: 'check-videos',
status: 'idle',
title: 'Downloads prüfen',
text: '',
cancellable: true,
fading: false,
queuedAtMs: 0,
startedAtMs: 0,
})
useEffect(() => { useEffect(() => {
return () => { return () => {
if (saveSuccessTimerRef.current != null) { if (saveSuccessTimerRef.current != null) {
@ -232,6 +291,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
teaserAudio: (data as any).teaserAudio ?? DEFAULTS.teaserAudio, teaserAudio: (data as any).teaserAudio ?? DEFAULTS.teaserAudio,
lowDiskPauseBelowGB: (data as any).lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB, lowDiskPauseBelowGB: (data as any).lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB,
enableNotifications: (data as any).enableNotifications ?? DEFAULTS.enableNotifications, enableNotifications: (data as any).enableNotifications ?? DEFAULTS.enableNotifications,
generateAssetsMeta: (data as any).generateAssetsMeta ?? DEFAULTS.generateAssetsMeta,
generateAssetsThumb: (data as any).generateAssetsThumb ?? DEFAULTS.generateAssetsThumb,
generateAssetsTeaser: (data as any).generateAssetsTeaser ?? DEFAULTS.generateAssetsTeaser,
generateAssetsSprites: (data as any).generateAssetsSprites ?? DEFAULTS.generateAssetsSprites,
generateAssetsAnalyze: (data as any).generateAssetsAnalyze ?? DEFAULTS.generateAssetsAnalyze,
}) })
setLoadedDatabaseUrl(String((data as any).databaseUrl ?? '').trim()) setLoadedDatabaseUrl(String((data as any).databaseUrl ?? '').trim())
}) })
@ -258,11 +322,13 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const assetsFile = String(assets?.currentFile || assets?.text || '').trim() const assetsFile = String(assets?.currentFile || assets?.text || '').trim()
const assetsLabel = String(assets?.currentLabel || '').trim() const assetsLabel = String(assets?.currentLabel || '').trim()
const assetsPhase = String(assets?.currentPhase || '').trim() const assetsPhase = String(assets?.currentPhase || '').trim()
const assetsQueuedAtMs = parseTaskTimeMs(assets?.queuedAt)
const assetsStartedAtMs = parseTaskTimeMs(assets?.startedAt)
if (assets?.queued) { if (assets?.queued) {
setAssetsTask((t) => ({ setAssetsTask((t) => ({
...t, ...t,
status: 'running', status: 'queued',
title: 'Assets generieren', title: 'Assets generieren',
text: String(assets.text || 'Wartet…'), text: String(assets.text || 'Wartet…'),
done: Number(assets.done ?? 0), done: Number(assets.done ?? 0),
@ -270,6 +336,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
err: undefined, err: undefined,
cancellable: false, cancellable: false,
fading: false, fading: false,
queuedAtMs: t.queuedAtMs || assetsQueuedAtMs || Date.now(),
startedAtMs: assetsStartedAtMs || t.startedAtMs || 0,
})) }))
} else if (assets?.running) { } else if (assets?.running) {
setAssetsTask((t) => ({ setAssetsTask((t) => ({
@ -282,6 +350,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
err: undefined, err: undefined,
cancellable: true, cancellable: true,
fading: false, fading: false,
queuedAtMs: t.queuedAtMs || assetsQueuedAtMs || Date.now(),
startedAtMs: assetsStartedAtMs || t.startedAtMs || Date.now(),
})) }))
} else if (assets?.error) { } else if (assets?.error) {
const assetsError = String(assets.error || '').trim() const assetsError = String(assets.error || '').trim()
@ -310,6 +380,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
err: cancelled ? undefined : assetsError, err: cancelled ? undefined : assetsError,
cancellable: false, cancellable: false,
fading: t.fading ?? false, fading: t.fading ?? false,
queuedAtMs: t.queuedAtMs || assetsQueuedAtMs || 0,
startedAtMs: assetsStartedAtMs || t.startedAtMs || 0,
} }
}) })
} else if (assets?.finishedAt) { } else if (assets?.finishedAt) {
@ -323,6 +395,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
err: undefined, err: undefined,
cancellable: false, cancellable: false,
fading: false, fading: false,
queuedAtMs: t.queuedAtMs || assetsQueuedAtMs || 0,
startedAtMs: assetsStartedAtMs || t.startedAtMs || 0,
})) }))
} }
@ -330,84 +404,113 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
? data.regenerateAssetsJobs ? data.regenerateAssetsJobs
: [] : []
const nextTasks: TaskItem[] = regenJobs
.map((job: any): TaskItem | null => {
const file = String(job?.file || '').trim()
if (!file) return null
const state = String(job?.state || '').trim().toLowerCase()
const error = String(job?.error || '').trim()
const startedAtMs =
Number(job?.startedAt ?? 0) ||
Number(job?.enqueuedAt ?? 0) ||
Date.now()
const cancelled =
state === 'cancelled' ||
error.toLowerCase() === 'abgebrochen' ||
error.toLowerCase() === 'abbruch' ||
error.toLowerCase() === 'cancelled' ||
error.toLowerCase() === 'canceled'
let status: TaskItem['status'] = 'idle'
if (state === 'queued' || state === 'running') {
status = 'running'
} else if (state === 'done') {
status = 'done'
} else if (cancelled) {
status = 'cancelled'
} else if (state === 'error') {
status = 'error'
}
const phases = Array.isArray(job?.phases) ? job.phases : []
const runningPhase = phases.find((p: any) => String(p?.state || '').toLowerCase() === 'running')
const queuedPhase = phases.find((p: any) => String(p?.state || '').toLowerCase() === 'queued')
const phaseLabel =
String(runningPhase?.label || '').trim() ||
String(queuedPhase?.label || '').trim()
const relevantPhases = phases.filter((p: any) => {
const q = String(p?.queue || '').toLowerCase()
const ph = String(p?.phase || '').toLowerCase()
return (
(q === 'postwork' && ['meta', 'thumb', 'teaser', 'sprites'].includes(ph)) ||
(q === 'enrich' && ph === 'analyze')
)
})
const total = relevantPhases.length || 5
const done = relevantPhases.filter((p: any) => String(p?.state || '').toLowerCase() === 'done').length
const text =
status === 'running'
? phaseLabel
? `${shortTaskFilename(file, 38)} · ${phaseLabel}`
: shortTaskFilename(file, 52)
: shortTaskFilename(file, 52)
return {
id: regenerateTaskId(file),
status,
title: regenerateTaskTitle(file),
text,
done: status === 'done' ? total : done,
total,
err: status === 'error' ? error || 'Fehler beim Regenerieren.' : undefined,
cancellable: status === 'running',
fading: false,
startedAtMs,
}
})
.filter(Boolean) as TaskItem[]
setRegenerateAssetsTasks((prev) => { setRegenerateAssetsTasks((prev) => {
const nextById = new Map(nextTasks.map((t) => [t.id, t]))
const prevById = new Map(prev.map((t) => [t.id, t])) const prevById = new Map(prev.map((t) => [t.id, t]))
const nextTasks: TaskItem[] = regenJobs
.map((job: any): TaskItem | null => {
const file = String(job?.file || '').trim()
if (!file) return null
const id = regenerateTaskId(file)
const prevTask = prevById.get(id)
const state = String(job?.state || '').trim().toLowerCase()
const error = String(job?.error || '').trim()
const cancelled =
state === 'cancelled' ||
error.toLowerCase() === 'abgebrochen' ||
error.toLowerCase() === 'abbruch' ||
error.toLowerCase() === 'cancelled' ||
error.toLowerCase() === 'canceled'
let status: TaskItem['status'] = 'idle'
if (state === 'queued') {
status = 'queued'
} else if (state === 'running') {
status = 'running'
} else if (state === 'done') {
status = 'done'
} else if (cancelled) {
status = 'cancelled'
} else if (state === 'error') {
status = 'error'
}
const phases = Array.isArray(job?.phases) ? job.phases : []
const relevantPhases = phases
.filter((p: any) => {
const q = String(p?.queue || '').toLowerCase()
const ph = String(p?.phase || '').toLowerCase()
return (
(q === 'postwork' && ['meta', 'thumb', 'teaser', 'sprites'].includes(ph)) ||
(q === 'enrich' && ph === 'analyze')
)
})
.sort((a: any, b: any) => {
return regeneratePhaseOrder(a?.queue, a?.phase) - regeneratePhaseOrder(b?.queue, b?.phase)
})
const runningPhase = relevantPhases.find(
(p: any) => String(p?.state || '').toLowerCase() === 'running'
)
const queuedPhase = relevantPhases.find(
(p: any) => String(p?.state || '').toLowerCase() === 'queued'
)
const activePhase = runningPhase ?? queuedPhase
const phaseLabel = assetsPhaseLabel(
String(activePhase?.label || '').trim(),
String(activePhase?.phase || '').trim()
)
const total = relevantPhases.length || 5
const done = relevantPhases.filter(
(p: any) => String(p?.state || '').toLowerCase() === 'done'
).length
// Wichtig:
// Reihenfolge in der UI immer über den ersten bekannten Queue-Zeitpunkt stabil halten
const stableQueuedAtMs =
prevTask?.queuedAtMs ||
Number(job?.enqueuedAt ?? 0) ||
Number(job?.startedAt ?? 0) ||
Date.now()
const stableStartedAtMs =
Number(job?.startedAt ?? 0) ||
prevTask?.startedAtMs ||
0
const text =
status === 'running' || status === 'queued'
? phaseLabel
? `${shortTaskFilename(file, 38)} · ${phaseLabel}`
: shortTaskFilename(file, 52)
: shortTaskFilename(file, 52)
return {
id,
status,
title: regenerateTaskTitle(file),
text,
done: status === 'done' ? total : done,
total,
err: status === 'error' ? error || 'Fehler beim Regenerieren.' : undefined,
cancellable: status === 'queued' || status === 'running',
fading: prevTask?.fading ?? false,
queuedAtMs: stableQueuedAtMs,
startedAtMs: stableStartedAtMs,
}
})
.filter(Boolean) as TaskItem[]
const nextById = new Map(nextTasks.map((t) => [t.id, t]))
const merged: TaskItem[] = [] const merged: TaskItem[] = []
for (const next of nextTasks) { for (const next of nextTasks) {
@ -421,7 +524,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
for (const prevTask of prev) { for (const prevTask of prev) {
if (nextById.has(prevTask.id)) continue if (nextById.has(prevTask.id)) continue
if (prevTask.status === 'running') continue if (prevTask.status === 'running' || prevTask.status === 'queued') continue
if (prevTask.fading) { if (prevTask.fading) {
merged.push(prevTask) merged.push(prevTask)
continue continue
@ -429,24 +533,146 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
merged.push({ ...prevTask, fading: true }) merged.push({ ...prevTask, fading: true })
const file = prevTask.id.startsWith('regenerate-assets:') window.setTimeout(() => {
? prevTask.id.slice('regenerate-assets:'.length) setRegenerateAssetsTasks((cur) => cur.filter((t) => t.id !== prevTask.id))
: '' }, 500)
if (file) {
window.setTimeout(() => {
setRegenerateAssetsTasks((cur) => cur.filter((t) => t.id !== prevTask.id))
}, 500)
}
} }
return merged return merged
}) })
const checkVideos = data.checkVideos
const checkVideosQueuedAtMs = parseTaskTimeMs(checkVideos?.queuedAt)
const checkVideosStartedAtMs = parseTaskTimeMs(checkVideos?.startedAt)
if (checkVideos?.queued) {
setCheckVideosTask((t) => ({
...t,
status: 'queued',
title: 'Downloads prüfen',
text: String(checkVideos.text || 'Wartet…'),
done: Number(checkVideos.done ?? 0),
total: Number(checkVideos.total ?? 0),
err: undefined,
cancellable: true,
fading: false,
queuedAtMs: t.queuedAtMs || checkVideosQueuedAtMs || Date.now(),
startedAtMs: checkVideosStartedAtMs || 0,
}))
} else if (checkVideos?.running) {
const currentFile = String(checkVideos.currentFile || '').trim()
setCheckVideosTask((t) => ({
...t,
status: 'running',
title: 'Downloads prüfen',
text: currentFile ? shortTaskFilename(currentFile, 52) : 'Prüfe Videos…',
done: Number(checkVideos.done ?? 0),
total: Number(checkVideos.total ?? 0),
err: undefined,
cancellable: true,
fading: false,
queuedAtMs: t.queuedAtMs || checkVideosQueuedAtMs || Date.now(),
startedAtMs: checkVideosStartedAtMs || t.startedAtMs || Date.now(),
}))
} else if (checkVideos?.error) {
const checkErr = String(checkVideos.error || '').trim()
const cancelled =
checkErr.toLowerCase() === 'abgebrochen' ||
checkErr.toLowerCase() === 'abbruch' ||
checkErr.toLowerCase() === 'cancelled' ||
checkErr.toLowerCase() === 'canceled'
if (cancelled) {
checkVideosCancelRequestedRef.current = false
setCheckVideosTask((t) => ({
...t,
status: 'idle',
title: 'Downloads prüfen',
text: '',
done: 0,
total: 0,
err: undefined,
cancellable: true,
fading: false,
queuedAtMs: 0,
startedAtMs: 0,
}))
return
}
setCheckVideosTask((t) => ({
...t,
status: 'error',
title: 'Downloads prüfen',
text: String(checkVideos.text || ''),
done: Number(checkVideos.done ?? 0),
total: Number(checkVideos.total ?? 0),
err: checkErr,
cancellable: false,
fading: false,
queuedAtMs: t.queuedAtMs || checkVideosQueuedAtMs || 0,
startedAtMs: checkVideosStartedAtMs || t.startedAtMs || 0,
}))
fadeOutTask(setCheckVideosTask)
} else if (checkVideos?.finishedAt) {
setCheckVideosTask((t) => ({
...t,
status: 'done',
title: 'Downloads prüfen',
text: String(checkVideos.text || 'Prüfung abgeschlossen.'),
done: Number(checkVideos.done ?? 0),
total: Number(checkVideos.total ?? 0),
err: undefined,
cancellable: false,
fading: false,
queuedAtMs: t.queuedAtMs || checkVideosQueuedAtMs || 0,
startedAtMs: checkVideosStartedAtMs || t.startedAtMs || 0,
}))
window.dispatchEvent(
new CustomEvent('finished-downloads:integrity-refresh', {
detail: { all: true },
})
)
fadeOutTask(setCheckVideosTask)
} else {
setCheckVideosTask((t) => ({
...t,
status: 'idle',
title: 'Downloads prüfen',
text: '',
done: 0,
total: 0,
err: undefined,
cancellable: true,
fading: false,
queuedAtMs: 0,
startedAtMs: 0,
}))
}
const cleanup = data.cleanup const cleanup = data.cleanup
const cleanupQueuedAtMs = parseTaskTimeMs(cleanup?.queuedAt)
const cleanupStartedAtMs = parseTaskTimeMs(cleanup?.startedAt)
if (cleanup?.running) { if (cleanup?.queued) {
setCleaning(true) setCleanupTask((t) => ({
...t,
status: 'queued',
title: 'Aufräumen',
text: String(cleanup.text || cleanup.currentFile || 'Wartet…'),
done: Number(cleanup.done ?? 0),
total: Number(cleanup.total ?? 0),
err: undefined,
cancellable: false,
fading: false,
queuedAtMs: t.queuedAtMs || cleanupQueuedAtMs || Date.now(),
startedAtMs: cleanupStartedAtMs || 0,
}))
} else if (cleanup?.running) {
setCleanupTask((t) => ({ setCleanupTask((t) => ({
...t, ...t,
status: 'running', status: 'running',
@ -457,9 +683,10 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
err: undefined, err: undefined,
cancellable: false, cancellable: false,
fading: false, fading: false,
queuedAtMs: t.queuedAtMs || cleanupQueuedAtMs || Date.now(),
startedAtMs: cleanupStartedAtMs || t.startedAtMs || Date.now(),
})) }))
} else if (cleanup?.error) { } else if (cleanup?.error) {
setCleaning(false)
setCleanupTask((t) => ({ setCleanupTask((t) => ({
...t, ...t,
status: 'error', status: 'error',
@ -470,9 +697,10 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
err: String(cleanup.error), err: String(cleanup.error),
cancellable: false, cancellable: false,
fading: false, fading: false,
queuedAtMs: t.queuedAtMs || cleanupQueuedAtMs || 0,
startedAtMs: cleanupStartedAtMs || t.startedAtMs || 0,
})) }))
} else if (cleanup?.finishedAt || cleanup?.text) { } else if (cleanup?.finishedAt) {
setCleaning(false)
setCleanupTask((t) => ({ setCleanupTask((t) => ({
...t, ...t,
status: 'done', status: 'done',
@ -483,9 +711,10 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
err: undefined, err: undefined,
cancellable: false, cancellable: false,
fading: false, fading: false,
queuedAtMs: t.queuedAtMs || cleanupQueuedAtMs || 0,
startedAtMs: cleanupStartedAtMs || t.startedAtMs || 0,
})) }))
} else { } else {
setCleaning(false)
setCleanupTask((t) => ({ setCleanupTask((t) => ({
...t, ...t,
status: 'idle', status: 'idle',
@ -496,6 +725,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
err: undefined, err: undefined,
cancellable: true, cancellable: true,
fading: false, fading: false,
queuedAtMs: 0,
startedAtMs: 0,
})) }))
} }
} catch { } catch {
@ -601,6 +832,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const teaserAudio = !!value.teaserAudio const teaserAudio = !!value.teaserAudio
const lowDiskPauseBelowGB = Math.max(1, Math.floor(Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB))) const lowDiskPauseBelowGB = Math.max(1, Math.floor(Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB)))
const enableNotifications = !!value.enableNotifications const enableNotifications = !!value.enableNotifications
const generateAssetsMeta = !!value.generateAssetsMeta
const generateAssetsThumb = !!value.generateAssetsThumb
const generateAssetsTeaser = !!value.generateAssetsTeaser
const generateAssetsSprites = !!value.generateAssetsSprites
const generateAssetsAnalyze = !!value.generateAssetsAnalyze
setSaving(true) setSaving(true)
try { try {
@ -625,13 +861,17 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
teaserAudio, teaserAudio,
lowDiskPauseBelowGB, lowDiskPauseBelowGB,
enableNotifications, enableNotifications,
generateAssetsMeta,
generateAssetsThumb,
generateAssetsTeaser,
generateAssetsSprites,
generateAssetsAnalyze,
}), }),
}) })
if (!res.ok) { if (!res.ok) {
const t = await res.text().catch(() => '') const t = await res.text().catch(() => '')
throw new Error(t || `HTTP ${res.status}`) throw new Error(t || `HTTP ${res.status}`)
} }
setMsg('✅ Gespeichert.')
// Button-Label: "Gespeichert" für 2.5s // Button-Label: "Gespeichert" für 2.5s
const until = Date.now() + 2500 const until = Date.now() + 2500
setSaveSuccessUntilMs(until) setSaveSuccessUntilMs(until)
@ -669,7 +909,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
} }
} }
function fadeOutTask(setter: React.Dispatch<React.SetStateAction<TaskItem>>, delayMs = 3000, fadeMs = 500) { function fadeOutTask(setter: Dispatch<SetStateAction<TaskItem>>, delayMs = 3000, fadeMs = 500) {
window.setTimeout(() => { window.setTimeout(() => {
setter((t) => ({ ...t, fading: true })) setter((t) => ({ ...t, fading: true }))
window.setTimeout(() => { window.setTimeout(() => {
@ -681,6 +921,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
done: 0, done: 0,
total: 0, total: 0,
fading: false, fading: false,
queuedAtMs: 0,
startedAtMs: 0, startedAtMs: 0,
})) }))
}, fadeMs) }, fadeMs)
@ -703,7 +944,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
async function cleanupSmallDone() { async function cleanupSmallDone() {
setErr(null) setErr(null)
setMsg(null)
const mb = Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB ?? 0) const mb = Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB ?? 0)
const doneDir = (value.doneDir || DEFAULTS.doneDir).trim() const doneDir = (value.doneDir || DEFAULTS.doneDir).trim()
@ -725,18 +965,20 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
) )
if (!ok) return if (!ok) return
// ✅ Task starten (als letzter Eintrag in TaskList) // Sofort in TaskList sichtbar machen
setCleaning(true) const now = Date.now()
setCleanupTask((t) => ({ setCleanupTask((t) => ({
...t, ...t,
status: 'running', status: 'queued',
title: 'Aufräumen', title: 'Aufräumen',
text: 'Räume auf…', text: 'Wartet…',
err: undefined, err: undefined,
done: 0, done: 0,
total: 0, total: 0,
fading: false, fading: false,
startedAtMs: t.startedAtMs || Date.now(), queuedAtMs: t.queuedAtMs || now,
startedAtMs: 0,
})) }))
try { try {
@ -745,27 +987,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
cache: 'no-store', cache: 'no-store',
}) })
if (!res.ok) { if (!res.ok) {
const t = await res.text().catch(() => '') const t = await res.text().catch(() => '')
throw new Error(t || `HTTP ${res.status}`) throw new Error(t || `HTTP ${res.status}`)
} }
const data = await res.json()
const scannedFiles = Number(data.scannedFiles ?? 0)
const deletedFiles = Number(data.deletedFiles ?? 0)
const deletedBytesHuman = String(data.deletedBytesHuman ?? '0 B')
setCleanupTask((t) => ({
...t,
status: 'done',
done: scannedFiles,
total: scannedFiles,
title: 'Aufräumen',
text: `gelöscht: ${deletedFiles}/${scannedFiles} · freigegeben: ${deletedBytesHuman}`,
}))
fadeOutTask(setCleanupTask, 7000, 500)
} catch (e: any) { } catch (e: any) {
const msg = e?.message ?? String(e) const msg = e?.message ?? String(e)
setErr(msg) setErr(msg)
@ -773,13 +999,14 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
setCleanupTask((t) => ({ setCleanupTask((t) => ({
...t, ...t,
status: 'error', status: 'error',
text: 'Fehler beim Aufräumen.', title: 'Aufräumen',
text: 'Fehler beim Starten des Aufräumens.',
err: msg, err: msg,
cancellable: false,
fading: false,
})) }))
fadeOutTask(setCleanupTask, 7000, 500) fadeOutTask(setCleanupTask, 7000, 500)
} finally {
setCleaning(false)
} }
} }
@ -807,6 +1034,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
total: 0, total: 0,
cancellable: true, cancellable: true,
fading: false, fading: false,
queuedAtMs: 0,
startedAtMs: 0, startedAtMs: 0,
})) }))
}, 500) }, 500)
@ -860,7 +1088,50 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
fadeOutRegenerateTask(cleanFile) fadeOutRegenerateTask(cleanFile)
} }
const visibleTasks = [assetsTask, ...regenerateAssetsTasks, cleanupTask].filter( async function cancelCheckVideosTask() {
checkVideosCancelRequestedRef.current = true
const ac = checkVideosAbortRef.current
checkVideosAbortRef.current = null
setCheckVideosTask((t) => ({
...t,
cancellable: false,
fading: true,
}))
window.setTimeout(() => {
setCheckVideosTask((t) => ({
...t,
status: 'idle',
title: 'Downloads prüfen',
text: '',
err: undefined,
done: 0,
total: 0,
cancellable: true,
fading: false,
queuedAtMs: 0,
startedAtMs: 0,
}))
}, 500)
if (ac) {
ac.abort()
return
}
try {
await fetch('/api/tasks/check-videos', {
method: 'DELETE',
cache: 'no-store' as any,
})
} catch {
// ignore
}
}
const visibleTasks = [assetsTask, ...regenerateAssetsTasks, checkVideosTask, cleanupTask].filter(
(t) => t.status !== 'idle' (t) => t.status !== 'idle'
) )
@ -908,6 +1179,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
void cancelRegenerateAssetsTask(file) void cancelRegenerateAssetsTask(file)
return return
} }
if (id === 'check-videos') {
void cancelCheckVideosTask()
return
}
}} }}
/> />
@ -941,7 +1217,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
<div className="mt-3 space-y-3"> <div className="mt-3 space-y-3">
<Task <Task
title="Assets-Generator" title="Assets-Generator"
description="Erzeugt nur fehlende oder unvollständige Assets (Meta, Vorschaubild, Teaser, Sprites, KI-Analyse). Bereits vorhandene Assets werden übersprungen." description="Erzeugt nur fehlende oder unvollständige Assets der aktivierten Phasen. Nicht ausgewählte Phasen werden komplett übersprungen."
startLabel="Start" startLabel="Start"
startingLabel="Starte…" startingLabel="Starte…"
startUrl="/api/tasks/generate-assets" startUrl="/api/tasks/generate-assets"
@ -960,7 +1236,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
err: undefined, err: undefined,
cancellable: true, cancellable: true,
fading: false, fading: false,
startedAtMs: t.startedAtMs || Date.now(), queuedAtMs: t.queuedAtMs || Date.now(),
startedAtMs: 0,
})) }))
}} }}
onStart={(ac) => { onStart={(ac) => {
@ -976,7 +1253,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
err: undefined, err: undefined,
cancellable: true, cancellable: true,
fading: false, fading: false,
startedAtMs: t.startedAtMs || Date.now(), queuedAtMs: t.queuedAtMs || Date.now(),
startedAtMs: Date.now(),
})) }))
}} }}
onProgress={(p) => { onProgress={(p) => {
@ -1027,13 +1305,152 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
}} }}
/> />
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<div className="space-y-2">
<LabeledSwitch
checked={!!value.generateAssetsMeta}
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsMeta: checked }))}
label="Meta"
description="Erzeugt oder ergänzt die meta.json mit Dauer, Auflösung, FPS und weiteren Videoinformationen."
/>
<LabeledSwitch
checked={!!value.generateAssetsThumb}
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsThumb: checked }))}
label="Vorschaubild"
description="Erzeugt das statische Vorschaubild preview.jpg für Listen und Kartenansicht."
/>
<LabeledSwitch
checked={!!value.generateAssetsTeaser}
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsTeaser: checked }))}
label="Teaser"
description="Erzeugt die kurze Video-Vorschau preview.mp4 für Hover- und Preview-Wiedergabe."
/>
<LabeledSwitch
checked={!!value.generateAssetsSprites}
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsSprites: checked }))}
label="Sprites"
description="Erzeugt das Sprite-Bild preview-sprite.jpg für die Scrubber- und Timeline-Vorschau."
/>
<LabeledSwitch
checked={!!value.generateAssetsAnalyze}
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsAnalyze: checked }))}
label="Analyse"
description="Führt die Analyse aus und ergänzt Treffer, Segmente und weitere Auswertungsdaten."
/>
</div>
</div>
<Task
title="Downloads prüfen"
description="Prüft fertige Videos anhand der preview-sprite.jpg auf schwarze Endzellen und schreibt das Ergebnis in die meta.json."
startLabel="Prüfen"
startingLabel="Starte…"
startUrl="/api/tasks/check-videos"
stopUrl="/api/tasks/check-videos"
sseUrl="/api/events/stream"
onQueued={() => {
checkVideosCancelRequestedRef.current = false
setCheckVideosTask((t: TaskItem) => ({
...t,
status: 'queued',
title: 'Downloads prüfen',
text: 'Wartet…',
done: 0,
total: 0,
err: undefined,
cancellable: true,
fading: false,
queuedAtMs: t.queuedAtMs || Date.now(),
startedAtMs: 0,
}))
}}
onStart={(ac) => {
checkVideosCancelRequestedRef.current = false
checkVideosAbortRef.current = ac
setCheckVideosTask((t: TaskItem) => ({
...t,
status: 'running',
title: 'Downloads prüfen',
text: '',
done: 0,
total: 0,
err: undefined,
cancellable: true,
fading: false,
queuedAtMs: t.queuedAtMs || Date.now(),
startedAtMs: Date.now(),
}))
}}
onProgress={(p) => {
const currentFile = String((p as any).currentFile ?? '').trim()
setCheckVideosTask((t: TaskItem) => ({
...t,
status: 'running',
title: 'Downloads prüfen',
text: currentFile ? shortTaskFilename(currentFile, 52) : 'Prüfe Videos…',
done: Number((p as any).done ?? 0),
total: Number((p as any).total ?? 0),
}))
}}
onDone={() => {
checkVideosAbortRef.current = null
setCheckVideosTask((t: TaskItem) => ({
...t,
status: 'done',
title: 'Downloads prüfen',
text: 'Prüfung abgeschlossen.',
}))
window.dispatchEvent(
new CustomEvent('finished-downloads:integrity-refresh', {
detail: { all: true },
})
)
fadeOutTask(setCheckVideosTask)
}}
onCancelled={() => {
checkVideosAbortRef.current = null
if (checkVideosCancelRequestedRef.current) {
checkVideosCancelRequestedRef.current = false
return
}
setCheckVideosTask((t: TaskItem) => ({
...t,
status: 'cancelled',
title: 'Downloads prüfen',
text: '',
}))
fadeOutTask(setCheckVideosTask)
}}
onError={(message) => {
checkVideosAbortRef.current = null
setCheckVideosTask((t: TaskItem) => ({
...t,
status: 'error',
title: 'Downloads prüfen',
text: 'Fehler bei der Video-Prüfung.',
err: message,
}))
fadeOutTask(setCheckVideosTask)
}}
/>
<Task <Task
title="Aufräumen" title="Aufräumen"
description='Löscht Dateien im doneDir kleiner als die Mindestgröße (Ordner "keep" wird übersprungen) und entfernt verwaiste Assets.' description='Löscht Dateien im doneDir kleiner als die Mindestgröße (Ordner "keep" wird übersprungen) und entfernt verwaiste Assets.'
startLabel="Aufräumen" startLabel="Aufräumen"
startingLabel="Läuft…" startingLabel="Läuft…"
onTrigger={cleanupSmallDone} onTrigger={cleanupSmallDone}
busy={cleaning}
disabled={saving} disabled={saving}
onError={(message) => { onError={(message) => {
// Optional: zusätzlicher Fallback für Startfehler-Anzeige direkt im Task // Optional: zusätzlicher Fallback für Startfehler-Anzeige direkt im Task

View File

@ -2,7 +2,7 @@
'use client' 'use client'
import * as React from 'react' import { useId, type SyntheticEvent, type CSSProperties } from 'react'
type Props = { type Props = {
id?: string id?: string
@ -65,7 +65,7 @@ export default function Slider({
className = '', className = '',
inputClassName = '', inputClassName = '',
}: Props) { }: Props) {
const reactId = React.useId() const reactId = useId()
const inputId = id ?? `slider-${reactId}` const inputId = id ?? `slider-${reactId}`
const safeValue = Number.isFinite(value) ? value : min const safeValue = Number.isFinite(value) ? value : min
@ -95,7 +95,7 @@ export default function Slider({
onChange(clamp(next, min, max)) onChange(clamp(next, min, max))
} }
const handleCommit = (e: React.SyntheticEvent<HTMLInputElement>) => { const handleCommit = (e: SyntheticEvent<HTMLInputElement>) => {
onChangeEnd?.(clamp(Number(e.currentTarget.value), min, max)) onChangeEnd?.(clamp(Number(e.currentTarget.value), min, max))
} }
@ -198,7 +198,7 @@ export default function Slider({
['--slider-track-h' as any]: `${trackH}px`, ['--slider-track-h' as any]: `${trackH}px`,
['--slider-thumb-size' as any]: `${thumbSize}px`, ['--slider-thumb-size' as any]: `${thumbSize}px`,
['--slider-thumb-offset' as any]: `${thumbOffset}px`, ['--slider-thumb-offset' as any]: `${thumbOffset}px`,
} as React.CSSProperties } as CSSProperties
} }
onChange={(e) => handleChange(Number(e.target.value))} onChange={(e) => handleChange(Number(e.target.value))}
onPointerUp={handleCommit} onPointerUp={handleCommit}
@ -389,7 +389,7 @@ export default function Slider({
['--slider-track-h' as any]: `${trackH}px`, ['--slider-track-h' as any]: `${trackH}px`,
['--slider-thumb-size' as any]: `${thumbSize}px`, ['--slider-thumb-size' as any]: `${thumbSize}px`,
['--slider-thumb-offset' as any]: `${thumbOffset}px`, ['--slider-thumb-offset' as any]: `${thumbOffset}px`,
} as React.CSSProperties } as CSSProperties
} }
onChange={(e) => handleChange(Number(e.target.value))} onChange={(e) => handleChange(Number(e.target.value))}
onPointerUp={handleCommit} onPointerUp={handleCommit}

View File

@ -0,0 +1,40 @@
// frontend\src\components\ui\SpritePreloader.tsx
'use client'
import { useEffect } from 'react'
const loadedSpriteUrls = new Set<string>()
type Props = {
src?: string | null
enabled?: boolean
}
export default function SpritePreloader({
src,
enabled = true,
}: Props) {
useEffect(() => {
const url = String(src ?? '').trim()
if (!enabled || !url || loadedSpriteUrls.has(url)) return
const img = new Image()
;(img as any).fetchPriority = 'low'
img.decoding = 'async'
img.src = url
const markDone = () => {
loadedSpriteUrls.add(url)
}
if (typeof img.decode === 'function') {
img.decode().then(markDone).catch(markDone)
} else {
img.onload = markDone
img.onerror = markDone
}
}, [src, enabled])
return null
}

View File

@ -2,10 +2,16 @@
'use client' 'use client'
import { useCallback, useRef, useState } from 'react' import {
useState,
useRef,
useCallback,
type ReactNode,
type PointerEvent,
type MouseEvent as ReactMouseEvent,
} from 'react'
type Props = { type Props = {
children: React.ReactNode children: ReactNode
actionLabel: string actionLabel: string
actionColor?: 'red' | 'indigo' actionColor?: 'red' | 'indigo'
disabled?: boolean disabled?: boolean
@ -89,7 +95,7 @@ export default function SwipeActionRow({
return -Math.min(resisted, width + MAX_OVERSWIPE, Math.abs(maxLeft)) return -Math.min(resisted, width + MAX_OVERSWIPE, Math.abs(maxLeft))
}, []) }, [])
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => { const onPointerDown = useCallback((e: PointerEvent<HTMLDivElement>) => {
if (disabled) return if (disabled) return
if (e.pointerType === 'mouse') return if (e.pointerType === 'mouse') return
@ -111,7 +117,7 @@ export default function SwipeActionRow({
} }
}, [disabled]) }, [disabled])
const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => { const onPointerMove = useCallback((e: PointerEvent<HTMLDivElement>) => {
if (!dragging) return if (!dragging) return
if (pointerIdRef.current !== e.pointerId) return if (pointerIdRef.current !== e.pointerId) return
@ -129,7 +135,7 @@ export default function SwipeActionRow({
setDragX(next) setDragX(next)
}, [dragging, applyDragResistance]) }, [dragging, applyDragResistance])
const onPointerEnd = useCallback(async (e: React.PointerEvent<HTMLDivElement>) => { const onPointerEnd = useCallback(async (e: PointerEvent<HTMLDivElement>) => {
if (!dragging) return if (!dragging) return
if (pointerIdRef.current !== e.pointerId) return if (pointerIdRef.current !== e.pointerId) return
@ -167,9 +173,9 @@ export default function SwipeActionRow({
setDragX(0) setDragX(0)
}, [dragging, dragX, getRevealWidth, runAction]) }, [dragging, dragX, getRevealWidth, runAction])
const onActionClick = useCallback(async (e: React.MouseEvent) => { const onActionClick = useCallback((e: ReactMouseEvent<HTMLDivElement>) => {
e.stopPropagation() e.stopPropagation()
await runAction() void runAction()
}, [runAction]) }, [runAction])
const revealedPx = Math.max(0, Math.abs(dragX)) const revealedPx = Math.max(0, Math.abs(dragX))

View File

@ -2,7 +2,7 @@
'use client' 'use client'
import * as React from 'react' import { useEffect, useRef, useState, useCallback, type ReactNode, forwardRef, useImperativeHandle } from 'react'
import { FireIcon as FireSolidIcon } from '@heroicons/react/24/solid' import { FireIcon as FireSolidIcon } from '@heroicons/react/24/solid'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
@ -31,12 +31,12 @@ function getGlobalFxLayer(): HTMLDivElement | null {
} }
export type SwipeAction = { export type SwipeAction = {
label: React.ReactNode label: ReactNode
className?: string className?: string
} }
export type SwipeCardProps = { export type SwipeCardProps = {
children: React.ReactNode children: ReactNode
enabled?: boolean enabled?: boolean
disabled?: boolean disabled?: boolean
onTap?: () => void onTap?: () => void
@ -93,7 +93,7 @@ function rubberBand(delta: number, width: number) {
return sign * Math.min(resisted, width + MAX_OVERSWIPE_PX) return sign * Math.min(resisted, width + MAX_OVERSWIPE_PX)
} }
const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard( const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard(
{ {
children, children,
enabled = true, enabled = true,
@ -120,26 +120,26 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}, },
ref ref
) { ) {
const cardRef = React.useRef<HTMLDivElement | null>(null) const cardRef = useRef<HTMLDivElement | null>(null)
const outerRef = React.useRef<HTMLDivElement | null>(null) const outerRef = useRef<HTMLDivElement | null>(null)
const doubleTapBusyRef = React.useRef(false) const doubleTapBusyRef = useRef(false)
const pressTimerRef = React.useRef<number | null>(null) const pressTimerRef = useRef<number | null>(null)
const longPressActiveRef = React.useRef(false) const longPressActiveRef = useRef(false)
const rafRef = React.useRef<number | null>(null) const rafRef = useRef<number | null>(null)
const dxRef = React.useRef(0) const dxRef = useRef(0)
const thresholdRef = React.useRef(0) const thresholdRef = useRef(0)
const widthRef = React.useRef(360) const widthRef = useRef(360)
const tapTimerRef = React.useRef<number | null>(null) const tapTimerRef = useRef<number | null>(null)
const lastTapRef = React.useRef<{ t: number; x: number; y: number } | null>(null) const lastTapRef = useRef<{ t: number; x: number; y: number } | null>(null)
const velocityXRef = React.useRef(0) const velocityXRef = useRef(0)
const lastMoveXRef = React.useRef(0) const lastMoveXRef = useRef(0)
const lastMoveTRef = React.useRef(0) const lastMoveTRef = useRef(0)
const pointer = React.useRef<{ const pointer = useRef<{
id: number | null id: number | null
startX: number startX: number
startY: number startY: number
@ -159,25 +159,25 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
axisLocked: null, axisLocked: null,
}) })
const [dx, setDx] = React.useState(0) const [dx, setDx] = useState(0)
const [armedDir, setArmedDir] = React.useState<null | 'left' | 'right'>(null) const [armedDir, setArmedDir] = useState<null | 'left' | 'right'>(null)
const [animMs, setAnimMs] = React.useState(0) const [animMs, setAnimMs] = useState(0)
const clearTapTimer = React.useCallback(() => { const clearTapTimer = useCallback(() => {
if (tapTimerRef.current != null) { if (tapTimerRef.current != null) {
window.clearTimeout(tapTimerRef.current) window.clearTimeout(tapTimerRef.current)
tapTimerRef.current = null tapTimerRef.current = null
} }
}, []) }, [])
const clearPressTimer = React.useCallback(() => { const clearPressTimer = useCallback(() => {
if (pressTimerRef.current != null) { if (pressTimerRef.current != null) {
window.clearTimeout(pressTimerRef.current) window.clearTimeout(pressTimerRef.current)
pressTimerRef.current = null pressTimerRef.current = null
} }
}, []) }, [])
const endLongPress = React.useCallback(() => { const endLongPress = useCallback(() => {
clearPressTimer() clearPressTimer()
if (!longPressActiveRef.current) return if (!longPressActiveRef.current) return
@ -186,7 +186,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
onPressEnd?.() onPressEnd?.()
}, [clearPressTimer, onPressEnd]) }, [clearPressTimer, onPressEnd])
const flushDx = React.useCallback((nextDx: number) => { const flushDx = useCallback((nextDx: number) => {
dxRef.current = nextDx dxRef.current = nextDx
if (rafRef.current != null) return if (rafRef.current != null) return
rafRef.current = requestAnimationFrame(() => { rafRef.current = requestAnimationFrame(() => {
@ -195,7 +195,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}) })
}, []) }, [])
const reset = React.useCallback(() => { const reset = useCallback(() => {
clearPressTimer() clearPressTimer()
longPressActiveRef.current = false longPressActiveRef.current = false
@ -220,7 +220,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}, snapMs) }, snapMs)
}, [snapMs, clearPressTimer]) }, [snapMs, clearPressTimer])
const commit = React.useCallback( const commit = useCallback(
async (dir: 'left' | 'right', runAction: boolean) => { async (dir: 'left' | 'right', runAction: boolean) => {
if (rafRef.current != null) { if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current) cancelAnimationFrame(rafRef.current)
@ -267,7 +267,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
[commitMs, onSwipeLeft, onSwipeRight, snapMs] [commitMs, onSwipeLeft, onSwipeRight, snapMs]
) )
const softResetForTap = React.useCallback(() => { const softResetForTap = useCallback(() => {
if (rafRef.current != null) { if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current) cancelAnimationFrame(rafRef.current)
rafRef.current = null rafRef.current = null
@ -286,7 +286,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
} }
}, []) }, [])
const runHotFx = React.useCallback( const runHotFx = useCallback(
(clientX?: number, clientY?: number) => { (clientX?: number, clientY?: number) => {
const outer = outerRef.current const outer = outerRef.current
const card = cardRef.current const card = cardRef.current
@ -401,14 +401,14 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
[hotTargetSelector] [hotTargetSelector]
) )
React.useEffect(() => { useEffect(() => {
return () => { return () => {
clearTapTimer() clearTapTimer()
clearPressTimer() clearPressTimer()
} }
}, [clearTapTimer, clearPressTimer]) }, [clearTapTimer, clearPressTimer])
React.useImperativeHandle( useImperativeHandle(
ref, ref,
() => ({ () => ({
swipeLeft: (opts) => commit('left', opts?.runAction ?? true), swipeLeft: (opts) => commit('left', opts?.runAction ?? true),

View File

@ -1,7 +1,7 @@
// components/ui/Switch.tsx // components/ui/Switch.tsx
'use client' 'use client'
import * as React from 'react' import type { ChangeEvent } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
type SwitchSize = 'default' | 'short' type SwitchSize = 'default' | 'short'
@ -22,6 +22,7 @@ export type SwitchProps = {
size?: SwitchSize size?: SwitchSize
variant?: SwitchVariant variant?: SwitchVariant
compact?: boolean
className?: string className?: string
} }
@ -37,9 +38,10 @@ export default function Switch({
ariaDescribedby, ariaDescribedby,
size = 'default', size = 'default',
variant = 'simple', variant = 'simple',
compact = false,
className, className,
}: SwitchProps) { }: SwitchProps) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
if (disabled) return if (disabled) return
onChange(e.target.checked) onChange(e.target.checked)
} }
@ -49,16 +51,25 @@ export default function Switch({
disabled && 'cursor-not-allowed' disabled && 'cursor-not-allowed'
) )
const rootBase =
'group relative inline-flex 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'
if (size === 'short') { if (size === 'short') {
const trackClass = compact ? 'h-4 w-8' : 'h-5 w-10'
const thumbClass = compact
? 'size-3 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked:translate-x-4'
: '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'
return ( return (
<div <div
className={clsx( className={clsx(
'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', rootBase,
trackClass,
disabled && 'opacity-60', disabled && 'opacity-60',
className className
)} )}
> >
<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" /> <span className={thumbClass} />
<input <input
id={id} id={id}
@ -77,16 +88,24 @@ export default function Switch({
) )
} }
const trackClass = compact ? 'h-5 w-9' : 'h-6 w-11'
const thumbClass = compact
? '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-4'
: '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'
const iconSizeClass = compact ? 'size-2.5' : 'size-3'
return ( return (
<div <div
className={clsx( className={clsx(
'group relative inline-flex w-11 shrink-0 rounded-full bg-gray-200 p-0.5 inset-ring inset-ring-gray-900/5 outline-offset-2 outline-indigo-600 transition-colors duration-200 ease-in-out has-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', rootBase,
trackClass,
disabled && 'opacity-60', disabled && 'opacity-60',
className className
)} )}
> >
{variant === 'icon' ? ( {variant === '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 className={clsx('relative', thumbClass)}>
<span <span
aria-hidden="true" aria-hidden="true"
className={clsx( className={clsx(
@ -94,7 +113,11 @@ export default function Switch({
checked ? 'opacity-0 duration-100' : 'opacity-100 duration-200' checked ? 'opacity-0 duration-100' : 'opacity-100 duration-200'
)} )}
> >
<svg fill="none" viewBox="0 0 12 12" className="size-3 text-gray-400 dark:text-gray-600"> <svg
fill="none"
viewBox="0 0 12 12"
className={clsx(iconSizeClass, 'text-gray-400 dark:text-gray-600')}
>
<path <path
d="M4 8l2-2m0 0l2-2M6 6L4 4m2 2l2 2" d="M4 8l2-2m0 0l2-2M6 6L4 4m2 2l2 2"
stroke="currentColor" stroke="currentColor"
@ -112,13 +135,17 @@ export default function Switch({
checked ? 'opacity-100 duration-200' : 'opacity-0 duration-100' checked ? 'opacity-100 duration-200' : 'opacity-0 duration-100'
)} )}
> >
<svg fill="currentColor" viewBox="0 0 12 12" className="size-3 text-indigo-600 dark:text-indigo-500"> <svg
fill="currentColor"
viewBox="0 0 12 12"
className={clsx(iconSizeClass, 'text-indigo-600 dark:text-indigo-500')}
>
<path d="M3.707 5.293a1 1 0 00-1.414 1.414l1.414-1.414zM5 8l-.707.707a1 1 0 001.414 0L5 8zm4.707-3.293a1 1 0 00-1.414-1.414l1.414 1.414zm-7.414 2l2 2 1.414-1.414-2-2-1.414 1.414zm3.414 2l4-4-1.414-1.414-4 4 1.414 1.414z" /> <path d="M3.707 5.293a1 1 0 00-1.414 1.414l1.414-1.414zM5 8l-.707.707a1 1 0 001.414 0L5 8zm4.707-3.293a1 1 0 00-1.414-1.414l1.414 1.414zm-7.414 2l2 2 1.414-1.414-2-2-1.414 1.414zm3.414 2l4-4-1.414-1.414-4 4 1.414 1.414z" />
</svg> </svg>
</span> </span>
</span> </span>
) : ( ) : (
<span className="size-5 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked:translate-x-5" /> <span className={thumbClass} />
)} )}
<input <input

View File

@ -1,6 +1,15 @@
// frontend\src\components\ui\Table.tsx
'use client' 'use client'
import * as React from 'react' import {
useState,
useMemo,
useCallback,
type ReactNode,
type HTMLAttributes,
type MouseEvent,
} from 'react'
import { ChevronDownIcon } from '@heroicons/react/20/solid' import { ChevronDownIcon } from '@heroicons/react/20/solid'
type Align = 'left' | 'center' | 'right' type Align = 'left' | 'center' | 'right'
@ -10,16 +19,16 @@ export type SortState = { key: string; direction: SortDirection } | null
export type Column<T> = { export type Column<T> = {
key: string key: string
header: React.ReactNode header: ReactNode
widthClassName?: string widthClassName?: string
align?: Align align?: Align
className?: string className?: string
headerClassName?: string headerClassName?: string
/** Wenn gesetzt: so wird die Zelle gerendert */ /** Wenn gesetzt: so wird die Zelle gerendert */
cell?: (row: T, rowIndex: number) => React.ReactNode cell?: (row: T, rowIndex: number) => ReactNode
/** Standard: row[col.key] */ /** Standard: row[col.key] */
accessor?: (row: T) => React.ReactNode accessor?: (row: T) => ReactNode
/** Optional: sr-only Header (z.B. für Action-Spalte) */ /** Optional: sr-only Header (z.B. für Action-Spalte) */
srOnlyHeader?: boolean srOnlyHeader?: boolean
@ -35,7 +44,7 @@ export type Column<T> = {
sortFn?: (a: T, b: T) => number sortFn?: (a: T, b: T) => number
} }
type TableRowAttrs = React.HTMLAttributes<HTMLTableRowElement> & { type TableRowAttrs = HTMLAttributes<HTMLTableRowElement> & {
[key: `data-${string}`]: string | number | undefined [key: `data-${string}`]: string | number | undefined
} }
@ -44,9 +53,9 @@ export type TableProps<T> = {
rows: T[] rows: T[]
getRowKey?: (row: T, index: number) => string getRowKey?: (row: T, index: number) => string
/** Optionaler Titelbereich über der Tabelle */ /** Optionaler Titelbereich über der Tabelle */
title?: React.ReactNode title?: ReactNode
description?: React.ReactNode description?: ReactNode
actions?: React.ReactNode actions?: ReactNode
/** Styling / Layout */ /** Styling / Layout */
fullWidth?: boolean fullWidth?: boolean
@ -57,16 +66,16 @@ export type TableProps<T> = {
/** States */ /** States */
isLoading?: boolean isLoading?: boolean
emptyLabel?: React.ReactNode emptyLabel?: ReactNode
className?: string className?: string
rowClassName?: (row: T, rowIndex: number) => string | undefined rowClassName?: (row: T, rowIndex: number) => string | undefined
onRowClick?: (row: T) => void onRowClick?: (row: T) => void
onRowContextMenu?: (row: T, e: React.MouseEvent<HTMLTableRowElement>) => void onRowContextMenu?: (row: T, e: MouseEvent<HTMLTableRowElement>) => void
onRowMouseEnter?: (row: T, e: React.MouseEvent<HTMLTableRowElement>) => void onRowMouseEnter?: (row: T, e: MouseEvent<HTMLTableRowElement>) => void
onRowMouseLeave?: (row: T, e: React.MouseEvent<HTMLTableRowElement>) => void onRowMouseLeave?: (row: T, e: MouseEvent<HTMLTableRowElement>) => void
rowAttrs?: (row: T, rowIndex: number) => TableRowAttrs rowAttrs?: (row: T, rowIndex: number) => TableRowAttrs
/** /**
@ -148,10 +157,10 @@ export default function Table<T>({
const headY = compact ? 'py-3' : 'py-3.5' const headY = compact ? 'py-3' : 'py-3.5'
const isControlled = sort !== undefined const isControlled = sort !== undefined
const [internalSort, setInternalSort] = React.useState<SortState>(defaultSort) const [internalSort, setInternalSort] = useState<SortState>(defaultSort)
const sortState = isControlled ? sort! : internalSort const sortState = isControlled ? sort! : internalSort
const setSortState = React.useCallback( const setSortState = useCallback(
(next: SortState) => { (next: SortState) => {
if (!isControlled) setInternalSort(next) if (!isControlled) setInternalSort(next)
onSortChange?.(next) onSortChange?.(next)
@ -159,7 +168,7 @@ export default function Table<T>({
[isControlled, onSortChange] [isControlled, onSortChange]
) )
const sortedRows = React.useMemo(() => { const sortedRows = useMemo(() => {
if (!sortState) return rows if (!sortState) return rows
const col = columns.find((c) => c.key === sortState.key) const col = columns.find((c) => c.key === sortState.key)
if (!col) return rows if (!col) return rows

View File

@ -2,11 +2,11 @@
'use client' 'use client'
import * as React from 'react' import { type ComponentType, type SVGProps } from 'react'
import { ChevronDownIcon } from '@heroicons/react/16/solid' import { ChevronDownIcon } from '@heroicons/react/16/solid'
import clsx from 'clsx' import clsx from 'clsx'
export type TabIcon = React.ComponentType<React.SVGProps<SVGSVGElement>> export type TabIcon = ComponentType<SVGProps<SVGSVGElement>>
export type TabItem = { export type TabItem = {
id: string id: string

View File

@ -2,13 +2,13 @@
'use client' 'use client'
import * as React from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import type { ReactNode, SyntheticEvent } from 'react'
type Props = { type Props = {
/** Entweder tag ODER children nutzen (tag ist praktisch, wenn du onClick nutzt). */ /** Entweder tag ODER children nutzen (tag ist praktisch, wenn du onClick nutzt). */
tag?: string tag?: string
children?: React.ReactNode children?: ReactNode
title?: string title?: string
@ -68,7 +68,7 @@ export default function TagBadge({
const cls = clsx(base, clickableCls, className) const cls = clsx(base, clickableCls, className)
const stop = (e: React.SyntheticEvent) => e.stopPropagation() const stop = (e: SyntheticEvent) => e.stopPropagation()
const commonHandlers = stopPropagation const commonHandlers = stopPropagation
? { ? {

View File

@ -2,7 +2,7 @@
'use client' 'use client'
import * as React from 'react' import { useEffect, useRef, useState, useMemo, useCallback, type KeyboardEvent } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { PlusIcon, XMarkIcon } from '@heroicons/react/24/outline' import { PlusIcon, XMarkIcon } from '@heroicons/react/24/outline'
import Button from './Button' import Button from './Button'
@ -31,42 +31,40 @@ export default function TagFilterBar({
emptyText = 'Keine passenden Tags gefunden.', emptyText = 'Keine passenden Tags gefunden.',
className = '', className = '',
}: TagFilterBarProps) { }: TagFilterBarProps) {
const [open, setOpen] = React.useState(false) const [open, setOpen] = useState(false)
const [query, setQuery] = React.useState('') const [query, setQuery] = useState('')
const rootRef = React.useRef<HTMLDivElement | null>(null) const rootRef = useRef<HTMLDivElement | null>(null)
const inputRef = React.useRef<HTMLInputElement | null>(null) const inputRef = useRef<HTMLInputElement | null>(null)
const activeTagSet = React.useMemo( const activeTagSet = useMemo(
() => new Set(activeTags.map(lower)), () => new Set(activeTags.map(lower)),
[activeTags] [activeTags]
) )
const addableTags = React.useMemo(() => { const addableTags = useMemo(() => {
return allTags.filter((tag) => !activeTagSet.has(lower(tag))) return allTags.filter((tag) => !activeTagSet.has(lower(tag)))
}, [allTags, activeTagSet]) }, [allTags, activeTagSet])
const normalizedQuery = React.useMemo(() => lower(query), [query]) const normalizedQuery = useMemo(() => lower(query), [query])
const filteredOptions = React.useMemo(() => { const filteredOptions = useMemo(() => {
if (!normalizedQuery) return addableTags.slice(0, 40) if (!normalizedQuery) return addableTags
return addableTags return addableTags.filter((tag) => lower(tag).includes(normalizedQuery))
.filter((tag) => lower(tag).includes(normalizedQuery))
.slice(0, 40)
}, [addableTags, normalizedQuery]) }, [addableTags, normalizedQuery])
const close = React.useCallback(() => { const close = useCallback(() => {
setOpen(false) setOpen(false)
setQuery('') setQuery('')
}, []) }, [])
const openPicker = React.useCallback(() => { const openPicker = useCallback(() => {
if (addableTags.length === 0) return if (addableTags.length === 0) return
setOpen(true) setOpen(true)
setQuery('') setQuery('')
}, [addableTags.length]) }, [addableTags.length])
const selectTag = React.useCallback( const selectTag = useCallback(
(tag: string) => { (tag: string) => {
onAddTag(tag) onAddTag(tag)
setQuery('') setQuery('')
@ -75,13 +73,13 @@ export default function TagFilterBar({
[onAddTag] [onAddTag]
) )
React.useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const t = window.setTimeout(() => inputRef.current?.focus(), 0) const t = window.setTimeout(() => inputRef.current?.focus(), 0)
return () => window.clearTimeout(t) return () => window.clearTimeout(t)
}, [open]) }, [open])
React.useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const onPointerDown = (e: MouseEvent) => { const onPointerDown = (e: MouseEvent) => {
@ -95,8 +93,8 @@ export default function TagFilterBar({
return () => document.removeEventListener('mousedown', onPointerDown) return () => document.removeEventListener('mousedown', onPointerDown)
}, [open, close]) }, [open, close])
const handleKeyDown = React.useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => { (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault() e.preventDefault()
close() close()

View File

@ -2,7 +2,8 @@
'use client' 'use client'
import * as React from 'react' import type { CSSProperties, SyntheticEvent } from 'react'
import { useEffect, useRef, useState, useMemo, useCallback, useLayoutEffect } from 'react'
import TagBadge from './TagBadge' import TagBadge from './TagBadge'
type Props = { type Props = {
@ -32,28 +33,28 @@ export default function TagOverflowRow({
expandMode = 'cover', expandMode = 'cover',
dropdownMaxHeightClassName = 'max-h-[40vh]', dropdownMaxHeightClassName = 'max-h-[40vh]',
}: Props) { }: Props) {
const [open, setOpen] = React.useState(false) const [open, setOpen] = useState(false)
// ✅ misst die komplette Zeile (Tags + Button) // ✅ misst die komplette Zeile (Tags + Button)
const rowWrapRef = React.useRef<HTMLDivElement | null>(null) const rowWrapRef = useRef<HTMLDivElement | null>(null)
// ✅ hidden “all tags” measurement // ✅ hidden “all tags” measurement
const measureRef = React.useRef<HTMLDivElement | null>(null) const measureRef = useRef<HTMLDivElement | null>(null)
// ✅ worst-case +X measurement // ✅ worst-case +X measurement
const plusMeasureRef = React.useRef<HTMLButtonElement | null>(null) const plusMeasureRef = useRef<HTMLButtonElement | null>(null)
const hostRef = React.useRef<HTMLDivElement | null>(null) const hostRef = useRef<HTMLDivElement | null>(null)
const [overlayStyle, setOverlayStyle] = React.useState<React.CSSProperties>({ const [overlayStyle, setOverlayStyle] = useState<CSSProperties>({
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
height: 0, height: 0,
}) })
React.useEffect(() => setOpen(false), [rowKey]) useEffect(() => setOpen(false), [rowKey])
React.useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false) if (e.key === 'Escape') setOpen(false)
@ -62,22 +63,22 @@ export default function TagOverflowRow({
return () => document.removeEventListener('keydown', onKeyDown) return () => document.removeEventListener('keydown', onKeyDown)
}, [open]) }, [open])
const cap = React.useMemo(() => { const cap = useMemo(() => {
return typeof maxVisible === 'number' && maxVisible > 0 ? maxVisible : Number.POSITIVE_INFINITY return typeof maxVisible === 'number' && maxVisible > 0 ? maxVisible : Number.POSITIVE_INFINITY
}, [maxVisible]) }, [maxVisible])
const sortedTags = React.useMemo(() => { const sortedTags = useMemo(() => {
return [...tags].sort((a, b) => return [...tags].sort((a, b) =>
a.localeCompare(b, undefined, { sensitivity: 'base' }) a.localeCompare(b, undefined, { sensitivity: 'base' })
) )
}, [tags]) }, [tags])
const [visibleCount, setVisibleCount] = React.useState<number>(() => { const [visibleCount, setVisibleCount] = useState<number>(() => {
const total = Math.min(sortedTags.length, cap) const total = Math.min(sortedTags.length, cap)
return Math.min(total, 6) // optional initial guess; recalc korrigiert direkt return Math.min(total, 6) // optional initial guess; recalc korrigiert direkt
}) })
const recalc = React.useCallback(() => { const recalc = useCallback(() => {
const rowWrap = rowWrapRef.current const rowWrap = rowWrapRef.current
const measure = measureRef.current const measure = measureRef.current
if (!rowWrap || !measure) return if (!rowWrap || !measure) return
@ -130,7 +131,7 @@ export default function TagOverflowRow({
setVisibleCount(Math.max(0, Math.min(count, totalTags))) setVisibleCount(Math.max(0, Math.min(count, totalTags)))
}, [sortedTags, cap, gapPx]) }, [sortedTags, cap, gapPx])
const recalcOverlay = React.useCallback(() => { const recalcOverlay = useCallback(() => {
const host = hostRef.current const host = hostRef.current
if (!host) return if (!host) return
@ -145,7 +146,7 @@ export default function TagOverflowRow({
}) })
}, []) }, [])
React.useLayoutEffect(() => { useLayoutEffect(() => {
if (!open || expandMode !== 'cover') return if (!open || expandMode !== 'cover') return
recalcOverlay() recalcOverlay()
@ -164,7 +165,7 @@ export default function TagOverflowRow({
return () => ro.disconnect() return () => ro.disconnect()
}, [open, expandMode, recalcOverlay]) }, [open, expandMode, recalcOverlay])
React.useLayoutEffect(() => { useLayoutEffect(() => {
if (!open) return if (!open) return
recalcOverlay() recalcOverlay()
@ -183,12 +184,12 @@ export default function TagOverflowRow({
return () => ro.disconnect() return () => ro.disconnect()
}, [open, recalcOverlay]) }, [open, recalcOverlay])
React.useLayoutEffect(() => { useLayoutEffect(() => {
recalc() recalc()
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [recalc, sortedTags.join('\u0000'), maxWidthClassName, cap]) }, [recalc, sortedTags.join('\u0000'), maxWidthClassName, cap])
React.useEffect(() => { useEffect(() => {
const el = rowWrapRef.current const el = rowWrapRef.current
if (!el) return if (!el) return
const ro = new ResizeObserver(() => recalc()) const ro = new ResizeObserver(() => recalc())
@ -199,7 +200,7 @@ export default function TagOverflowRow({
const totalTags = Math.min(sortedTags.length, cap) const totalTags = Math.min(sortedTags.length, cap)
const visibleTags = sortedTags.slice(0, Math.min(visibleCount, totalTags)) const visibleTags = sortedTags.slice(0, Math.min(visibleCount, totalTags))
const stop = (e: React.SyntheticEvent) => e.stopPropagation() const stop = (e: SyntheticEvent) => e.stopPropagation()
// ✅ Hinweis: auch wenn cap < tags.length, zählt "rest" nur gegen totalTags (capped) // ✅ Hinweis: auch wenn cap < tags.length, zählt "rest" nur gegen totalTags (capped)
// willst du immer gegen alle tags: rest = tags.length - visibleTags.length // willst du immer gegen alle tags: rest = tags.length - visibleTags.length

View File

@ -269,8 +269,6 @@ export default function Task({
} }
} }
const running = !!state?.running
return ( return (
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
@ -285,8 +283,8 @@ export default function Task({
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
<Button variant="primary" onClick={start} disabled={disabled || busy || starting || running}> <Button variant="primary" onClick={start} disabled={disabled || starting}>
{(starting || busy) ? startingLabel : startLabel} {starting ? startingLabel : startLabel}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -12,6 +12,7 @@ export type TaskItem = {
err?: string err?: string
cancellable?: boolean cancellable?: boolean
fading?: boolean fading?: boolean
queuedAtMs?: number
startedAtMs?: number startedAtMs?: number
} }
@ -31,8 +32,8 @@ export default function TaskList({ tasks, onCancel }: Props) {
const visible = [...(tasks || [])] const visible = [...(tasks || [])]
.filter((t) => t.status !== 'idle') .filter((t) => t.status !== 'idle')
.sort((a, b) => { .sort((a, b) => {
const aMs = Number(a.startedAtMs ?? 0) const aMs = Number(a.queuedAtMs ?? a.startedAtMs ?? 0)
const bMs = Number(b.startedAtMs ?? 0) const bMs = Number(b.queuedAtMs ?? b.startedAtMs ?? 0)
return aMs - bMs return aMs - bMs
}) })
@ -122,7 +123,11 @@ export default function TaskList({ tasks, onCancel }: Props) {
</div> </div>
</div> </div>
{t.status === 'done' ? ( {t.status === 'queued' ? (
<span className="shrink-0 inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-800 ring-1 ring-inset ring-amber-200 dark:bg-amber-500/20 dark:text-amber-200 dark:ring-amber-400/30">
Wartet
</span>
) : t.status === 'done' ? (
<span className="shrink-0 inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-semibold text-green-800 ring-1 ring-inset ring-green-200 dark:bg-green-500/20 dark:text-green-200 dark:ring-green-400/30"> <span className="shrink-0 inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-semibold text-green-800 ring-1 ring-inset ring-green-200 dark:bg-green-500/20 dark:text-green-200 dark:ring-green-400/30">
Fertig Fertig
</span> </span>

View File

@ -2,12 +2,18 @@
'use client' 'use client'
import * as React from 'react' import {
useRef,
useCallback,
forwardRef,
type InputHTMLAttributes,
type Ref,
} from 'react'
type TextInputSize = 'sm' | 'md' | 'lg' type TextInputSize = 'sm' | 'md' | 'lg'
export type TextInputProps = Omit< export type TextInputProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>, InputHTMLAttributes<HTMLInputElement>,
'size' 'size'
> & { > & {
size?: TextInputSize size?: TextInputSize
@ -28,16 +34,18 @@ const sizeClassName: Record<TextInputSize, string> = {
lg: 'rounded-lg px-4 py-3 text-base', lg: 'rounded-lg px-4 py-3 text-base',
} }
function assignRef<T>(ref: React.Ref<T> | undefined, value: T) { function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
if (!ref) return if (!ref) return
if (typeof ref === 'function') { if (typeof ref === 'function') {
ref(value) ref(value)
return return
} }
;(ref as React.MutableRefObject<T | null>).current = value
;(ref as { current: T | null }).current = value
} }
const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>( const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
( (
{ {
className = '', className = '',
@ -51,9 +59,9 @@ const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(
}, },
forwardedRef forwardedRef
) => { ) => {
const innerRef = React.useRef<HTMLInputElement | null>(null) const innerRef = useRef<HTMLInputElement | null>(null)
const setRefs = React.useCallback( const setRefs = useCallback(
(node: HTMLInputElement | null) => { (node: HTMLInputElement | null) => {
innerRef.current = node innerRef.current = node
assignRef(forwardedRef, node) assignRef(forwardedRef, node)
@ -61,7 +69,7 @@ const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(
[forwardedRef] [forwardedRef]
) )
const selectAll = React.useCallback(() => { const selectAll = useCallback(() => {
const el = innerRef.current const el = innerRef.current
if (!el) return if (!el) return
el.focus() el.focus()

View File

@ -2,7 +2,7 @@
'use client' 'use client'
import * as React from 'react' import { useEffect, useRef, useState, useMemo, useCallback, createContext, useContext, type CSSProperties, type ReactNode, type PointerEvent } from 'react'
import { Transition } from '@headlessui/react' import { Transition } from '@headlessui/react'
import { import {
CheckCircleIcon, CheckCircleIcon,
@ -47,7 +47,7 @@ type ToastContextValue = {
clear: () => void clear: () => void
} }
const ToastContext = React.createContext<ToastContextValue | null>(null) const ToastContext = createContext<ToastContextValue | null>(null)
const TOAST_LEAVE_MS = 220 const TOAST_LEAVE_MS = 220
const TOAST_SWIPE_DISMISS_PX = 96 const TOAST_SWIPE_DISMISS_PX = 96
@ -135,25 +135,25 @@ export function ToastProvider({
defaultDurationMs = 3500, defaultDurationMs = 3500,
position = 'bottom-right', position = 'bottom-right',
}: { }: {
children: React.ReactNode children: ReactNode
maxToasts?: number maxToasts?: number
defaultDurationMs?: number defaultDurationMs?: number
position?: 'bottom-right' | 'top-right' | 'bottom-left' | 'top-left' position?: 'bottom-right' | 'top-right' | 'bottom-left' | 'top-left'
}) { }) {
const [toasts, setToasts] = React.useState<ToastInternal[]>([]) const [toasts, setToasts] = useState<ToastInternal[]>([])
const [notificationsEnabled, setNotificationsEnabled] = React.useState(true) const [notificationsEnabled, setNotificationsEnabled] = useState(true)
const autoCloseTimersRef = React.useRef<Record<string, number>>({}) const autoCloseTimersRef = useRef<Record<string, number>>({})
const finalizeRemoveTimersRef = React.useRef<Record<string, number>>({}) const finalizeRemoveTimersRef = useRef<Record<string, number>>({})
const toastStartedAtRef = React.useRef<Record<string, number>>({}) const toastStartedAtRef = useRef<Record<string, number>>({})
const toastRemainingMsRef = React.useRef<Record<string, number>>({}) const toastRemainingMsRef = useRef<Record<string, number>>({})
const toastPausedRef = React.useRef<Record<string, boolean>>({}) const toastPausedRef = useRef<Record<string, boolean>>({})
const swipeStateRef = React.useRef<Record<string, SwipeState>>({}) const swipeStateRef = useRef<Record<string, SwipeState>>({})
const [, forceSwipeRender] = React.useState(0) const [, forceSwipeRender] = useState(0)
const clearTimersFor = React.useCallback((id: string) => { const clearTimersFor = useCallback((id: string) => {
const autoId = autoCloseTimersRef.current[id] const autoId = autoCloseTimersRef.current[id]
if (autoId) { if (autoId) {
window.clearTimeout(autoId) window.clearTimeout(autoId)
@ -172,7 +172,7 @@ export function ToastProvider({
delete swipeStateRef.current[id] delete swipeStateRef.current[id]
}, []) }, [])
const loadNotificationSetting = React.useCallback(async () => { const loadNotificationSetting = useCallback(async () => {
try { try {
const r = await fetch('/api/settings', { cache: 'no-store' }) const r = await fetch('/api/settings', { cache: 'no-store' })
if (!r.ok) return if (!r.ok) return
@ -183,7 +183,7 @@ export function ToastProvider({
} }
}, []) }, [])
React.useEffect(() => { useEffect(() => {
loadNotificationSetting() loadNotificationSetting()
const onUpdated = () => loadNotificationSetting() const onUpdated = () => loadNotificationSetting()
@ -191,7 +191,7 @@ export function ToastProvider({
return () => window.removeEventListener('recorder-settings-updated', onUpdated) return () => window.removeEventListener('recorder-settings-updated', onUpdated)
}, [loadNotificationSetting]) }, [loadNotificationSetting])
React.useEffect(() => { useEffect(() => {
if (!notificationsEnabled) { if (!notificationsEnabled) {
// Nur Fehler sichtbar lassen (animiert schließen) // Nur Fehler sichtbar lassen (animiert schließen)
setToasts((prev) => prev.map((t) => (t.type === 'error' ? t : { ...t, open: false }))) setToasts((prev) => prev.map((t) => (t.type === 'error' ? t : { ...t, open: false })))
@ -208,7 +208,7 @@ export function ToastProvider({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [notificationsEnabled]) }, [notificationsEnabled])
const remove = React.useCallback( const remove = useCallback(
(id: string) => { (id: string) => {
const cur = swipeStateRef.current[id] const cur = swipeStateRef.current[id]
if (cur) { if (cur) {
@ -232,7 +232,7 @@ export function ToastProvider({
[clearTimersFor] [clearTimersFor]
) )
const clear = React.useCallback(() => { const clear = useCallback(() => {
setToasts((prev) => prev.map((t) => ({ ...t, open: false }))) setToasts((prev) => prev.map((t) => ({ ...t, open: false })))
Object.keys(autoCloseTimersRef.current).forEach((id) => { Object.keys(autoCloseTimersRef.current).forEach((id) => {
@ -247,7 +247,7 @@ export function ToastProvider({
window.setTimeout(() => setToasts([]), TOAST_LEAVE_MS) window.setTimeout(() => setToasts([]), TOAST_LEAVE_MS)
}, []) }, [])
const startAutoCloseTimer = React.useCallback( const startAutoCloseTimer = useCallback(
(id: string, ms: number) => { (id: string, ms: number) => {
if (!ms || ms <= 0) return if (!ms || ms <= 0) return
@ -273,7 +273,7 @@ export function ToastProvider({
[remove] [remove]
) )
const push = React.useCallback( const push = useCallback(
(t: Omit<Toast, 'id'>) => { (t: Omit<Toast, 'id'>) => {
if (!notificationsEnabled && t.type !== 'error') return '' if (!notificationsEnabled && t.type !== 'error') return ''
@ -300,7 +300,7 @@ export function ToastProvider({
[defaultDurationMs, maxToasts, notificationsEnabled, clearTimersFor, startAutoCloseTimer] [defaultDurationMs, maxToasts, notificationsEnabled, clearTimersFor, startAutoCloseTimer]
) )
const pauseAutoCloseTimer = React.useCallback((id: string) => { const pauseAutoCloseTimer = useCallback((id: string) => {
if (toastPausedRef.current[id]) return if (toastPausedRef.current[id]) return
const timerId = autoCloseTimersRef.current[id] const timerId = autoCloseTimersRef.current[id]
@ -318,7 +318,7 @@ export function ToastProvider({
toastPausedRef.current[id] = true toastPausedRef.current[id] = true
}, []) }, [])
const resumeAutoCloseTimer = React.useCallback( const resumeAutoCloseTimer = useCallback(
(id: string) => { (id: string) => {
if (!toastPausedRef.current[id]) return if (!toastPausedRef.current[id]) return
@ -342,7 +342,7 @@ export function ToastProvider({
[remove] [remove]
) )
const isTouchLikePointer = (e: React.PointerEvent) => { const isTouchLikePointer = (e: PointerEvent) => {
return e.pointerType === 'touch' || e.pointerType === 'pen' return e.pointerType === 'touch' || e.pointerType === 'pen'
} }
@ -361,7 +361,7 @@ export function ToastProvider({
) )
} }
const handleToastPointerDown = React.useCallback((id: string, e: React.PointerEvent<HTMLDivElement>) => { const handleToastPointerDown = useCallback((id: string, e: PointerEvent<HTMLDivElement>) => {
if (!isTouchLikePointer(e)) return if (!isTouchLikePointer(e)) return
swipeStateRef.current[id] = { swipeStateRef.current[id] = {
@ -379,7 +379,7 @@ export function ToastProvider({
forceSwipeRender((n) => n + 1) forceSwipeRender((n) => n + 1)
}, [pauseAutoCloseTimer]) }, [pauseAutoCloseTimer])
const handleToastPointerMove = React.useCallback((id: string, e: React.PointerEvent<HTMLDivElement>) => { const handleToastPointerMove = useCallback((id: string, e: PointerEvent<HTMLDivElement>) => {
const s = swipeStateRef.current[id] const s = swipeStateRef.current[id]
if (!s) return if (!s) return
if (s.pointerId !== e.pointerId) return if (s.pointerId !== e.pointerId) return
@ -420,7 +420,7 @@ export function ToastProvider({
forceSwipeRender((n) => n + 1) forceSwipeRender((n) => n + 1)
}, []) }, [])
const handleToastPointerEnd = React.useCallback((id: string, e?: React.PointerEvent<HTMLDivElement>) => { const handleToastPointerEnd = useCallback((id: string, e?: PointerEvent<HTMLDivElement>) => {
const s = swipeStateRef.current[id] const s = swipeStateRef.current[id]
if (!s) return if (!s) return
if (e && s.pointerId !== e.pointerId) return if (e && s.pointerId !== e.pointerId) return
@ -446,7 +446,7 @@ export function ToastProvider({
forceSwipeRender((n) => n + 1) forceSwipeRender((n) => n + 1)
}, [remove, resumeAutoCloseTimer]) }, [remove, resumeAutoCloseTimer])
const handleToastPointerCancel = React.useCallback((id: string, e?: React.PointerEvent<HTMLDivElement>) => { const handleToastPointerCancel = useCallback((id: string, e?: PointerEvent<HTMLDivElement>) => {
const s = swipeStateRef.current[id] const s = swipeStateRef.current[id]
if (!s) return if (!s) return
if (e && s.pointerId !== e.pointerId) return if (e && s.pointerId !== e.pointerId) return
@ -465,7 +465,7 @@ export function ToastProvider({
forceSwipeRender((n) => n + 1) forceSwipeRender((n) => n + 1)
}, [resumeAutoCloseTimer]) }, [resumeAutoCloseTimer])
React.useEffect(() => { useEffect(() => {
return () => { return () => {
Object.values(autoCloseTimersRef.current).forEach((n) => window.clearTimeout(n)) Object.values(autoCloseTimersRef.current).forEach((n) => window.clearTimeout(n))
Object.values(finalizeRemoveTimersRef.current).forEach((n) => window.clearTimeout(n)) Object.values(finalizeRemoveTimersRef.current).forEach((n) => window.clearTimeout(n))
@ -473,7 +473,7 @@ export function ToastProvider({
} }
}, []) }, [])
const ctx = React.useMemo<ToastContextValue>(() => ({ push, remove, clear }), [push, remove, clear]) const ctx = useMemo<ToastContextValue>(() => ({ push, remove, clear }), [push, remove, clear])
const posCls = const posCls =
position === 'top-right' position === 'top-right'
@ -532,7 +532,7 @@ export function ToastProvider({
? Math.max(0.985, 1 - absDx / 1600) ? Math.max(0.985, 1 - absDx / 1600)
: 1 : 1
const swipeStyle: React.CSSProperties = { const swipeStyle: CSSProperties = {
transform: `translateX(${swipe.dx}px) scale(${dragScale})`, transform: `translateX(${swipe.dx}px) scale(${dragScale})`,
opacity: dragOpacity, opacity: dragOpacity,
transition: swipe.dragging transition: swipe.dragging
@ -720,7 +720,7 @@ export function ToastProvider({
} }
export function useToast() { export function useToast() {
const ctx = React.useContext(ToastContext) const ctx = useContext(ToastContext)
if (!ctx) throw new Error('useToast must be used within <ToastProvider>') if (!ctx) throw new Error('useToast must be used within <ToastProvider>')
return ctx return ctx
} }

File diff suppressed because it is too large Load Diff

View File

@ -34,3 +34,28 @@ export function formatResolution(r?: { w: number; h: number } | null): string {
// Fallback: z.B. 1600p (Ultrawide) oder 800p // Fallback: z.B. 1600p (Ultrawide) oder 800p
return `${p}p` return `${p}p`
} }
export function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '—'
const totalSec = Math.floor(ms / 1000)
const h = Math.floor(totalSec / 3600)
const m = Math.floor((totalSec % 3600) / 60)
const s = totalSec % 60
if (h > 0) return `${h}h ${m}m`
if (m > 0) return `${m}m ${s}s`
return `${s}s`
}
export function formatBytes(bytes?: number | null): string {
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
const digits = i === 0 ? 0 : v >= 100 ? 0 : v >= 10 ? 1 : 2
return `${v.toFixed(digits)} ${units[i]}`
}