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"
"sort"
"strings"
"syscall"
"time"
)
@ -268,6 +269,10 @@ func extractVideoFrameAt(ctx context.Context, outPath string, atSec float64) (im
"-y",
tmpPath,
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
if out, err := cmd.CombinedOutput(); err != nil {
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)
func assetIDFromVideoPath(videoPath string) string {
base := filepath.Base(strings.TrimSpace(videoPath))
if base == "" {
return ""
return canonicalAssetIDFromName(videoPath)
}
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)
return strings.TrimSpace(id)
p := strings.ToLower(filepath.ToSlash(clean))
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)
@ -93,6 +103,41 @@ func assetPathsForID(id string) (assetDir, thumbPath, previewPath, spritePath, m
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 {
durSec float64
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)
}
spriteTruth := regeneratePhaseTruthForVideo(videoPath, id)
spriteTruth := assetsTruthForVideo(videoPath)
if !spriteTruth.SpritesReady {
return fmt.Errorf("preview-sprite.jpg fehlt nach deferred generation: %s", id)
}
// 2) KI-Analyse
// 2) Analyse
analyzeReady, err := ensureAnalyzeForVideoCtx(ctx, videoPath, sourceURL, "nsfw")
if err != nil {
return fmt.Errorf("deferred analyze failed for %s: %w", videoPath, err)
}
analyzeTruth := regeneratePhaseTruthForVideo(videoPath, id)
analyzeTruth := assetsTruthForVideo(videoPath)
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
@ -845,7 +890,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string
return false
}
truth := finishedPhaseTruthForID(assetID)
truth := assetsTruthForVideo(outPath)
needMeta := !truth.MetaReady
needThumb := !truth.ThumbReady
@ -872,13 +917,20 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "queued", "Sprites warten", nil)
}
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{
Key: "enrich:" + assetID,
Added: time.Now(),
Key: "enrich:" + assetID,
Added: time.Now(),
SortBucket: sortBucket,
SortName: sortName,
Run: func(ctx context.Context) error {
if err := waitUntilEnrichMayRun(ctx); err != nil {
return err
}
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)
}
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

View File

@ -10,6 +10,7 @@ import (
"os/exec"
"path/filepath"
"strings"
"syscall"
)
const (
@ -29,9 +30,21 @@ func previewSpriteStepSeconds(durationSec float64) float64 {
return 5
}
step := durationSec / float64(previewSpriteFrameCount)
if step < 0.5 {
step = 0.5
// 80 Zellen = 79 Abstände.
// Wir ziehen ein kleines Stück vom Ende ab, damit ffmpeg
// 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
@ -97,7 +110,7 @@ func generatePreviewSpriteJPG(
// robustere Filterkette
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,"+
"pad=%d:%d:(ow-iw)/2:(oh-ih)/2:black,"+
"setsar=1,"+
@ -127,6 +140,10 @@ func generatePreviewSpriteJPG(
)
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var stderr bytes.Buffer
cmd.Stderr = &stderr

View File

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

View File

@ -1,114 +1,114 @@
{
"items": [
{
"modelKey": "sw33t_g1rl",
"url": "https://www.myfreecams.com/#Sw33t_g1rl",
"modelKey": "xdaisyx",
"url": "https://www.myfreecams.com/#xDaisyx",
"mode": "probe_retry",
"nextProbeAtMs": 1776172330458,
"nextProbeAtMs": 1777292591836,
"currentShow": "offline",
"source": "watched"
},
{
"modelKey": "angel_from_sky",
"url": "https://chaturbate.com/angel_from_sky/",
"modelKey": "wekittycluby",
"url": "https://chaturbate.com/wekittycluby/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/angel_from_sky.jpg",
"imageUrl": "https://thumb.live.mmcdn.com/ri/wekittycluby.jpg",
"source": "watched"
},
{
"modelKey": "vanicha_007",
"url": "https://chaturbate.com/vanicha_007/",
"modelKey": "la__melanie",
"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",
"currentShow": "hidden",
"imageUrl": "https://thumb.live.mmcdn.com/ri/vanicha_007.jpg",
"imageUrl": "https://thumb.live.mmcdn.com/ri/adrean_sofi.jpg",
"source": "watched"
},
{
"modelKey": "tomokosturgeon",
"url": "https://chaturbate.com/tomokosturgeon/",
"modelKey": "extasy_maya",
"url": "https://chaturbate.com/extasy_maya/",
"mode": "wait_public",
"currentShow": "hidden",
"imageUrl": "https://thumb.live.mmcdn.com/ri/tomokosturgeon.jpg",
"imageUrl": "https://thumb.live.mmcdn.com/ri/extasy_maya.jpg",
"source": "watched"
},
{
"modelKey": "tammieprotano",
"url": "https://chaturbate.com/tammieprotano/",
"modelKey": "rita__shy",
"url": "https://chaturbate.com/rita__shy/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/tammieprotano.jpg",
"imageUrl": "https://thumb.live.mmcdn.com/ri/rita__shy.jpg",
"source": "watched"
},
{
"modelKey": "troybailly",
"url": "https://chaturbate.com/troybailly/",
"modelKey": "shachatte",
"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",
"currentShow": "hidden",
"imageUrl": "https://thumb.live.mmcdn.com/ri/troybailly.jpg",
"imageUrl": "https://thumb.live.mmcdn.com/ri/phqqipi.jpg",
"source": "watched"
},
{
"modelKey": "vonen_ray",
"url": "https://chaturbate.com/vonen_ray/",
"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/",
"modelKey": "evewilder",
"url": "https://chaturbate.com/evewilder/",
"mode": "probe_retry",
"nextProbeAtMs": 1776172331673,
"nextProbeAtMs": 1777292606854,
"currentShow": "unknown",
"source": "watched"
}

View File

@ -4,6 +4,7 @@ go 1.25.3
require (
github.com/PuerkitoBio/goquery v1.11.0
github.com/getlantern/systray v1.2.2
github.com/google/uuid v1.6.0
github.com/grafov/m3u8 v0.12.1
github.com/jackc/pgx/v5 v5.8.0
@ -15,11 +16,19 @@ require (
require (
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-stack/stack v1.8.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // 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/shoenig/go-m1cpu v0.1.6 // 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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
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/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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
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/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/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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
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/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw=
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/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-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-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-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.1.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.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/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=
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/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

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

View File

@ -20,6 +20,7 @@ import (
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/grafov/m3u8"
@ -36,6 +37,10 @@ type Playlist struct {
Framerate int
}
var recordedAssetStemPrefixRe = regexp.MustCompile(
`^(.+?_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2})`,
)
const (
JobRunning JobStatus = "running"
JobPostwork JobStatus = "postwork" // ✅ NEU: Aufnahme vorbei, Nacharbeiten laufen noch
@ -130,6 +135,29 @@ type ffprobeInfo struct {
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) {
ev := map[string]any{
"type": "job_upsert",
@ -230,6 +258,10 @@ func probeVideoProps(ctx context.Context, filePath string) (w int, h int, fps fl
"-of", "json",
filePath,
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
out, err := cmd.Output()
if err != nil {
@ -447,6 +479,11 @@ func durationSecondsCached(ctx context.Context, path string) (float64, error) {
"-of", "default=noprint_wrappers=1:nokey=1",
path,
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
out, err := cmd.Output()
if err == nil {
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
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
text := string(b)
@ -569,21 +610,41 @@ func removeGeneratedForID(id string) {
return
}
id = stripHotPrefix(id)
if isAssetIDInUse(id) {
return
}
id = strings.TrimSuffix(id, filepath.Ext(id))
id = stripHotPrefix(id)
var err error
id, err = sanitizeID(id)
if err != nil || id == "" {
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) != "" {
_ = os.RemoveAll(filepath.Join(root, id))
removeDir(filepath.Join(root, id))
}
}
@ -766,6 +827,11 @@ func remuxTSToMP4(tsPath, mp4Path string) error {
"-movflags", "+faststart",
mp4Path,
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
@ -800,16 +866,35 @@ func remuxTSToMP4WithProgress(
onRatio func(r float64),
) error {
// ffmpeg progress kommt auf stdout als key=value
cmd := exec.CommandContext(ctx, ffmpegPath,
args := []string{
"-y",
"-nostats",
"-progress", "pipe:1",
"-hide_banner",
"-loglevel", "warning",
}
args = append(args, ffmpegInputTol...)
args = append(args,
"-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",
"-movflags", "+faststart",
mp4Path,
)
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
@ -996,6 +1081,10 @@ func ensureFastStartMP4(path string) error {
"-movflags", "+faststart",
tmp,
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
@ -1119,8 +1208,7 @@ func uniqueDestPath(dstDir, file string) (string, error) {
}
func idFromVideoPath(videoPath string) string {
name := filepath.Base(strings.TrimSpace(videoPath))
return strings.TrimSuffix(name, filepath.Ext(name))
return canonicalAssetIDFromName(videoPath)
}
func assetIDForJob(job *RecordJob) string {
@ -1521,3 +1609,109 @@ func fileExists(path string) bool {
_, err := os.Stat(path)
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"`
UpdatedAtUnix int64 `json:"updatedAtUnix"`
File videoFileMeta `json:"file"`
Media videoMediaMeta `json:"media"`
Preview previewMeta `json:"preview,omitempty"`
Analysis analysisMeta `json:"analysis,omitempty"`
Postwork postworkMeta `json:"postwork,omitempty"`
File videoFileMeta `json:"file"`
Media videoMediaMeta `json:"media"`
Preview previewMeta `json:"preview,omitempty"`
Analysis analysisMeta `json:"analysis,omitempty"`
Postwork postworkMeta `json:"postwork,omitempty"`
Validation map[string]any `json:"validation,omitempty"`
}
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.Analysis = existing.Analysis
m.Postwork = existing.Postwork
m.Validation = existing.Validation
if m.File.SourceURL == "" {
m.File.SourceURL = existing.File.SourceURL

View File

@ -10,22 +10,27 @@ import (
// Eine Nacharbeit (kann ffmpeg, ffprobe, thumbnails, rename, etc. enthalten)
type PostWorkTask struct {
Key string // z.B. Dateiname oder Job-ID, zum Deduplizieren
Run func(ctx context.Context) error
Added time.Time
Key string // z.B. Dateiname oder Job-ID, zum Deduplizieren
Run func(ctx context.Context) error
Added time.Time
SortBucket int // 0 = /done, 1 = /done/keep
SortName string // Dateiname lower-case
}
type PostWorkQueue struct {
q chan PostWorkTask
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
queued int // Anzahl inflight (queued + running)
// ✅ 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)
cancelByKey map[string]context.CancelFunc
}
@ -38,16 +43,71 @@ func NewPostWorkQueue(queueSize int, maxParallelFFmpeg int) *PostWorkQueue {
maxParallelFFmpeg = 1 // Default: "nacheinander"
}
return &PostWorkQueue{
q: make(chan PostWorkTask, queueSize),
pq := &PostWorkQueue{
ffmpegSem: make(chan struct{}, maxParallelFFmpeg),
maxQueued: queueSize,
queue: make([]PostWorkTask, 0, queueSize),
inflight: make(map[string]struct{}),
waitingKeys: make([]string, 0, queueSize),
runningKeys: make(map[string]struct{}),
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)
@ -55,31 +115,41 @@ func (pq *PostWorkQueue) Enqueue(task PostWorkTask) bool {
if task.Key == "" || task.Run == nil {
return false
}
if task.Added.IsZero() {
task.Added = time.Now()
}
pq.mu.Lock()
defer pq.mu.Unlock()
if _, ok := pq.inflight[task.Key]; ok {
pq.mu.Unlock()
return false // schon queued oder läuft
}
pq.inflight[task.Key] = struct{}{}
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()
if pq.maxQueued > 0 && len(pq.queue) >= pq.maxQueued {
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 {
@ -138,27 +208,30 @@ func (pq *PostWorkQueue) RemoveQueued(key string) bool {
pq.queued--
}
pq.queue = removeQueuedTaskLocked(pq.queue, key)
// Das Task-Element bleibt evtl. noch im Channel liegen.
// Beim Worker-Start muss es dann erkannt und übersprungen werden.
return true
}
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()
_, stillInflight := pq.inflight[task.Key]
_, alreadyRunning := pq.runningKeys[task.Key]
waitingFound := false
for _, k := range pq.waitingKeys {
if k == task.Key {
waitingFound = true
break
}
}
waitingFound := containsWaitingKey(pq.waitingKeys, task.Key)
pq.mu.Unlock()
// queued Job wurde zwischenzeitlich entfernt -> Task aus dem Channel verwerfen
// queued Job wurde zwischenzeitlich entfernt -> verwerfen
if !stillInflight || (!waitingFound && !alreadyRunning) {
continue
}
@ -188,7 +261,6 @@ func (pq *PostWorkQueue) workerLoop(id int) {
defer cancel()
// ffmpeg/heavy-slot holen
select {
case pq.ffmpegSem <- struct{}{}:
case <-ctx.Done():
@ -198,7 +270,6 @@ func (pq *PostWorkQueue) workerLoop(id int) {
<-pq.ffmpegSem
}()
// jetzt wirklich von waiting -> running
pq.mu.Lock()
pq.removeWaitingKeyLocked(task.Key)
pq.runningKeys[task.Key] = struct{}{}
@ -277,7 +348,7 @@ func (pq *PostWorkQueue) StatusForKey(key string) PostWorkKeyStatus {
}
// 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
// --- Status Refresher (ehemals postwork_refresh.go) ---
@ -320,6 +391,26 @@ func clearEnrichStatusSnapshot(key string) {
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() {
t := time.NewTicker(2 * time.Second)
go func() {

View File

@ -2,7 +2,6 @@
package main
import (
"bufio"
"bytes"
"context"
"crypto/sha1"
@ -18,7 +17,6 @@ import (
"image/jpeg"
"image/png"
"io"
"math"
"math/rand"
"net/http"
"net/url"
@ -32,6 +30,7 @@ import (
"strconv"
"strings"
"sync"
"syscall"
"time"
"golang.org/x/image/font"
@ -1194,6 +1193,10 @@ func extractLastFrameJPG(path string) ([]byte, error) {
"-f", "image2pipe",
"pipe:1",
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var out bytes.Buffer
var stderr bytes.Buffer
@ -1233,6 +1236,10 @@ func extractFrameAtTimeJPG(path string, seconds float64) ([]byte, error) {
"-f", "image2pipe",
"pipe:1",
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var out bytes.Buffer
var stderr bytes.Buffer
@ -1277,6 +1284,10 @@ func extractLastFrameJPGScaled(path string, width int, quality int) ([]byte, err
"-f", "image2pipe",
"pipe:1",
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var out bytes.Buffer
var stderr bytes.Buffer
@ -1312,6 +1323,10 @@ func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, er
"-f", "image2pipe",
"pipe:1",
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var out 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.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var out bytes.Buffer
var stderr bytes.Buffer
@ -2163,525 +2182,3 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
_ = atomicWriteFile(thumbPath, 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
}
// laufende/queued Nacharbeit abbrechen
cancelDeferredEnrichForFile(file)
// laufende/queued Nacharbeit abbrechen + Datei freigeben
releaseFileForMutation(file)
target, from, fi, err := resolveDoneFileByName(doneAbs, file)
if err != nil {
@ -391,7 +391,7 @@ func handleKeepMany(w http.ResponseWriter, r *http.Request) {
continue
}
cancelDeferredEnrichForFile(file)
releaseFileForMutation(file)
src, from, fi, err := resolveDoneFileByName(doneAbs, file)
if err != nil {
@ -932,9 +932,7 @@ func applyPreviewSpriteTruthToRecordJobMeta(j *RecordJob) {
if outPath == "" {
return
}
base := filepath.Base(outPath)
id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base)))
id = strings.TrimSpace(id)
id := canonicalAssetIDFromName(outPath)
if id == "" {
return
}
@ -1009,8 +1007,7 @@ func applyFinishedPhaseTruthToRecordJobMeta(j *RecordJob) {
return
}
base := filepath.Base(outPath)
id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base)))
id := canonicalAssetIDFromName(outPath)
id = strings.TrimSpace(id)
if id == "" {
return
@ -1348,8 +1345,7 @@ func recordRemoveQueuedPostwork(w http.ResponseWriter, r *http.Request) {
_ = removeWithRetry(out)
purgeDurationCacheForPath(out)
base := filepath.Base(out)
id1 := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base)))
id1 := canonicalAssetIDFromName(out)
if strings.TrimSpace(id1) != "" {
removeGeneratedForID(id1)
}
@ -1409,22 +1405,8 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
// ---- TS -> MP4 (on-demand remux) ----
if strings.ToLower(filepath.Ext(outPath)) == ".ts" {
newOut, err := maybeRemuxTS(outPath)
if err != nil {
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
}
writeErr(http.StatusConflict, "TS-Datei ist noch nicht nachbearbeitet und kann nicht direkt abgespielt werden")
return
}
// ✅ 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)
switch kind {
case "ts":
newOut, err := maybeRemuxTS(outPath)
if err != nil {
writeErr(http.StatusInternalServerError, "Datei ist TS (nur .mp4 benannt); Remux fehlgeschlagen: "+err.Error())
return
}
outPath = filepath.Clean(strings.TrimSpace(newOut))
writeErr(http.StatusConflict, "Datei hat .mp4-Endung, enthält aber TS-Daten und wird nicht on-demand remuxt")
return
case "html":
writeErr(http.StatusInternalServerError, "Server liefert HTML statt Video (Pfad/Lookup prüfen)")
return
@ -1592,6 +1570,30 @@ func normalizeQueryModel(raw string) string {
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)
func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
items := make([]doneIndexItem, 0, 2048)
@ -1611,6 +1613,10 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
}
name := filepath.Base(full)
if isTemporaryDoneVideoName(name) {
return
}
ext := strings.ToLower(filepath.Ext(name))
if ext != ".mp4" && ext != ".ts" {
return
@ -1625,7 +1631,17 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
start := t
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 {
mm, _ := strconv.Atoi(m[2])
dd, _ := strconv.Atoi(m[3])
@ -1671,7 +1687,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
ended := t
items = append(items, doneIndexItem{
job: &RecordJob{
ID: base,
ID: stableID,
Output: full,
SourceURL: srcURL,
Status: JobFinished,
@ -1692,9 +1708,10 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
if err != nil {
return
}
for _, e := range entries {
if e.IsDir() {
if strings.EqualFold(e.Name(), ".trash") {
if strings.EqualFold(e.Name(), ".trash") || strings.EqualFold(e.Name(), ".postwork_tmp") {
continue
}
if skipKeep && e.Name() == "keep" {
@ -1731,6 +1748,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
// done (ohne keep)
scanDir(doneAbs, true)
// keep
scanDir(filepath.Join(doneAbs, "keep"), false)
@ -1755,12 +1773,14 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
B := items[idx[b]]
ta, tb := A.endedAt, B.endedAt
switch sortMode {
case "completed_asc":
if !ta.Equal(tb) {
return ta.Before(tb)
}
return A.fileSort < B.fileSort
case "completed_desc":
if !ta.Equal(tb) {
return ta.After(tb)
@ -1775,6 +1795,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
return ta.After(tb)
}
return A.fileSort < B.fileSort
case "file_desc":
if 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 A.fileSort < B.fileSort
case "duration_desc":
da, okA := durationForSort(A)
db, okB := durationForSort(B)
@ -1819,6 +1841,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
return ta.After(tb)
}
return A.fileSort < B.fileSort
case "size_desc":
if 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 A.fileSort < B.fileSort
default:
if !ta.Equal(tb) {
return ta.After(tb)
@ -1844,6 +1868,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
"duration_asc", "duration_desc",
"size_asc", "size_desc",
}
for _, m := range modes {
sortedIdx["0|"+m] = mkSorted(false, 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))
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)
resp := doneMetaFileResp{File: filepath.Base(outPath)}
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(outPath), filepath.Ext(outPath)))
id := canonicalAssetIDFromName(outPath)
if strings.TrimSpace(id) != "" {
if mp, merr := generatedMetaFile(id); merr == nil && strings.TrimSpace(mp) != "" {
@ -2362,7 +2379,7 @@ func cancelDeferredEnrichForFile(file string) {
return
}
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)))
id := canonicalAssetIDFromName(file)
id = strings.TrimSpace(id)
if id == "" {
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) {
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
http.Error(w, "Nur POST oder DELETE erlaubt", http.StatusMethodNotAllowed)
@ -2428,8 +2467,7 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
return
}
// ✅ laufende/queued späte Nacharbeiten für dieses Asset vorher stoppen
cancelDeferredEnrichForFile(file)
releaseFileForMutation(file)
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
@ -2698,8 +2736,7 @@ func recordUnkeepVideo(w http.ResponseWriter, r *http.Request) {
return
}
// ✅ späte Nacharbeiten abbrechen, bevor die Datei aus keep zurück verschoben wird
cancelDeferredEnrichForFile(file)
releaseFileForMutation(file)
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
@ -2771,8 +2808,7 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
return
}
// ✅ späte Nacharbeiten abbrechen, bevor die Datei verschoben wird
cancelDeferredEnrichForFile(file)
releaseFileForMutation(file)
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
@ -2880,7 +2916,7 @@ func recordToggleHot(w http.ResponseWriter, r *http.Request) {
return
}
cancelDeferredEnrichForFile(file)
releaseFileForMutation(file)
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)

View File

@ -9,6 +9,8 @@ import (
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@ -95,9 +97,32 @@ func RecordStream(
const maxPlaylistRefreshes = 12
refreshes := 0
attempt := 0
useChunkMerge := strings.EqualFold(filepath.Ext(outputPath), ".ts")
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 {
return nil
}
@ -133,7 +158,6 @@ func RecordStream(
newStream, rerr := loadFreshHLS()
if rerr != nil {
select {
case <-ctx.Done():
return ctx.Err()

View File

@ -15,6 +15,7 @@ import (
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"github.com/grafov/m3u8"
@ -334,6 +335,62 @@ func buildFFmpegInputHeaders(httpCookie, userAgent, referer string) string {
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(
ctx context.Context,
stream *selectedHLSStream,
@ -439,6 +496,10 @@ func handleM3U8Mode(
}
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var stderr bytes.Buffer
cmd.Stdout = io.Discard

View File

@ -532,6 +532,27 @@ func requireNonEmptyRegularFile(path string, label string) error {
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(
ctx context.Context,
job *RecordJob,
@ -543,6 +564,9 @@ func runPrimaryPostworkPipeline(
return out, err
}
cleanupTSAfterMove := ""
removeTempOutOnMoveFailure := false
// 1) TS -> MP4 zuerst
if strings.EqualFold(filepath.Ext(out), ".ts") {
tsPath := out
@ -557,8 +581,12 @@ func runPrimaryPostworkPipeline(
}
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
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
@ -587,6 +615,7 @@ func runPrimaryPostworkPipeline(
},
)
if err != nil {
_ = removeWithRetry(mp4Path)
return tsPath, fmt.Errorf("ts remux failed: %w", err)
}
@ -595,20 +624,31 @@ func runPrimaryPostworkPipeline(
return tsPath, err
}
// best effort cleanup der alten TS
if err := removeWithRetry(tsPath); err != nil && !os.IsNotExist(err) {
fmt.Println("⚠️ ts cleanup after remux:", err)
}
purgeDurationCacheForPath(tsPath)
if err := validateVideoDecodesNearEnd(ctx, mp4Path); err != nil {
fmt.Println("⚠️ remux mp4 decode validation failed, fallback to reencode:", err)
_ = 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
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
@ -616,20 +656,42 @@ func runPrimaryPostworkPipeline(
setPhase("moving", 0)
}
fallbackOut := out
if cleanupTSAfterMove != "" {
fallbackOut = cleanupTSAfterMove
}
moved, err := moveToDoneDir(out)
if err != nil || strings.TrimSpace(moved) == "" {
if err != nil {
return out, fmt.Errorf("moveToDoneDir failed: %w", err)
if removeTempOutOnMoveFailure {
_ = 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)
if err := requireNonEmptyRegularFile(out, "moved output"); err != nil {
if cleanupTSAfterMove != "" {
_ = removeWithRetry(out)
purgeDurationCacheForPath(out)
return cleanupTSAfterMove, 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()
job.Output = out
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
}
truth := finishedPhaseTruthForID(assetID)
truth := assetsTruthForVideo(outPath)
// -------------------------------------------------
// 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 {
return cerr
}
if err != nil || !okMeta || !finishedPhaseTruthForID(assetID).MetaReady {
if err != nil || !okMeta || !assetsTruthForVideo(outPath).MetaReady {
if err == nil {
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)
truth = finishedPhaseTruthForID(assetID)
truth = assetsTruthForVideo(outPath)
}
if !truth.ThumbReady {
@ -1241,7 +1303,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
if cerr := abortIfCanceled(err, "postwork", "thumb"); cerr != nil {
return cerr
}
if err != nil || !finishedPhaseTruthForID(assetID).ThumbReady {
if err != nil || !assetsTruthForVideo(outPath).ThumbReady {
if err == nil {
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)
truth = finishedPhaseTruthForID(assetID)
truth = assetsTruthForVideo(outPath)
}
if !truth.TeaserReady {
@ -1260,7 +1322,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
if cerr := abortIfCanceled(err, "postwork", "teaser"); cerr != nil {
return cerr
}
if err != nil || !finishedPhaseTruthForID(assetID).TeaserReady {
if err != nil || !assetsTruthForVideo(outPath).TeaserReady {
if err == nil {
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)
truth = finishedPhaseTruthForID(assetID)
truth = assetsTruthForVideo(outPath)
}
// 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 {
return cerr
}
if err != nil || !finishedPhaseTruthForID(assetID).SpritesReady {
if err != nil || !assetsTruthForVideo(outPath).SpritesReady {
if err == nil {
err = fmt.Errorf("sprites konnten nicht erzeugt werden")
}
@ -1291,29 +1353,33 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
firstErr = err
}
} 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)
}
}
truth = finishedPhaseTruthForID(assetID)
truth = assetsTruthForVideo(outPath)
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")
if cerr := abortIfCanceled(err, "enrich", "analyze"); cerr != nil {
return cerr
}
if err != nil || !finishedPhaseTruthForID(assetID).AnalyzeReady {
if err != nil || !assetsTruthForVideo(outPath).AnalyzeReady {
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 {
firstErr = err
}
} 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",
"teaserAudio": 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=="
}

View File

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

View File

@ -4,21 +4,103 @@ package main
import (
"context"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"runtime"
"strings"
"sync"
"syscall"
"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 ---
func main() {
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()
postWorkQ.StartWorkers(2)
postWorkQ.StartWorkers(1)
enrichQ.StartWorkers(1)
startPostWorkStatusRefresher()
startEnrichStatusRefresher()
@ -86,25 +168,52 @@ func main() {
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)
signal.Notify(stopSig, os.Interrupt, syscall.SIGTERM)
go func() {
<-stopSig
fmt.Println("🛑 Beende Server...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
closeNSFW()
shutdown()
}()
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Println("❌ HTTP-Server Fehler:", err)
closeNSFW()
os.Exit(1)
}
serverErrCh := make(chan error, 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"`
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 string `json:"encryptedCookies"`
}
@ -69,6 +75,12 @@ var (
EnableNotifications: true,
GenerateAssetsMeta: true,
GenerateAssetsThumb: true,
GenerateAssetsTeaser: true,
GenerateAssetsSprites: false,
GenerateAssetsAnalyze: false,
EncryptedCookies: "",
}
settingsFile = "recorder_settings.json"
@ -223,6 +235,12 @@ type RecorderSettingsPublic struct {
TeaserAudio bool `json:"teaserAudio"`
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 {
@ -250,6 +268,12 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
TeaserAudio: s.TeaserAudio,
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.GenerateAssetsMeta = in.GenerateAssetsMeta
next.GenerateAssetsThumb = in.GenerateAssetsThumb
next.GenerateAssetsTeaser = in.GenerateAssetsTeaser
next.GenerateAssetsSprites = in.GenerateAssetsSprites
next.GenerateAssetsAnalyze = in.GenerateAssetsAnalyze
dbChanged :=
strings.TrimSpace(next.DatabaseURL) != strings.TrimSpace(current.DatabaseURL) ||
strings.TrimSpace(next.EncryptedDBPassword) != strings.TrimSpace(current.EncryptedDBPassword)

View File

@ -3,6 +3,7 @@
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
@ -14,16 +15,26 @@ import (
"sort"
"strconv"
"strings"
"syscall"
"time"
)
type splitVideoSegmentIn struct {
Start float64 `json:"start"`
End float64 `json:"end"`
Label string `json:"label,omitempty"`
}
type splitVideoRequest struct {
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]
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]
Segments []splitVideoSegmentIn `json:"segments"` // echte Segmente
DeleteOriginal bool `json:"deleteOriginal,omitempty"`
}
type splitVideoSegmentResponse struct {
Index int `json:"index"`
Label string `json:"label,omitempty"`
Start float64 `json:"start"`
End float64 `json:"end"`
Duration float64 `json:"duration"`
@ -38,6 +49,21 @@ type splitVideoResponse struct {
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) {
if !mustMethod(w, r, http.MethodPost) {
return
@ -59,6 +85,8 @@ func recordSplitVideo(w http.ResponseWriter, r *http.Request) {
return
}
stream := r.URL.Query().Get("stream") == "1"
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
@ -82,31 +110,43 @@ func recordSplitVideo(w http.ResponseWriter, r *http.Request) {
srcPath = filepath.Clean(srcPath)
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
durationSec, err := durationSecondsCached(ctx, srcPath)
probeCtx, probeCancel := context.WithTimeout(r.Context(), 20*time.Second)
durationSec, err := durationSecondsCached(probeCtx, srcPath)
probeCancel()
if err != nil || durationSec <= 0 {
http.Error(w, "videodauer konnte nicht ermittelt werden", http.StatusInternalServerError)
return
}
points, err := normalizeSplitPoints(req.Splits, durationSec)
if err != nil {
http.Error(w, "splits ungültig: "+err.Error(), http.StatusBadRequest)
return
var segments []normalizedSegment
if len(req.Segments) > 0 {
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 {
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
}
splitCtx, splitCancel := context.WithTimeout(r.Context(), splitTimeoutForSegments(len(segments)))
defer splitCancel()
outDir := filepath.Join(filepath.Dir(srcPath), "_split")
if err := os.MkdirAll(outDir, 0o755); err != nil {
@ -126,11 +166,78 @@ func recordSplitVideo(w http.ResponseWriter, r *http.Request) {
Source: srcPath,
}
for i, seg := range segments {
outName := fmt.Sprintf("%s__part_%02d%s", base, i+1, ext)
outPath := filepath.Join(outDir, outName)
var flusher http.Flusher
if stream {
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(
w,
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
}
resp.Segments = append(resp.Segments, splitVideoSegmentResponse{
segResp := splitVideoSegmentResponse{
Index: i + 1,
Label: seg.Label,
Start: seg.Start,
End: seg.End,
Duration: seg.Duration,
File: outName,
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()
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)
}
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 {
Label string
Start float64
End 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) {
if duration <= 0 {
return nil, fmt.Errorf("duration <= 0")
@ -218,7 +541,29 @@ func buildSplitSegments(points []float64, duration float64) []normalizedSegment
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) == "" {
return fmt.Errorf("srcPath leer")
}
@ -229,19 +574,27 @@ func splitSingleSegment(parentCtx context.Context, srcPath, outPath string, star
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(outPath)
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Minute)
timeout := splitSingleSegmentTimeout(durSec)
ctx, cancel := context.WithTimeout(parentCtx, timeout)
defer cancel()
// Re-Encode ist robuster/framesauberer als -c copy
formatName := "mp4"
if ext == ".ts" {
formatName = "mpegts"
}
args := []string{
"-y",
"-nostats",
"-progress", "pipe:1",
"-hide_banner",
"-loglevel", "error",
"-loglevel", "warning",
"-ss", formatFFSec(startSec),
"-i", srcPath,
"-t", formatFFSec(durSec),
@ -255,15 +608,93 @@ func splitSingleSegment(parentCtx context.Context, srcPath, outPath string, star
"-c:a", "aac",
"-b:a", "128k",
"-movflags", "+faststart",
"-f", formatName,
tmpPath,
}
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
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)
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())
if 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)
}
if onProgress != nil {
onProgress(1)
}
return nil
}

View File

@ -8,6 +8,7 @@ import (
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
@ -20,13 +21,14 @@ import (
type AssetsTaskState 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"`
Total int `json:"total"`
Done int `json:"done"`
GeneratedThumbs int `json:"generatedThumbs"`
GeneratedPreviews int `json:"generatedPreviews"`
Skipped int `json:"skipped"`
StartedAt time.Time `json:"startedAt"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
Error string `json:"error,omitempty"`
CurrentFile string `json:"currentFile,omitempty"`
CurrentQueue string `json:"currentQueue,omitempty"`
@ -35,10 +37,101 @@ type AssetsTaskState struct {
Text string `json:"text,omitempty"`
}
type assetsPhaseSelection struct {
Meta bool
Thumb bool
Teaser bool
Sprites bool
Analyze bool
}
var assetsTaskMu sync.Mutex
var assetsTaskState AssetsTaskState
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.
// notifyAssetsChanged() muss außerhalb des Locks passieren.
func updateAssetsState(fn func(st *AssetsTaskState)) AssetsTaskState {
@ -85,13 +178,14 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
assetsTaskState = AssetsTaskState{
Queued: true,
Running: false,
QueuedAt: time.Now(),
StartedAt: time.Time{},
FinishedAt: nil,
Total: 0,
Done: 0,
GeneratedThumbs: 0,
GeneratedPreviews: 0,
Skipped: 0,
StartedAt: time.Time{},
FinishedAt: nil,
Error: "",
CurrentFile: "",
CurrentQueue: "",
@ -99,6 +193,7 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
CurrentLabel: "",
Text: "Wartet…",
}
resetAssetsTaskFileControlLocked()
st := assetsTaskState
assetsTaskMu.Unlock()
@ -189,21 +284,117 @@ func publishAssetsTaskPhase(fileName string, queue string, phase string, state s
)
}
func clearAssetsTaskFinishedPostworkStates(fileName string) {
defs := []struct {
queue string
phase string
}{
{queue: "postwork", phase: "meta"},
{queue: "postwork", phase: "thumb"},
{queue: "postwork", phase: "teaser"},
{queue: "postwork", phase: "sprites"},
{queue: "enrich", phase: "analyze"},
func clearAssetsTaskFinishedPostworkStates(fileName string, sel assetsPhaseSelection) {
if sel.Meta {
publishAssetsTaskPhase(fileName, "postwork", "meta", "missing", "")
}
if sel.Thumb {
publishAssetsTaskPhase(fileName, "postwork", "thumb", "missing", "")
}
if sel.Teaser {
publishAssetsTaskPhase(fileName, "postwork", "teaser", "missing", "")
}
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 {
publishAssetsTaskPhase(fileName, def.queue, def.phase, "missing", "")
fi, err := os.Stat(path)
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) {
@ -237,67 +428,27 @@ func runGenerateMissingAssets(ctx context.Context) {
st.Error = err.Error()
}
})
}
abortIfCanceled := func(stepErr error, fileName string) bool {
if errors.Is(stepErr, context.Canceled) || errors.Is(stepErr, context.DeadlineExceeded) || ctx.Err() != nil {
if strings.TrimSpace(fileName) != "" {
clearAssetsTaskFinishedPostworkStates(fileName)
}
finishWithErr(context.Canceled)
return true
}
return false
assetsTaskMu.Lock()
resetAssetsTaskFileControlLocked()
assetsTaskMu.Unlock()
}
s := getSettings()
sel := selectedAssetsPhasesFromSettings()
if !sel.Any() {
finishWithErr(nil)
return
}
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil || strings.TrimSpace(doneAbs) == "" {
finishWithErr(fmt.Errorf("doneDir auflösung fehlgeschlagen: %v", err))
return
}
type item struct {
name string
path string
}
items := listAssetsDoneThenKeepByFilenameAsc(doneAbs)
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 {
name string
path string
@ -307,6 +458,7 @@ func runGenerateMissingAssets(ctx context.Context) {
candidates := make([]assetCandidate, 0, len(items))
skippedCount := 0
initialDone := 0
for _, it := range items {
if err := ctx.Err(); err != nil {
@ -317,30 +469,33 @@ func runGenerateMissingAssets(ctx context.Context) {
base := strings.TrimSuffix(it.name, filepath.Ext(it.name))
id := stripHotPrefix(base)
if strings.TrimSpace(id) == "" {
clearAssetsTaskFinishedPostworkStates(it.name)
clearAssetsTaskFinishedPostworkStates(it.name, sel)
skippedCount++
initialDone++
continue
}
vfi, verr := os.Stat(it.path)
if verr != nil || vfi == nil || vfi.IsDir() || vfi.Size() <= 0 {
clearAssetsTaskFinishedPostworkStates(it.name)
clearAssetsTaskFinishedPostworkStates(it.name, sel)
skippedCount++
initialDone++
continue
}
beforeTruth := finishedPhaseTruthForID(id)
beforeTruth := assetsTaskTruthForVideo(id, it.path)
needMeta := !beforeTruth.MetaReady
needThumb := !beforeTruth.ThumbReady
needTeaser := !beforeTruth.TeaserReady
needSprites := !beforeTruth.SpritesReady
needAnalyze := !beforeTruth.AnalyzeReady
needMeta := sel.Meta && !beforeTruth.MetaReady
needThumb := sel.Thumb && !beforeTruth.ThumbReady
needTeaser := sel.Teaser && !beforeTruth.TeaserReady
needSprites := sel.Sprites && !beforeTruth.SpritesReady
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 {
clearAssetsTaskFinishedPostworkStates(it.name)
clearAssetsTaskFinishedPostworkStates(it.name, sel)
skippedCount++
initialDone++
continue
}
@ -353,8 +508,8 @@ func runGenerateMissingAssets(ctx context.Context) {
}
updateAssetsState(func(st *AssetsTaskState) {
st.Total = len(candidates)
st.Done = 0
st.Total = len(items) // alle Dateien, inkl. keep + skips
st.Done = initialDone // vorhandene/ungültige direkt als erledigt zählen
st.GeneratedThumbs = 0
st.GeneratedPreviews = 0
st.Skipped = skippedCount
@ -363,42 +518,109 @@ func runGenerateMissingAssets(ctx context.Context) {
st.CurrentQueue = ""
st.CurrentPhase = ""
st.CurrentLabel = ""
st.Text = ""
})
if len(items) == 0 {
finishWithErr(nil)
return
}
if len(candidates) == 0 {
finishWithErr(nil)
return
}
for i, it := range candidates {
for _, it := range candidates {
if err := ctx.Err(); err != nil {
finishWithErr(err)
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) {
st.CurrentFile = it.name
})
if isAssetsTaskSkipRequested(it.name) || !fileExistsNonEmpty(it.path) {
skipCurrentFile()
continue
}
id := it.id
vfi, verr := os.Stat(it.path)
if verr != nil || vfi == nil || vfi.IsDir() || vfi.Size() <= 0 {
clearAssetsTaskFinishedPostworkStates(it.name)
clearAssetsTaskFinishedPostworkStates(it.name, sel)
updateAssetsState(func(st *AssetsTaskState) {
st.Done = i + 1
st.Done++
st.Skipped++
st.CurrentFile = ""
st.CurrentQueue = ""
st.CurrentPhase = ""
st.CurrentLabel = ""
})
finishFileControl()
continue
}
beforeTruth := it.beforeTruth
needMeta := !beforeTruth.MetaReady
needThumb := !beforeTruth.ThumbReady
needTeaser := !beforeTruth.TeaserReady
needSprites := !beforeTruth.SpritesReady
needAnalyze := !beforeTruth.AnalyzeReady
needMeta := sel.Meta && !beforeTruth.MetaReady
needThumb := sel.Thumb && !beforeTruth.ThumbReady
needTeaser := sel.Teaser && !beforeTruth.TeaserReady
needSprites := sel.Sprites && !beforeTruth.SpritesReady
needAnalyze := sel.Analyze && !beforeTruth.AnalyzeReady
// Pfade einmalig über zentralen Helper
_, _, _, _, metaPath, perr := assetPathsForID(id)
@ -416,13 +638,19 @@ func runGenerateMissingAssets(ctx context.Context) {
publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen")
}
if needAnalyze {
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen")
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen")
}
updateAssetsState(func(st *AssetsTaskState) {
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)
continue
}
@ -449,9 +677,12 @@ func runGenerateMissingAssets(ctx context.Context) {
publishAssetsTaskPhase(it.name, "postwork", "meta", "running", "Meta")
okMeta, merr := ensureMetaForVideoCtx(ctx, it.path, sourceURL)
if abortIfCanceled(merr, it.name) {
okMeta, merr := ensureMetaForVideoCtx(fileCtx, it.path, sourceURL)
if skipFile, stopAll := handleFileAbort(merr); stopAll {
return
} else if skipFile {
skipCurrentFile()
continue
}
if merr != nil || !okMeta {
publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen")
@ -473,14 +704,17 @@ func runGenerateMissingAssets(ctx context.Context) {
publishAssetsTaskPhase(it.name, "postwork", "thumb", "running", "Vorschaubild")
resThumb, terr := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, it.path, sourceURL, nil)
if abortIfCanceled(terr, it.name) {
thumbGeneratedNow, terr := ensureThumbForVideoCtx(fileCtx, it.path, sourceURL)
if skipFile, stopAll := handleFileAbort(terr); stopAll {
return
} else if skipFile {
skipCurrentFile()
continue
}
if terr != nil {
publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen")
} else if finishedPhaseTruthForID(id).ThumbReady {
if resThumb.ThumbGenerated {
} else if assetsTaskTruthForVideo(id, it.path).ThumbReady {
if thumbGeneratedNow {
thumbGenerated = true
}
publishAssetsTaskPhase(it.name, "postwork", "thumb", "done", "Vorschaubild")
@ -502,13 +736,16 @@ func runGenerateMissingAssets(ctx context.Context) {
publishAssetsTaskPhase(it.name, "postwork", "teaser", "running", "Teaser")
genTeaser, terr := ensureTeaserForVideoCtx(ctx, it.path, sourceURL)
if abortIfCanceled(terr, it.name) {
genTeaser, terr := ensureTeaserForVideoCtx(fileCtx, it.path, sourceURL)
if skipFile, stopAll := handleFileAbort(terr); stopAll {
return
} else if skipFile {
skipCurrentFile()
continue
}
if terr != nil {
publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen")
} else if finishedPhaseTruthForID(id).TeaserReady {
} else if assetsTaskTruthForVideo(id, it.path).TeaserReady {
if genTeaser {
previewGenerated = true
}
@ -531,13 +768,16 @@ func runGenerateMissingAssets(ctx context.Context) {
publishAssetsTaskPhase(it.name, "postwork", "sprites", "running", "Sprites")
_, serr := ensureSpritesForVideoCtx(ctx, it.path, sourceURL)
if abortIfCanceled(serr, it.name) {
_, serr := ensureSpritesForVideoCtx(fileCtx, it.path, sourceURL)
if skipFile, stopAll := handleFileAbort(serr); stopAll {
return
} else if skipFile {
skipCurrentFile()
continue
}
if serr != nil {
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")
} else {
publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen")
@ -552,21 +792,24 @@ func runGenerateMissingAssets(ctx context.Context) {
st.CurrentFile = it.name
st.CurrentQueue = "enrich"
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")
if abortIfCanceled(aerr, it.name) {
_, aerr := ensureAnalyzeForVideoCtx(fileCtx, it.path, sourceURL, "nsfw")
if skipFile, stopAll := handleFileAbort(aerr); stopAll {
return
} else if skipFile {
skipCurrentFile()
continue
}
if aerr != nil {
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen")
} else if finishedPhaseTruthForID(id).AnalyzeReady {
publishAssetsTaskPhase(it.name, "enrich", "analyze", "done", "KI-Analyse")
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen")
} else if assetsTaskTruthForVideo(id, it.path).AnalyzeReady {
publishAssetsTaskPhase(it.name, "enrich", "analyze", "done", "Analyse")
} 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 {
st.GeneratedPreviews++
}
st.Done = i + 1
st.Done++
st.CurrentFile = ""
st.CurrentQueue = ""
st.CurrentPhase = ""
st.CurrentLabel = ""
})
finishFileControl()
}
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
import (
"context"
"encoding/json"
"fmt"
"io/fs"
@ -61,7 +62,6 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
s := getSettings()
// doneDir auflösen
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil || strings.TrimSpace(doneAbs) == "" {
setCleanupTaskError("doneDir auflösung fehlgeschlagen")
@ -74,11 +74,8 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Threshold: standardmäßig aus Settings
mb := int(s.AutoDeleteSmallDownloadsBelowMB)
// optional Body-Override (wenn du es später brauchst)
// (Frontend sendet aktuell nichts; ist trotzdem safe)
var req cleanupReq
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&req)
@ -90,44 +87,62 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
mb = 0
}
// ✅ exklusiv warten: Cleanup startet erst, wenn andere exklusive Tasks fertig sind
if err := acquireExclusiveTask(r.Context()); err != nil {
http.Error(w, err.Error(), http.StatusRequestTimeout)
cleanupTaskMu.Lock()
if cleanupTaskState.Queued || cleanupTaskState.Running {
st := cleanupTaskState
cleanupTaskMu.Unlock()
writeJSON(w, http.StatusOK, st)
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 mb > 0 {
threshold := int64(mb) * 1024 * 1024
cleanupSmallFiles(doneAbs, threshold, &resp)
}
if err := acquireExclusiveTask(ctx); err != nil {
setCleanupTaskError(err.Error())
time.AfterFunc(7*time.Second, func() {
clearCleanupTaskState()
})
return
}
defer releaseExclusiveTask()
// ✅ Beim manuellen Aufräumen: Generated-GC synchron laufen lassen,
// 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)
setCleanupTaskRunning("Räume auf…")
// ✅ Generated-GC zur insgesamt freigegebenen Größe addieren
resp.DeletedBytes += gcStats.RemovedBytes
resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes)
resp := cleanupResp{}
setCleanupTaskDone(
cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes),
)
if mb > 0 {
threshold := int64(mb) * 1024 * 1024
cleanupSmallFiles(doneAbs, threshold, &resp)
}
time.AfterFunc(7*time.Second, func() {
clearCleanupTaskState()
})
gcStats := triggerGeneratedGarbageCollectorSync()
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 {
@ -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)
// Liefert Stats zurück, damit /api/settings/cleanup die Zahlen anzeigen kann.
func runGeneratedGarbageCollector() generatedGCStats {
@ -334,7 +410,7 @@ func runGeneratedGarbageCollector() generatedGCStats {
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)
_ = filepath.WalkDir(doneAbs, func(p string, d fs.DirEntry, err error) error {
@ -371,6 +447,11 @@ func runGeneratedGarbageCollector() generatedGCStats {
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.
// Sonst räumt der GC generated/meta/<id> weg, obwohl RecordStream dort noch schreibt.
jobsMu.RLock()

View File

@ -4,12 +4,88 @@ package main
import (
"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() {
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 {
@ -17,17 +93,41 @@ func acquireExclusiveTask(ctx context.Context) error {
ctx = context.Background()
}
req := &exclusiveTaskRequest{
id: exclusiveTaskNextID.Add(1),
granted: make(chan struct{}),
}
select {
case exclusiveTaskReqCh <- req:
case <-ctx.Done():
return ctx.Err()
case <-exclusiveTaskSem:
}
select {
case <-req.granted:
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() {
select {
case exclusiveTaskSem <- struct{}{}:
case exclusiveTaskReleaseCh <- struct{}{}:
default:
}
}

View File

@ -9,13 +9,12 @@ import (
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
const regenerateAssetsMaxParallel = 1
type regenerateAssetsRequest struct {
File string `json:"file"`
}
@ -47,10 +46,8 @@ type regenerateAssetsJob struct {
}
var (
regenerateAssetsMu sync.Mutex
regenerateAssetsJobs = map[string]*regenerateAssetsJob{} // key = file
regenerateAssetsQueue []string // file names
regenerateAssetsWorkersStarted bool
regenerateAssetsMu sync.Mutex
regenerateAssetsJobs = map[string]*regenerateAssetsJob{} // key = file
)
func publishFinishedPostworkPhase(
@ -152,56 +149,11 @@ func firstMissingDeferredRegeneratePhaseError(videoPath string, assetID string)
return errors.New("preview-sprite.jpg fehlt oder ist leer")
}
if !truth.AnalyzeReady {
return errors.New("KI-Analyse fehlt oder wurde nicht in meta.json gespeichert")
return errors.New("Analyse fehlt oder wurde nicht in meta.json gespeichert")
}
return nil
}
func ensureRegenerateAssetsWorkersStarted() {
regenerateAssetsMu.Lock()
defer regenerateAssetsMu.Unlock()
if regenerateAssetsWorkersStarted {
return
}
regenerateAssetsWorkersStarted = true
for i := 0; i < regenerateAssetsMaxParallel; i++ {
go regenerateAssetsWorker()
}
}
func enqueueRegenerateAssetsJob(job *regenerateAssetsJob) {
regenerateAssetsMu.Lock()
defer regenerateAssetsMu.Unlock()
regenerateAssetsJobs[job.File] = job
regenerateAssetsQueue = append(regenerateAssetsQueue, job.File)
}
func nextQueuedRegenerateAssetsJob() *regenerateAssetsJob {
regenerateAssetsMu.Lock()
defer regenerateAssetsMu.Unlock()
for len(regenerateAssetsQueue) > 0 {
file := regenerateAssetsQueue[0]
regenerateAssetsQueue = regenerateAssetsQueue[1:]
job := regenerateAssetsJobs[file]
if job == nil {
continue
}
if job.State != "queued" {
continue
}
return job
}
return nil
}
func markRegenerateAssetsJobRunning(file string) *regenerateAssetsJob {
regenerateAssetsMu.Lock()
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 {
regenerateAssetsMu.Lock()
defer regenerateAssetsMu.Unlock()
out := make([]map[string]any, 0, len(regenerateAssetsJobs))
jobs := make([]*regenerateAssetsJob, 0, len(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 {
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{
"queue": p.Queue,
"phase": p.Phase,
@ -288,12 +295,17 @@ func getRegenerateAssetsJobsSnapshot() []map[string]any {
}
out = append(out, map[string]any{
"file": job.File,
"assetId": job.AssetID,
"state": job.State,
"error": job.Error,
"phases": phases,
"enqueuedAt": job.EnqueuedAt.UnixMilli(),
"file": job.File,
"assetId": job.AssetID,
"state": job.State,
"error": job.Error,
"phases": phases,
"enqueuedAt": func() int64 {
if job.EnqueuedAt.IsZero() {
return 0
}
return job.EnqueuedAt.UnixMilli()
}(),
"startedAt": func() int64 {
if job.StartedAt.IsZero() {
return 0
@ -312,35 +324,6 @@ func getRegenerateAssetsJobsSnapshot() []map[string]any {
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) {
file := job.File
videoPath := job.VideoPath
@ -385,7 +368,7 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild", "")
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser", "")
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites", "")
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse", "")
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "Analyse", "")
// Alte Assets löschen
if id != "" {
@ -471,8 +454,8 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites", "")
// 5) KI-Analyse
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "KI-Analyse", "")
// 5) Analyse
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "Analyse", "")
if _, err := ensureAnalyzeForVideoCtx(ctx, videoPath, "", "nsfw"); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
@ -484,11 +467,11 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
}
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
}
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "KI-Analyse", "")
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "Analyse", "")
finishRegenerateAssetsJob(file, "done", "")
setRegenerateAssetsTaskDone(file, "Fertig")
@ -577,15 +560,30 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
return
}
ensureRegenerateAssetsWorkersStarted()
regenerateAssetsMu.Lock()
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()
w.Header().Set("Content-Type", "application/json")
phases := make([]map[string]any, 0, len(existing.Phases))
for _, p := range existing.Phases {
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{
"queue": p.Queue,
"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{
"ok": true,
"file": file,
"state": existing.State,
"error": existing.Error,
"state": state,
"error": errText,
"phases": phases,
})
return
@ -624,13 +623,39 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
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", "thumb", "queued", "Vorschaubild", "")
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser", "")
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites", "")
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse", "")
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")
_ = json.NewEncoder(w).Encode(map[string]any{

View File

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

View File

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

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

View File

@ -1,6 +1,6 @@
// 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 Button from './components/ui/Button'
import CookieModal from './components/ui/CookieModal'
@ -26,7 +26,6 @@ import {
} from '@heroicons/react/24/solid'
import PerformanceMonitor from './components/ui/PerformanceMonitor'
import { useNotify } from './components/ui/notify'
//import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller'
import CategoriesTab from './components/ui/CategoriesTab'
import LoginPage from './components/ui/LoginPage'
import VideoSplitModal from './components/ui/VideoSplitModal'
@ -125,6 +124,25 @@ type TaskStateEvent = {
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 = {
recordDir: 'records',
doneDir: 'records/done',
@ -602,6 +620,61 @@ function upsertJobById(list: RecordJob[], incoming: RecordJob): RecordJob[] {
}
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 [authed, setAuthed] = useState(false)
@ -3766,7 +3839,7 @@ export default function App() {
ref={sourceUrlInputRef}
value={sourceUrl}
size='sm'
onChange={(e) => setSourceUrl(e.target.value)}
onChange={(e: { target: { value: SetStateAction<string> } }) => setSourceUrl(e.target.value)}
selectAllOnFocus
selectAllOnMouseDown
placeholder="https://…"
@ -3890,6 +3963,7 @@ export default function App() {
setDonePage(1)
donePrefetchRef.current = null
}}
splitProgressByFile={splitProgressByFile}
/>
) : null}
@ -3952,13 +4026,268 @@ export default function App() {
setSplitModalOpen(false)
setSplitJob(null)
}}
onApply={async ({ job, splits, segments }) => {
console.log('VIDEO_SPLIT_APPLY', {
jobId: job.id,
output: job.output,
splits,
segments,
})
onApply={({ job, splits, segments, deleteOriginal }) => {
const file = baseName(job.output || '')
if (!file) {
throw new Error('Kein Dateiname vorhanden.')
}
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
import * as React from 'react'
import { type ButtonHTMLAttributes, type ReactNode } from 'react'
type Variant = 'primary' | 'secondary' | 'soft'
type Size = 'xs' | 'sm' | 'md' | 'lg'
type Color = 'indigo' | 'blue' | 'emerald' | 'red' | 'amber'
export type ButtonProps = Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
ButtonHTMLAttributes<HTMLButtonElement>,
'children'
> & {
children: React.ReactNode
children: ReactNode
variant?: Variant
size?: Size
color?: Color
rounded?: 'sm' | 'md' | 'full'
leadingIcon?: React.ReactNode
trailingIcon?: React.ReactNode
leadingIcon?: ReactNode
trailingIcon?: ReactNode
isLoading?: boolean
className?: string
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -150,8 +150,7 @@ export default function FinishedVideoPreview({
return m
}, [job])
// ✅ merge statt "meta ?? fetchedMeta"
// job.meta bleibt Basis, fetchedMeta ergänzt fehlende Felder (z.B. previewClips)
// meta aus job normalisiert; dient als Basis für Preview-/Teaser-Daten
const metaForPreview = meta
const [progressMountTick, setProgressMountTick] = useState(0)
@ -685,8 +684,8 @@ export default function FinishedVideoPreview({
const teaserSrc = useMemo(() => {
if (!previewId) return ''
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1`
}, [previewId])
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1&v=${v}`
}, [previewId, v])
// --- Inline Video sichtbar?
const showingInlineVideo =
@ -1403,7 +1402,7 @@ export default function FinishedVideoPreview({
{/* ✅ Teaser prewarm (lädt Datei/Metadata vor, bleibt unsichtbar) */}
{!showingInlineVideo && teaserCanPrewarm && !(teaserActive && animatedMode === 'teaser') ? (
<video
key={`teaser-prewarm-${previewId}`}
key={`teaser-prewarm-${previewId}-${v}`}
src={teaserSrc}
className="hidden"
muted
@ -1427,6 +1426,7 @@ export default function FinishedVideoPreview({
{/* ✅ Teaser MP4 */}
{!showingInlineVideo && teaserActive && animatedMode === 'teaser' ? (
<video
key={`teaser-${previewId}-${v}`}
ref={(el) => {
teaserMp4Ref.current = 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
'use client'
import * as React from 'react'
import { useId, type ReactNode } from 'react'
import clsx from 'clsx'
import Switch, { type SwitchProps } from './Switch'
type Props = Omit<SwitchProps, 'ariaLabelledby' | 'ariaDescribedby' | 'ariaLabel'> & {
label: React.ReactNode
description?: React.ReactNode
/** "left" = Label links / Switch rechts (wie Beispiel) */
label: ReactNode
description?: ReactNode
labelPosition?: 'left' | 'right'
/** Layout wrapper classes */
className?: string
}
@ -21,30 +18,50 @@ export default function LabeledSwitch({
labelPosition = 'left',
id,
className,
compact = false,
...switchProps
}: Props) {
const reactId = React.useId()
const reactId = useId()
const switchId = id ?? `sw-${reactId}`
const labelId = `${switchId}-label`
const descId = `${switchId}-desc`
if (labelPosition === 'right') {
// With right label Beispiel
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
{...switchProps}
compact={compact}
id={switchId}
ariaLabelledby={labelId}
ariaDescribedby={description ? descId : undefined}
/>
<div className="text-sm">
<label id={labelId} htmlFor={switchId} className="font-medium text-gray-900 dark:text-white">
<div className={compact ? 'text-xs' : 'text-sm'}>
<label
id={labelId}
htmlFor={switchId}
className={clsx(
'font-medium text-gray-900 dark:text-white',
compact ? 'leading-5' : ''
)}
>
{label}
</label>{' '}
{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}
</span>
) : null}
@ -53,15 +70,34 @@ export default function LabeledSwitch({
)
}
// With left label and description Beispiel
return (
<div className={clsx('flex items-center justify-between', className)}>
<span className="flex grow flex-col pr-2">
<label id={labelId} htmlFor={switchId} className="text-sm/6 font-medium text-gray-900 dark:text-white">
<div
className={clsx(
'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>
{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}
</span>
) : null}
@ -69,10 +105,11 @@ export default function LabeledSwitch({
<Switch
{...switchProps}
compact={compact}
id={switchId}
ariaLabelledby={labelId}
ariaDescribedby={description ? descId : undefined}
/>
</div>
)
}
}

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
// 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'
type Props = {
@ -170,7 +170,7 @@ export default function LoginPage({ onLoggedIn }: Props) {
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') {
const cur = codeDigits[i]
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 only = text.replace(/\D/g, '').slice(0, 6)
if (!only) return

View File

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

View File

@ -2,7 +2,7 @@
'use client'
import * as React from 'react'
import { type ComponentType, type ReactNode } from 'react'
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
@ -59,7 +59,7 @@ function BaseIcon({
children,
className,
}: {
children: React.ReactNode
children: ReactNode
className?: string
}) {
return (
@ -140,7 +140,7 @@ export default function ModelGenderIcon({
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 },
male: { label: 'Male', Icon: MaleIcon },
trans: { label: 'Trans', Icon: TransIcon },

View File

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

View File

@ -2,7 +2,16 @@
'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 Card from './Card'
import Button from './Button'
@ -197,8 +206,8 @@ function IconToggle({
title: string
active?: boolean
hiddenUntilHover?: boolean
onClick: (e: React.MouseEvent) => void
icon: React.ReactNode
onClick: (e: ReactMouseEvent<HTMLButtonElement>) => void
icon: ReactNode
}) {
return (
<span
@ -286,32 +295,32 @@ type ModelsTabProps = {
}
export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
const [models, setModels] = React.useState<StoredModel[]>([])
const flagsInFlightRef = React.useRef<Record<string, true>>({})
const [models, setModels] = useState<StoredModel[]>([])
const flagsInFlightRef = useRef<Record<string, true>>({})
const [loading, setLoading] = React.useState(false)
const [err, setErr] = React.useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [err, setErr] = useState<string | null>(null)
const [q, setQ] = React.useState('')
const [page, setPage] = React.useState(1)
const [q, setQ] = useState('')
const [page, setPage] = useState(1)
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 StatusFilterKey = 'favorite' | 'liked'
const [watchFilter, setWatchFilter] = React.useState<WatchFilterMode>('all')
const [statusFilter, setStatusFilter] = React.useState<StatusFilterKey[]>([])
const [watchFilter, setWatchFilter] = useState<WatchFilterMode>('all')
const [statusFilter, setStatusFilter] = useState<StatusFilterKey[]>([])
const activeStatusSet = React.useMemo(
const activeStatusSet = useMemo(
() => new Set(statusFilter),
[statusFilter]
)
const toggleStatusFilter = React.useCallback((key: StatusFilterKey) => {
const toggleStatusFilter = useCallback((key: StatusFilterKey) => {
setStatusFilter((prev) =>
prev.includes(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')
setStatusFilter([])
}, [])
const [videoCounts, setVideoCounts] = React.useState<Record<string, number>>({})
const [videoCountsLoading, setVideoCountsLoading] = React.useState(false)
const [videoCounts, setVideoCounts] = useState<Record<string, number>>({})
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',
direction: 'desc',
})
const refreshVideoCounts = React.useCallback(async () => {
const refreshVideoCounts = useCallback(async () => {
setVideoCountsLoading(true)
try {
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 out: string[] = []
@ -380,7 +389,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
return out
}, [models])
const toggleTagFilter = React.useCallback((tag: string) => {
const toggleTagFilter = useCallback((tag: string) => {
const k = tag.toLowerCase()
setTagFilter((prev) => {
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 e = ev as CustomEvent<{ tags?: string[] }>
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)
}, [])
React.useEffect(() => {
useEffect(() => {
try {
const raw = localStorage.getItem('models_pendingTags')
if (!raw) return
@ -413,7 +422,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
}
}, [])
React.useEffect(() => {
useEffect(() => {
try {
const saved = localStorage.getItem('models_viewMode')
if (saved === 'table' || saved === 'gallery') setViewMode(saved)
@ -422,7 +431,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
}
}, [])
React.useEffect(() => {
useEffect(() => {
try {
localStorage.setItem('models_viewMode', viewMode)
} catch {
@ -430,18 +439,18 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
}
}, [viewMode])
const [input, setInput] = React.useState('')
const [parsed, setParsed] = React.useState<ParsedModel | null>(null)
const [parseError, setParseError] = React.useState<string | null>(null)
const [adding, setAdding] = React.useState(false)
const [input, setInput] = useState('')
const [parsed, setParsed] = useState<ParsedModel | null>(null)
const [parseError, setParseError] = useState<string | null>(null)
const [adding, setAdding] = useState(false)
const [importOpen, setImportOpen] = React.useState(false)
const [importFile, setImportFile] = React.useState<File | null>(null)
const [importMsg, setImportMsg] = React.useState<string | null>(null)
const [importOpen, setImportOpen] = useState(false)
const [importFile, setImportFile] = useState<File | null>(null)
const [importMsg, setImportMsg] = useState<string | null>(null)
const [importKind, setImportKind] = React.useState<ImportKind>('favorite')
const [importing, setImporting] = React.useState(false)
const [importErr, setImportErr] = React.useState<string | null>(null)
const [importKind, setImportKind] = useState<ImportKind>('favorite')
const [importing, setImporting] = useState(false)
const [importErr, setImportErr] = useState<string | null>(null)
async function doImport() {
if (!importFile) return
@ -484,7 +493,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
setImportOpen(true)
}
const refresh = React.useCallback(async () => {
const refresh = useCallback(async () => {
setLoading(true)
setErr(null)
try {
@ -509,15 +518,15 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
}
}, [refreshVideoCounts])
React.useEffect(() => {
useEffect(() => {
void refreshVideoCounts()
}, [refreshVideoCounts])
React.useEffect(() => {
useEffect(() => {
void refresh()
}, [refresh])
React.useEffect(() => {
useEffect(() => {
const onChanged = (ev: Event) => {
const e = ev as CustomEvent<any>
const detail = e?.detail ?? {}
@ -547,7 +556,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
return () => window.removeEventListener('models-changed', onChanged as any)
}, [refresh])
React.useEffect(() => {
useEffect(() => {
const onDbChanged = () => {
setPage(1)
setModels([])
@ -558,7 +567,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
return () => window.removeEventListener('models-db-changed', onDbChanged as any)
}, [refresh])
React.useEffect(() => {
useEffect(() => {
const raw = input.trim()
if (!raw) {
setParsed(null)
@ -598,14 +607,14 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
return () => window.clearTimeout(t)
}, [input])
const modelsWithHay = React.useMemo(() => {
const modelsWithHay = useMemo(() => {
return models.map((m) => ({
m,
hay: `${m.modelKey} ${m.host ?? ''} ${m.input ?? ''} ${m.tags ?? ''}`.toLowerCase(),
}))
}, [models])
const deferredQ = React.useDeferredValue(q)
const deferredQ = useDeferredValue(q)
async function patch(id: string, body: any) {
setErr(null)
@ -674,7 +683,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
}
}
const columns = React.useMemo<Column<StoredModel>[]>(() => {
const columns = useMemo<Column<StoredModel>[]>(() => {
return [
{
key: 'statusAll',
@ -936,7 +945,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
]
}, [activeTagSet, toggleTagFilter, videoCounts, videoCountsLoading])
const filtered = React.useMemo(() => {
const filtered = useMemo(() => {
const needle = deferredQ.trim().toLowerCase()
let base = !needle
@ -976,7 +985,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
})
}, [models, modelsWithHay, deferredQ, watchFilter, activeStatusSet, activeTagSet])
const sortedAll = React.useMemo(() => {
const sortedAll = useMemo(() => {
if (!sort) return filtered
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)
}, [filtered, sort, columns])
React.useEffect(() => {
useEffect(() => {
setPage(1)
}, [q, tagFilter, watchFilter, statusFilter, viewMode, sort])
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)
}, [page, totalPages])
const pageRows = React.useMemo(() => {
const pageRows = useMemo(() => {
const start = (page - 1) * pageSize
return sortedAll.slice(start, start + pageSize)
}, [sortedAll, page, pageSize])

View File

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

View File

@ -1,6 +1,6 @@
// frontend\src\components\ui\PerformanceMonitor.tsx
import React from 'react'
import { useState, useEffect, useRef } from 'react'
import { subscribeSSE } from '../../lib/sseSingleton'
type Props = {
@ -64,9 +64,9 @@ function formatBytesCompact(bytes: number | null) {
}
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 last = performance.now()
let frames = 0
@ -129,18 +129,18 @@ export default function PerformanceMonitor({
}: Props) {
const fps = useFps(1000)
const [ping, setPing] = React.useState<number | null>(null)
const [cpu, setCpu] = React.useState<number | null>(null)
const [diskFreeBytes, setDiskFreeBytes] = React.useState<number | null>(null)
const [diskTotalBytes, setDiskTotalBytes] = React.useState<number | null>(null)
const [diskUsedPercent, setDiskUsedPercent] = React.useState<number | null>(null)
const [ping, setPing] = useState<number | null>(null)
const [cpu, setCpu] = useState<number | null>(null)
const [diskFreeBytes, setDiskFreeBytes] = useState<number | null>(null)
const [diskTotalBytes, setDiskTotalBytes] = useState<number | null>(null)
const [diskUsedPercent, setDiskUsedPercent] = useState<number | null>(null)
const LOW_FREE_BYTES = 5 * 1024 * 1024 * 1024 // 5 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 unsub = subscribeSSE<any>(url, 'perf', (data) => {

View File

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

View File

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

View File

@ -1,13 +1,13 @@
'use client'
import * as React from 'react'
import { type ReactNode } from 'react'
type ProgressBarProps = {
label?: React.ReactNode
label?: ReactNode
value?: number | null // 0..100
indeterminate?: boolean // wenn true -> “läuft…” ohne Prozent
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
currentStep?: number // 0-basiert
size?: 'sm' | 'md'

View File

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

View File

@ -1,7 +1,7 @@
// frontend\src\components\ui\RecorderSettings.tsx
'use client'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import Button from './Button'
import Card from './Card'
import LabeledSwitch from './LabeledSwitch'
@ -29,6 +29,12 @@ type RecorderSettings = {
teaserAudio?: boolean
lowDiskPauseBelowGB?: number
enableNotifications?: boolean
generateAssetsMeta?: boolean
generateAssetsThumb?: boolean
generateAssetsTeaser?: boolean
generateAssetsSprites?: boolean
generateAssetsAnalyze?: boolean
}
type DiskStatus = {
@ -59,12 +65,40 @@ const DEFAULTS: RecorderSettings = {
teaserAudio: false,
lowDiskPauseBelowGB: 5,
enableNotifications: true,
generateAssetsMeta: true,
generateAssetsThumb: true,
generateAssetsTeaser: true,
generateAssetsSprites: true,
generateAssetsAnalyze: true,
}
type Props = {
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) {
const s = String(name ?? '').trim()
if (!s) return ''
@ -94,7 +128,7 @@ function assetsPhaseLabel(label?: string, phase?: string) {
case 'sprites':
return 'Sprites'
case 'analyze':
return 'KI-Analyse'
return 'Analyse'
default:
return ''
}
@ -109,12 +143,21 @@ function assetsTaskText(file?: string, label?: string, phase?: string) {
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) {
const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
const [saving, setSaving] = useState(false)
const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState<number>(0)
const saveSuccessTimerRef = useRef<number | null>(null)
const [cleaning, setCleaning] = useState(false)
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
const [msg, setMsg] = useState<string | null>(null)
const [err, setErr] = useState<string | null>(null)
@ -171,6 +214,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
text: '',
cancellable: true,
fading: false,
queuedAtMs: 0,
startedAtMs: 0,
})
@ -181,6 +225,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
text: '',
cancellable: true,
fading: false,
queuedAtMs: 0,
startedAtMs: 0,
})
@ -190,6 +235,20 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const uiPauseGB = diskStatus?.pauseGB ?? pauseGB
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(() => {
return () => {
if (saveSuccessTimerRef.current != null) {
@ -232,6 +291,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
teaserAudio: (data as any).teaserAudio ?? DEFAULTS.teaserAudio,
lowDiskPauseBelowGB: (data as any).lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB,
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())
})
@ -258,11 +322,13 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const assetsFile = String(assets?.currentFile || assets?.text || '').trim()
const assetsLabel = String(assets?.currentLabel || '').trim()
const assetsPhase = String(assets?.currentPhase || '').trim()
const assetsQueuedAtMs = parseTaskTimeMs(assets?.queuedAt)
const assetsStartedAtMs = parseTaskTimeMs(assets?.startedAt)
if (assets?.queued) {
setAssetsTask((t) => ({
...t,
status: 'running',
status: 'queued',
title: 'Assets generieren',
text: String(assets.text || 'Wartet…'),
done: Number(assets.done ?? 0),
@ -270,6 +336,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
err: undefined,
cancellable: false,
fading: false,
queuedAtMs: t.queuedAtMs || assetsQueuedAtMs || Date.now(),
startedAtMs: assetsStartedAtMs || t.startedAtMs || 0,
}))
} else if (assets?.running) {
setAssetsTask((t) => ({
@ -282,6 +350,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
err: undefined,
cancellable: true,
fading: false,
queuedAtMs: t.queuedAtMs || assetsQueuedAtMs || Date.now(),
startedAtMs: assetsStartedAtMs || t.startedAtMs || Date.now(),
}))
} else if (assets?.error) {
const assetsError = String(assets.error || '').trim()
@ -310,6 +380,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
err: cancelled ? undefined : assetsError,
cancellable: false,
fading: t.fading ?? false,
queuedAtMs: t.queuedAtMs || assetsQueuedAtMs || 0,
startedAtMs: assetsStartedAtMs || t.startedAtMs || 0,
}
})
} else if (assets?.finishedAt) {
@ -323,6 +395,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
err: undefined,
cancellable: 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
: []
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) => {
const nextById = new Map(nextTasks.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[] = []
for (const next of nextTasks) {
@ -421,7 +524,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
for (const prevTask of prev) {
if (nextById.has(prevTask.id)) continue
if (prevTask.status === 'running') continue
if (prevTask.status === 'running' || prevTask.status === 'queued') continue
if (prevTask.fading) {
merged.push(prevTask)
continue
@ -429,24 +533,146 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
merged.push({ ...prevTask, fading: true })
const file = prevTask.id.startsWith('regenerate-assets:')
? prevTask.id.slice('regenerate-assets:'.length)
: ''
if (file) {
window.setTimeout(() => {
setRegenerateAssetsTasks((cur) => cur.filter((t) => t.id !== prevTask.id))
}, 500)
}
window.setTimeout(() => {
setRegenerateAssetsTasks((cur) => cur.filter((t) => t.id !== prevTask.id))
}, 500)
}
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 cleanupQueuedAtMs = parseTaskTimeMs(cleanup?.queuedAt)
const cleanupStartedAtMs = parseTaskTimeMs(cleanup?.startedAt)
if (cleanup?.running) {
setCleaning(true)
if (cleanup?.queued) {
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) => ({
...t,
status: 'running',
@ -457,9 +683,10 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
err: undefined,
cancellable: false,
fading: false,
queuedAtMs: t.queuedAtMs || cleanupQueuedAtMs || Date.now(),
startedAtMs: cleanupStartedAtMs || t.startedAtMs || Date.now(),
}))
} else if (cleanup?.error) {
setCleaning(false)
setCleanupTask((t) => ({
...t,
status: 'error',
@ -470,9 +697,10 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
err: String(cleanup.error),
cancellable: false,
fading: false,
queuedAtMs: t.queuedAtMs || cleanupQueuedAtMs || 0,
startedAtMs: cleanupStartedAtMs || t.startedAtMs || 0,
}))
} else if (cleanup?.finishedAt || cleanup?.text) {
setCleaning(false)
} else if (cleanup?.finishedAt) {
setCleanupTask((t) => ({
...t,
status: 'done',
@ -483,9 +711,10 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
err: undefined,
cancellable: false,
fading: false,
queuedAtMs: t.queuedAtMs || cleanupQueuedAtMs || 0,
startedAtMs: cleanupStartedAtMs || t.startedAtMs || 0,
}))
} else {
setCleaning(false)
setCleanupTask((t) => ({
...t,
status: 'idle',
@ -496,6 +725,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
err: undefined,
cancellable: true,
fading: false,
queuedAtMs: 0,
startedAtMs: 0,
}))
}
} catch {
@ -601,6 +832,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const teaserAudio = !!value.teaserAudio
const lowDiskPauseBelowGB = Math.max(1, Math.floor(Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB)))
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)
try {
@ -625,13 +861,17 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
teaserAudio,
lowDiskPauseBelowGB,
enableNotifications,
generateAssetsMeta,
generateAssetsThumb,
generateAssetsTeaser,
generateAssetsSprites,
generateAssetsAnalyze,
}),
})
if (!res.ok) {
const t = await res.text().catch(() => '')
throw new Error(t || `HTTP ${res.status}`)
}
setMsg('✅ Gespeichert.')
// Button-Label: "Gespeichert" für 2.5s
const until = Date.now() + 2500
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(() => {
setter((t) => ({ ...t, fading: true }))
window.setTimeout(() => {
@ -681,6 +921,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
done: 0,
total: 0,
fading: false,
queuedAtMs: 0,
startedAtMs: 0,
}))
}, fadeMs)
@ -703,7 +944,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
async function cleanupSmallDone() {
setErr(null)
setMsg(null)
const mb = Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB ?? 0)
const doneDir = (value.doneDir || DEFAULTS.doneDir).trim()
@ -725,18 +965,20 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
)
if (!ok) return
// ✅ Task starten (als letzter Eintrag in TaskList)
setCleaning(true)
// Sofort in TaskList sichtbar machen
const now = Date.now()
setCleanupTask((t) => ({
...t,
status: 'running',
status: 'queued',
title: 'Aufräumen',
text: 'Räume auf…',
text: 'Wartet…',
err: undefined,
done: 0,
total: 0,
fading: false,
startedAtMs: t.startedAtMs || Date.now(),
queuedAtMs: t.queuedAtMs || now,
startedAtMs: 0,
}))
try {
@ -745,27 +987,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
})
if (!res.ok) {
const t = await res.text().catch(() => '')
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) {
const msg = e?.message ?? String(e)
setErr(msg)
@ -773,13 +999,14 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
setCleanupTask((t) => ({
...t,
status: 'error',
text: 'Fehler beim Aufräumen.',
title: 'Aufräumen',
text: 'Fehler beim Starten des Aufräumens.',
err: msg,
cancellable: false,
fading: false,
}))
fadeOutTask(setCleanupTask, 7000, 500)
} finally {
setCleaning(false)
}
}
@ -807,6 +1034,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
total: 0,
cancellable: true,
fading: false,
queuedAtMs: 0,
startedAtMs: 0,
}))
}, 500)
@ -860,7 +1088,50 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
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'
)
@ -908,6 +1179,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
void cancelRegenerateAssetsTask(file)
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">
<Task
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"
startingLabel="Starte…"
startUrl="/api/tasks/generate-assets"
@ -960,7 +1236,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
err: undefined,
cancellable: true,
fading: false,
startedAtMs: t.startedAtMs || Date.now(),
queuedAtMs: t.queuedAtMs || Date.now(),
startedAtMs: 0,
}))
}}
onStart={(ac) => {
@ -976,7 +1253,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
err: undefined,
cancellable: true,
fading: false,
startedAtMs: t.startedAtMs || Date.now(),
queuedAtMs: t.queuedAtMs || Date.now(),
startedAtMs: Date.now(),
}))
}}
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
title="Aufräumen"
description='Löscht Dateien im doneDir kleiner als die Mindestgröße (Ordner "keep" wird übersprungen) und entfernt verwaiste Assets.'
startLabel="Aufräumen"
startingLabel="Läuft…"
onTrigger={cleanupSmallDone}
busy={cleaning}
disabled={saving}
onError={(message) => {
// Optional: zusätzlicher Fallback für Startfehler-Anzeige direkt im Task

View File

@ -2,7 +2,7 @@
'use client'
import * as React from 'react'
import { useId, type SyntheticEvent, type CSSProperties } from 'react'
type Props = {
id?: string
@ -65,7 +65,7 @@ export default function Slider({
className = '',
inputClassName = '',
}: Props) {
const reactId = React.useId()
const reactId = useId()
const inputId = id ?? `slider-${reactId}`
const safeValue = Number.isFinite(value) ? value : min
@ -95,7 +95,7 @@ export default function Slider({
onChange(clamp(next, min, max))
}
const handleCommit = (e: React.SyntheticEvent<HTMLInputElement>) => {
const handleCommit = (e: SyntheticEvent<HTMLInputElement>) => {
onChangeEnd?.(clamp(Number(e.currentTarget.value), min, max))
}
@ -198,7 +198,7 @@ export default function Slider({
['--slider-track-h' as any]: `${trackH}px`,
['--slider-thumb-size' as any]: `${thumbSize}px`,
['--slider-thumb-offset' as any]: `${thumbOffset}px`,
} as React.CSSProperties
} as CSSProperties
}
onChange={(e) => handleChange(Number(e.target.value))}
onPointerUp={handleCommit}
@ -389,7 +389,7 @@ export default function Slider({
['--slider-track-h' as any]: `${trackH}px`,
['--slider-thumb-size' as any]: `${thumbSize}px`,
['--slider-thumb-offset' as any]: `${thumbOffset}px`,
} as React.CSSProperties
} as CSSProperties
}
onChange={(e) => handleChange(Number(e.target.value))}
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'
import { useCallback, useRef, useState } from 'react'
import {
useState,
useRef,
useCallback,
type ReactNode,
type PointerEvent,
type MouseEvent as ReactMouseEvent,
} from 'react'
type Props = {
children: React.ReactNode
children: ReactNode
actionLabel: string
actionColor?: 'red' | 'indigo'
disabled?: boolean
@ -89,7 +95,7 @@ export default function SwipeActionRow({
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 (e.pointerType === 'mouse') return
@ -111,7 +117,7 @@ export default function SwipeActionRow({
}
}, [disabled])
const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const onPointerMove = useCallback((e: PointerEvent<HTMLDivElement>) => {
if (!dragging) return
if (pointerIdRef.current !== e.pointerId) return
@ -129,7 +135,7 @@ export default function SwipeActionRow({
setDragX(next)
}, [dragging, applyDragResistance])
const onPointerEnd = useCallback(async (e: React.PointerEvent<HTMLDivElement>) => {
const onPointerEnd = useCallback(async (e: PointerEvent<HTMLDivElement>) => {
if (!dragging) return
if (pointerIdRef.current !== e.pointerId) return
@ -167,9 +173,9 @@ export default function SwipeActionRow({
setDragX(0)
}, [dragging, dragX, getRevealWidth, runAction])
const onActionClick = useCallback(async (e: React.MouseEvent) => {
const onActionClick = useCallback((e: ReactMouseEvent<HTMLDivElement>) => {
e.stopPropagation()
await runAction()
void runAction()
}, [runAction])
const revealedPx = Math.max(0, Math.abs(dragX))

View File

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

View File

@ -1,7 +1,7 @@
// components/ui/Switch.tsx
'use client'
import * as React from 'react'
import type { ChangeEvent } from 'react'
import clsx from 'clsx'
type SwitchSize = 'default' | 'short'
@ -22,6 +22,7 @@ export type SwitchProps = {
size?: SwitchSize
variant?: SwitchVariant
compact?: boolean
className?: string
}
@ -37,9 +38,10 @@ export default function Switch({
ariaDescribedby,
size = 'default',
variant = 'simple',
compact = false,
className,
}: SwitchProps) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
if (disabled) return
onChange(e.target.checked)
}
@ -49,16 +51,25 @@ export default function Switch({
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') {
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 (
<div
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',
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
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 (
<div
className={clsx(
'group relative inline-flex w-11 shrink-0 rounded-full bg-gray-200 p-0.5 inset-ring inset-ring-gray-900/5 outline-offset-2 outline-indigo-600 transition-colors duration-200 ease-in-out has-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',
className
)}
>
{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
aria-hidden="true"
className={clsx(
@ -94,7 +113,11 @@ export default function Switch({
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
d="M4 8l2-2m0 0l2-2M6 6L4 4m2 2l2 2"
stroke="currentColor"
@ -112,13 +135,17 @@ export default function Switch({
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" />
</svg>
</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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,8 @@
'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'
type Props = {
@ -32,28 +33,28 @@ export default function TagOverflowRow({
expandMode = 'cover',
dropdownMaxHeightClassName = 'max-h-[40vh]',
}: Props) {
const [open, setOpen] = React.useState(false)
const [open, setOpen] = useState(false)
// ✅ misst die komplette Zeile (Tags + Button)
const rowWrapRef = React.useRef<HTMLDivElement | null>(null)
const rowWrapRef = useRef<HTMLDivElement | null>(null)
// ✅ hidden “all tags” measurement
const measureRef = React.useRef<HTMLDivElement | null>(null)
const measureRef = useRef<HTMLDivElement | null>(null)
// ✅ 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 [overlayStyle, setOverlayStyle] = React.useState<React.CSSProperties>({
const hostRef = useRef<HTMLDivElement | null>(null)
const [overlayStyle, setOverlayStyle] = useState<CSSProperties>({
top: 0,
left: 0,
right: 0,
height: 0,
})
React.useEffect(() => setOpen(false), [rowKey])
useEffect(() => setOpen(false), [rowKey])
React.useEffect(() => {
useEffect(() => {
if (!open) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
@ -62,22 +63,22 @@ export default function TagOverflowRow({
return () => document.removeEventListener('keydown', onKeyDown)
}, [open])
const cap = React.useMemo(() => {
const cap = useMemo(() => {
return typeof maxVisible === 'number' && maxVisible > 0 ? maxVisible : Number.POSITIVE_INFINITY
}, [maxVisible])
const sortedTags = React.useMemo(() => {
const sortedTags = useMemo(() => {
return [...tags].sort((a, b) =>
a.localeCompare(b, undefined, { sensitivity: 'base' })
)
}, [tags])
const [visibleCount, setVisibleCount] = React.useState<number>(() => {
const [visibleCount, setVisibleCount] = useState<number>(() => {
const total = Math.min(sortedTags.length, cap)
return Math.min(total, 6) // optional initial guess; recalc korrigiert direkt
})
const recalc = React.useCallback(() => {
const recalc = useCallback(() => {
const rowWrap = rowWrapRef.current
const measure = measureRef.current
if (!rowWrap || !measure) return
@ -130,7 +131,7 @@ export default function TagOverflowRow({
setVisibleCount(Math.max(0, Math.min(count, totalTags)))
}, [sortedTags, cap, gapPx])
const recalcOverlay = React.useCallback(() => {
const recalcOverlay = useCallback(() => {
const host = hostRef.current
if (!host) return
@ -145,7 +146,7 @@ export default function TagOverflowRow({
})
}, [])
React.useLayoutEffect(() => {
useLayoutEffect(() => {
if (!open || expandMode !== 'cover') return
recalcOverlay()
@ -164,7 +165,7 @@ export default function TagOverflowRow({
return () => ro.disconnect()
}, [open, expandMode, recalcOverlay])
React.useLayoutEffect(() => {
useLayoutEffect(() => {
if (!open) return
recalcOverlay()
@ -183,12 +184,12 @@ export default function TagOverflowRow({
return () => ro.disconnect()
}, [open, recalcOverlay])
React.useLayoutEffect(() => {
useLayoutEffect(() => {
recalc()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recalc, sortedTags.join('\u0000'), maxWidthClassName, cap])
React.useEffect(() => {
useEffect(() => {
const el = rowWrapRef.current
if (!el) return
const ro = new ResizeObserver(() => recalc())
@ -199,7 +200,7 @@ export default function TagOverflowRow({
const totalTags = Math.min(sortedTags.length, cap)
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)
// 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 (
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
@ -285,8 +283,8 @@ export default function Task({
</div>
<div className="shrink-0">
<Button variant="primary" onClick={start} disabled={disabled || busy || starting || running}>
{(starting || busy) ? startingLabel : startLabel}
<Button variant="primary" onClick={start} disabled={disabled || starting}>
{starting ? startingLabel : startLabel}
</Button>
</div>
</div>

View File

@ -12,6 +12,7 @@ export type TaskItem = {
err?: string
cancellable?: boolean
fading?: boolean
queuedAtMs?: number
startedAtMs?: number
}
@ -31,8 +32,8 @@ export default function TaskList({ tasks, onCancel }: Props) {
const visible = [...(tasks || [])]
.filter((t) => t.status !== 'idle')
.sort((a, b) => {
const aMs = Number(a.startedAtMs ?? 0)
const bMs = Number(b.startedAtMs ?? 0)
const aMs = Number(a.queuedAtMs ?? a.startedAtMs ?? 0)
const bMs = Number(b.queuedAtMs ?? b.startedAtMs ?? 0)
return aMs - bMs
})
@ -122,7 +123,11 @@ export default function TaskList({ tasks, onCancel }: Props) {
</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">
Fertig
</span>

View File

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

View File

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