updated everything
This commit is contained in:
parent
779eeddf52
commit
14412eab4c
@ -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
BIN
backend/assets/img/tray.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -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 {
|
||||
|
||||
208
backend/main.go
208
backend/main.go
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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=="
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
503
backend/split.go
503
backend/split.go
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
665
backend/tasks_check_video.go
Normal file
665
backend/tasks_check_video.go
Normal 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
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
}
|
||||
}
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
567
backend/teaser.go
Normal 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
226
backend/tray.go
Normal 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
232
backend/tray_stats.go
Normal 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])
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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}`)
|
||||
}
|
||||
})()
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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 [
|
||||
|
||||
@ -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)
|
||||
|
||||
409
frontend/src/components/ui/Icons.tsx
Normal file
409
frontend/src/components/ui/Icons.tsx
Normal 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]
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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(' ')} />
|
||||
)
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
40
frontend/src/components/ui/SpritePreloader.tsx
Normal file
40
frontend/src/components/ui/SpritePreloader.tsx
Normal 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
|
||||
}
|
||||
@ -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))
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
? {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
@ -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]}`
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user