updated everything
This commit is contained in:
parent
779eeddf52
commit
14412eab4c
@ -18,6 +18,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -268,6 +269,10 @@ func extractVideoFrameAt(ctx context.Context, outPath string, atSec float64) (im
|
|||||||
"-y",
|
"-y",
|
||||||
tmpPath,
|
tmpPath,
|
||||||
)
|
)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||||||
|
}
|
||||||
|
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
return nil, fmt.Errorf("ffmpeg fehlgeschlagen: %v: %s", err, strings.TrimSpace(string(out)))
|
return nil, fmt.Errorf("ffmpeg fehlgeschlagen: %v: %s", err, strings.TrimSpace(string(out)))
|
||||||
|
|||||||
BIN
backend/assets/img/tray.ico
Normal file
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)
|
// Asset "ID" = Dateiname ohne Endung (immer OHNE "HOT " Prefix)
|
||||||
func assetIDFromVideoPath(videoPath string) string {
|
func assetIDFromVideoPath(videoPath string) string {
|
||||||
base := filepath.Base(strings.TrimSpace(videoPath))
|
return canonicalAssetIDFromName(videoPath)
|
||||||
if base == "" {
|
}
|
||||||
return ""
|
|
||||||
|
func postWorkSortForVideoPath(videoPath string) (bucket int, sortName string) {
|
||||||
|
clean := strings.TrimSpace(videoPath)
|
||||||
|
if clean == "" {
|
||||||
|
return 0, ""
|
||||||
}
|
}
|
||||||
id := strings.TrimSuffix(base, filepath.Ext(base))
|
|
||||||
id = stripHotPrefix(id)
|
p := strings.ToLower(filepath.ToSlash(clean))
|
||||||
return strings.TrimSpace(id)
|
if strings.Contains(p, "/keep/") {
|
||||||
|
bucket = 1
|
||||||
|
} else {
|
||||||
|
bucket = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return bucket, strings.ToLower(filepath.Base(clean))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liefert die standardisierten Pfade (preview.jpg / preview.mp4 / preview-sprite.jpg / meta.json)
|
// Liefert die standardisierten Pfade (preview.jpg / preview.mp4 / preview-sprite.jpg / meta.json)
|
||||||
@ -93,6 +103,41 @@ func assetPathsForID(id string) (assetDir, thumbPath, previewPath, spritePath, m
|
|||||||
return assetDir, thumbPath, previewPath, spritePath, metaPath, nil
|
return assetDir, thumbPath, previewPath, spritePath, metaPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assetsTruthForVideo(videoPath string) finishedPhaseTruth {
|
||||||
|
videoPath = strings.TrimSpace(videoPath)
|
||||||
|
|
||||||
|
var truth finishedPhaseTruth
|
||||||
|
if videoPath == "" {
|
||||||
|
return truth
|
||||||
|
}
|
||||||
|
|
||||||
|
id := assetIDFromVideoPath(videoPath)
|
||||||
|
if id == "" {
|
||||||
|
return truth
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basiszustand aus bestehender Logik lesen
|
||||||
|
truth = finishedPhaseTruthForID(id)
|
||||||
|
|
||||||
|
_, thumbPath, previewPath, spritePath, metaPath, err := assetPathsForID(id)
|
||||||
|
if err != nil {
|
||||||
|
return truth
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta: nur ready, wenn Inhalt OK UND Datei wirklich vorhanden
|
||||||
|
truth.MetaReady = truth.MetaReady && fileExistsNonEmpty(metaPath)
|
||||||
|
|
||||||
|
// Dateibasierte Assets immer an echter Datei festmachen
|
||||||
|
truth.ThumbReady = fileExistsNonEmpty(thumbPath)
|
||||||
|
truth.TeaserReady = fileExistsNonEmpty(previewPath)
|
||||||
|
truth.SpritesReady = fileExistsNonEmpty(spritePath)
|
||||||
|
|
||||||
|
// Analyse weiterhin inhaltlich prüfen
|
||||||
|
truth.AnalyzeReady = hasAIResultsForOutput(videoPath)
|
||||||
|
|
||||||
|
return truth
|
||||||
|
}
|
||||||
|
|
||||||
type ensuredMeta struct {
|
type ensuredMeta struct {
|
||||||
durSec float64
|
durSec float64
|
||||||
vw, vh int
|
vw, vh int
|
||||||
@ -814,20 +859,20 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string)
|
|||||||
return fmt.Errorf("deferred sprite failed for %s: %w", videoPath, err)
|
return fmt.Errorf("deferred sprite failed for %s: %w", videoPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
spriteTruth := regeneratePhaseTruthForVideo(videoPath, id)
|
spriteTruth := assetsTruthForVideo(videoPath)
|
||||||
if !spriteTruth.SpritesReady {
|
if !spriteTruth.SpritesReady {
|
||||||
return fmt.Errorf("preview-sprite.jpg fehlt nach deferred generation: %s", id)
|
return fmt.Errorf("preview-sprite.jpg fehlt nach deferred generation: %s", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) KI-Analyse
|
// 2) Analyse
|
||||||
analyzeReady, err := ensureAnalyzeForVideoCtx(ctx, videoPath, sourceURL, "nsfw")
|
analyzeReady, err := ensureAnalyzeForVideoCtx(ctx, videoPath, sourceURL, "nsfw")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("deferred analyze failed for %s: %w", videoPath, err)
|
return fmt.Errorf("deferred analyze failed for %s: %w", videoPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzeTruth := regeneratePhaseTruthForVideo(videoPath, id)
|
analyzeTruth := assetsTruthForVideo(videoPath)
|
||||||
if !analyzeReady && !analyzeTruth.AnalyzeReady {
|
if !analyzeReady && !analyzeTruth.AnalyzeReady {
|
||||||
return fmt.Errorf("KI-Analyse fehlt nach deferred analyze: %s", id)
|
return fmt.Errorf("Analyse fehlt nach deferred analyze: %s", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@ -845,7 +890,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
truth := finishedPhaseTruthForID(assetID)
|
truth := assetsTruthForVideo(outPath)
|
||||||
|
|
||||||
needMeta := !truth.MetaReady
|
needMeta := !truth.MetaReady
|
||||||
needThumb := !truth.ThumbReady
|
needThumb := !truth.ThumbReady
|
||||||
@ -872,13 +917,20 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string
|
|||||||
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "queued", "Sprites warten", nil)
|
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "queued", "Sprites warten", nil)
|
||||||
}
|
}
|
||||||
if needAnalyze {
|
if needAnalyze {
|
||||||
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "queued", "KI-Analyse wartet", nil)
|
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "queued", "Analyse wartet", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sortBucket, sortName := postWorkSortForVideoPath(outPath)
|
||||||
|
|
||||||
ok := enrichQ.Enqueue(PostWorkTask{
|
ok := enrichQ.Enqueue(PostWorkTask{
|
||||||
Key: "enrich:" + assetID,
|
Key: "enrich:" + assetID,
|
||||||
Added: time.Now(),
|
Added: time.Now(),
|
||||||
|
SortBucket: sortBucket,
|
||||||
|
SortName: sortName,
|
||||||
Run: func(ctx context.Context) error {
|
Run: func(ctx context.Context) error {
|
||||||
|
if err := waitUntilEnrichMayRun(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return runDeferredEnrichPipeline(ctx, outPath, sourceURL)
|
return runDeferredEnrichPipeline(ctx, outPath, sourceURL)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -903,7 +955,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string
|
|||||||
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "error", "Sprites konnten nicht eingeplant werden", enqueueErr)
|
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "error", "Sprites konnten nicht eingeplant werden", enqueueErr)
|
||||||
}
|
}
|
||||||
if needAnalyze {
|
if needAnalyze {
|
||||||
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "KI-Analyse konnte nicht eingeplant werden", enqueueErr)
|
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "Analyse konnte nicht eingeplant werden", enqueueErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -29,9 +30,21 @@ func previewSpriteStepSeconds(durationSec float64) float64 {
|
|||||||
return 5
|
return 5
|
||||||
}
|
}
|
||||||
|
|
||||||
step := durationSec / float64(previewSpriteFrameCount)
|
// 80 Zellen = 79 Abstände.
|
||||||
if step < 0.5 {
|
// Wir ziehen ein kleines Stück vom Ende ab, damit ffmpeg
|
||||||
step = 0.5
|
// nicht "hinter" dem letzten gültigen Frame sampelt.
|
||||||
|
usableDur := durationSec - 0.10
|
||||||
|
if usableDur <= 0 {
|
||||||
|
usableDur = durationSec
|
||||||
|
}
|
||||||
|
|
||||||
|
step := usableDur / float64(previewSpriteFrameCount-1)
|
||||||
|
|
||||||
|
// Nicht künstlich auf 0.5s anheben:
|
||||||
|
// genau das erzeugt bei kürzeren Videos zu wenige Frames
|
||||||
|
// und damit schwarze Rest-Zellen im Sprite.
|
||||||
|
if step < 0.05 {
|
||||||
|
step = 0.05
|
||||||
}
|
}
|
||||||
|
|
||||||
return step
|
return step
|
||||||
@ -97,7 +110,7 @@ func generatePreviewSpriteJPG(
|
|||||||
|
|
||||||
// robustere Filterkette
|
// robustere Filterkette
|
||||||
vf := fmt.Sprintf(
|
vf := fmt.Sprintf(
|
||||||
"fps=1/%f,"+
|
"fps=fps=1/%f:start_time=0:round=up,"+
|
||||||
"scale=%d:%d:force_original_aspect_ratio=decrease:flags=bilinear,"+
|
"scale=%d:%d:force_original_aspect_ratio=decrease:flags=bilinear,"+
|
||||||
"pad=%d:%d:(ow-iw)/2:(oh-ih)/2:black,"+
|
"pad=%d:%d:(ow-iw)/2:(oh-ih)/2:black,"+
|
||||||
"setsar=1,"+
|
"setsar=1,"+
|
||||||
@ -127,6 +140,10 @@ func generatePreviewSpriteJPG(
|
|||||||
)
|
)
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||||||
|
}
|
||||||
|
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
|
|||||||
@ -78,7 +78,7 @@ type chaturbateCache struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cbHTTP = &http.Client{Timeout: 30 * time.Second}
|
cbHTTP = &http.Client{Timeout: 12 * time.Second}
|
||||||
cbMu sync.RWMutex
|
cbMu sync.RWMutex
|
||||||
cb chaturbateCache
|
cb chaturbateCache
|
||||||
|
|
||||||
@ -89,6 +89,9 @@ var (
|
|||||||
var (
|
var (
|
||||||
cbRefreshMu sync.Mutex
|
cbRefreshMu sync.Mutex
|
||||||
cbRefreshInFlight bool
|
cbRefreshInFlight bool
|
||||||
|
|
||||||
|
cbStoreSyncMu sync.Mutex
|
||||||
|
cbStoreSyncInFlight bool
|
||||||
)
|
)
|
||||||
|
|
||||||
func normalizeList(in []string) []string {
|
func normalizeList(in []string) []string {
|
||||||
@ -511,20 +514,39 @@ func refreshChaturbateSnapshotNow(ctx context.Context) (time.Time, error) {
|
|||||||
fetchedAtNow := cbApplySnapshot(rooms)
|
fetchedAtNow := cbApplySnapshot(rooms)
|
||||||
|
|
||||||
if cbModelStore != nil {
|
if cbModelStore != nil {
|
||||||
copiedRooms := append([]ChaturbateRoom(nil), rooms...)
|
triggerChaturbateStoreSync(cbModelStore, rooms, fetchedAtNow)
|
||||||
|
|
||||||
syncChaturbateRoomStateIntoModelStore(
|
|
||||||
cbModelStore,
|
|
||||||
copiedRooms,
|
|
||||||
fetchedAtNow,
|
|
||||||
)
|
|
||||||
|
|
||||||
_ = cbModelStore.SyncChaturbateOnlineForKnownModels(copiedRooms, fetchedAtNow)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetchedAtNow, nil
|
return fetchedAtNow, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func triggerChaturbateStoreSync(store *ModelStore, rooms []ChaturbateRoom, fetchedAt time.Time) {
|
||||||
|
if store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
roomsCopy := append([]ChaturbateRoom(nil), rooms...)
|
||||||
|
|
||||||
|
cbStoreSyncMu.Lock()
|
||||||
|
if cbStoreSyncInFlight {
|
||||||
|
cbStoreSyncMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cbStoreSyncInFlight = true
|
||||||
|
cbStoreSyncMu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
cbStoreSyncMu.Lock()
|
||||||
|
cbStoreSyncInFlight = false
|
||||||
|
cbStoreSyncMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
syncChaturbateRoomStateIntoModelStore(store, roomsCopy, fetchedAt)
|
||||||
|
_ = store.SyncChaturbateOnlineForKnownModels(roomsCopy, fetchedAt)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
// startChaturbateOnlinePoller pollt die API alle paar Sekunden,
|
// startChaturbateOnlinePoller pollt die API alle paar Sekunden,
|
||||||
// aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist.
|
// aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist.
|
||||||
func startChaturbateOnlinePoller(store *ModelStore) {
|
func startChaturbateOnlinePoller(store *ModelStore) {
|
||||||
@ -541,82 +563,101 @@ func startChaturbateOnlinePoller(store *ModelStore) {
|
|||||||
var tagsMu sync.Mutex
|
var tagsMu sync.Mutex
|
||||||
var tagsLast time.Time
|
var tagsLast time.Time
|
||||||
|
|
||||||
refreshOnce := func() {
|
refreshOnce := func(trigger string) {
|
||||||
if !getSettings().UseChaturbateAPI {
|
if !getSettings().UseChaturbateAPI {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
cbRefreshMu.Lock()
|
||||||
fetchedAtNow, err := refreshChaturbateSnapshotNow(ctx)
|
if cbRefreshInFlight {
|
||||||
cancel()
|
cbRefreshMu.Unlock()
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
cbMu.RLock()
|
|
||||||
msg := cb.LastErr
|
|
||||||
prevFetchedAt := cb.FetchedAt
|
|
||||||
prevCount := len(cb.Rooms)
|
|
||||||
cbMu.RUnlock()
|
|
||||||
|
|
||||||
if msg != lastLoggedErr {
|
|
||||||
if prevFetchedAt.IsZero() {
|
|
||||||
fmt.Println("❌ [chaturbate] online rooms fetch failed (no snapshot yet):", msg)
|
|
||||||
} else {
|
|
||||||
fmt.Printf(
|
|
||||||
"⚠️ [chaturbate] refresh failed, keeping snapshot from %s (%d rooms): %s\n",
|
|
||||||
prevFetchedAt.Format(time.RFC3339),
|
|
||||||
prevCount,
|
|
||||||
msg,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
lastLoggedErr = msg
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
cbRefreshInFlight = true
|
||||||
|
cbRefreshMu.Unlock()
|
||||||
|
|
||||||
cbMu.RLock()
|
go func() {
|
||||||
roomsCopy := append([]ChaturbateRoom(nil), cb.Rooms...)
|
defer func() {
|
||||||
roomsCount := len(cb.Rooms)
|
cbRefreshMu.Lock()
|
||||||
cbMu.RUnlock()
|
cbRefreshInFlight = false
|
||||||
|
cbRefreshMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
// Tags nur selten und asynchron
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
if cbModelStore != nil && len(roomsCopy) > 0 {
|
fetchedAtNow, err := refreshChaturbateSnapshotNow(ctx)
|
||||||
shouldFill := false
|
cancel()
|
||||||
|
|
||||||
tagsMu.Lock()
|
if err != nil {
|
||||||
if tagsLast.IsZero() || time.Since(tagsLast) >= tagsFillEvery {
|
cbMu.RLock()
|
||||||
tagsLast = time.Now()
|
msg := cb.LastErr
|
||||||
shouldFill = true
|
prevFetchedAt := cb.FetchedAt
|
||||||
|
prevCount := len(cb.Rooms)
|
||||||
|
lastAttempt := cb.LastAttempt
|
||||||
|
cbMu.RUnlock()
|
||||||
|
|
||||||
|
// wiederholt loggen, nicht nur bei geändertem Fehlertext
|
||||||
|
if msg != lastLoggedErr || time.Since(lastAttempt) > 30*time.Second {
|
||||||
|
if prevFetchedAt.IsZero() {
|
||||||
|
fmt.Println("❌ [chaturbate] online rooms fetch failed (no snapshot yet):", msg)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(
|
||||||
|
"⚠️ [chaturbate] refresh failed, keeping snapshot from %s (%d rooms): %s\n",
|
||||||
|
prevFetchedAt.Format(time.RFC3339),
|
||||||
|
prevCount,
|
||||||
|
msg,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
lastLoggedErr = msg
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
tagsMu.Unlock()
|
|
||||||
|
|
||||||
if shouldFill {
|
cbMu.RLock()
|
||||||
go cbModelStore.FillMissingTagsFromChaturbateOnline(roomsCopy)
|
roomsCopy := append([]ChaturbateRoom(nil), cb.Rooms...)
|
||||||
|
roomsCount := len(cb.Rooms)
|
||||||
|
cbMu.RUnlock()
|
||||||
|
|
||||||
|
// Tags nur selten und asynchron
|
||||||
|
if cbModelStore != nil {
|
||||||
|
shouldFill := false
|
||||||
|
|
||||||
|
tagsMu.Lock()
|
||||||
|
if tagsLast.IsZero() || time.Since(tagsLast) >= tagsFillEvery {
|
||||||
|
tagsLast = time.Now()
|
||||||
|
shouldFill = true
|
||||||
|
}
|
||||||
|
tagsMu.Unlock()
|
||||||
|
|
||||||
|
if shouldFill {
|
||||||
|
go cbModelStore.FillMissingTagsFromChaturbateOnline(roomsCopy)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if lastLoggedErr != "" {
|
if lastLoggedErr != "" {
|
||||||
fmt.Println("✅ [chaturbate] online rooms fetch recovered")
|
fmt.Println("✅ [chaturbate] online rooms fetch recovered")
|
||||||
lastLoggedErr = ""
|
lastLoggedErr = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if verboseLogs() && roomsCount != lastLoggedCount {
|
if verboseLogs() && roomsCount != lastLoggedCount {
|
||||||
fmt.Printf(
|
fmt.Printf(
|
||||||
"✅ [chaturbate] online rooms: %d (snapshot %s)\n",
|
"✅ [chaturbate] online rooms: %d (snapshot %s, trigger=%s)\n",
|
||||||
roomsCount,
|
roomsCount,
|
||||||
fetchedAtNow.Format(time.RFC3339),
|
fetchedAtNow.Format(time.RFC3339),
|
||||||
)
|
trigger,
|
||||||
lastLoggedCount = roomsCount
|
)
|
||||||
}
|
lastLoggedCount = roomsCount
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ sofort einmal versuchen
|
// ✅ sofort einmal versuchen
|
||||||
refreshOnce()
|
refreshOnce("startup")
|
||||||
|
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
refreshOnce()
|
refreshOnce("ticker")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,114 +1,114 @@
|
|||||||
{
|
{
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"modelKey": "sw33t_g1rl",
|
"modelKey": "xdaisyx",
|
||||||
"url": "https://www.myfreecams.com/#Sw33t_g1rl",
|
"url": "https://www.myfreecams.com/#xDaisyx",
|
||||||
"mode": "probe_retry",
|
"mode": "probe_retry",
|
||||||
"nextProbeAtMs": 1776172330458,
|
"nextProbeAtMs": 1777292591836,
|
||||||
"currentShow": "offline",
|
"currentShow": "offline",
|
||||||
"source": "watched"
|
"source": "watched"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"modelKey": "angel_from_sky",
|
"modelKey": "wekittycluby",
|
||||||
"url": "https://chaturbate.com/angel_from_sky/",
|
"url": "https://chaturbate.com/wekittycluby/",
|
||||||
"mode": "wait_public",
|
"mode": "wait_public",
|
||||||
"currentShow": "private",
|
"currentShow": "private",
|
||||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/angel_from_sky.jpg",
|
"imageUrl": "https://thumb.live.mmcdn.com/ri/wekittycluby.jpg",
|
||||||
"source": "watched"
|
"source": "watched"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"modelKey": "vanicha_007",
|
"modelKey": "la__melanie",
|
||||||
"url": "https://chaturbate.com/vanicha_007/",
|
"url": "https://chaturbate.com/la__melanie/",
|
||||||
|
"mode": "wait_public",
|
||||||
|
"currentShow": "private",
|
||||||
|
"imageUrl": "https://thumb.live.mmcdn.com/ri/la__melanie.jpg",
|
||||||
|
"source": "watched"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modelKey": "adrean_sofi",
|
||||||
|
"url": "https://chaturbate.com/adrean_sofi/",
|
||||||
"mode": "wait_public",
|
"mode": "wait_public",
|
||||||
"currentShow": "hidden",
|
"currentShow": "hidden",
|
||||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/vanicha_007.jpg",
|
"imageUrl": "https://thumb.live.mmcdn.com/ri/adrean_sofi.jpg",
|
||||||
"source": "watched"
|
"source": "watched"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"modelKey": "tomokosturgeon",
|
"modelKey": "extasy_maya",
|
||||||
"url": "https://chaturbate.com/tomokosturgeon/",
|
"url": "https://chaturbate.com/extasy_maya/",
|
||||||
"mode": "wait_public",
|
"mode": "wait_public",
|
||||||
"currentShow": "hidden",
|
"currentShow": "hidden",
|
||||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/tomokosturgeon.jpg",
|
"imageUrl": "https://thumb.live.mmcdn.com/ri/extasy_maya.jpg",
|
||||||
"source": "watched"
|
"source": "watched"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"modelKey": "tammieprotano",
|
"modelKey": "rita__shy",
|
||||||
"url": "https://chaturbate.com/tammieprotano/",
|
"url": "https://chaturbate.com/rita__shy/",
|
||||||
"mode": "wait_public",
|
"mode": "wait_public",
|
||||||
"currentShow": "private",
|
"currentShow": "private",
|
||||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/tammieprotano.jpg",
|
"imageUrl": "https://thumb.live.mmcdn.com/ri/rita__shy.jpg",
|
||||||
"source": "watched"
|
"source": "watched"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"modelKey": "troybailly",
|
"modelKey": "shachatte",
|
||||||
"url": "https://chaturbate.com/troybailly/",
|
"url": "https://chaturbate.com/shachatte/",
|
||||||
|
"mode": "wait_public",
|
||||||
|
"currentShow": "private",
|
||||||
|
"imageUrl": "https://thumb.live.mmcdn.com/ri/shachatte.jpg",
|
||||||
|
"source": "watched"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modelKey": "carolecronan",
|
||||||
|
"url": "https://chaturbate.com/carolecronan/",
|
||||||
|
"mode": "wait_public",
|
||||||
|
"currentShow": "away",
|
||||||
|
"imageUrl": "https://thumb.live.mmcdn.com/ri/carolecronan.jpg",
|
||||||
|
"source": "watched"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modelKey": "lora_lifelover",
|
||||||
|
"url": "https://chaturbate.com/lora_lifelover/",
|
||||||
|
"mode": "wait_public",
|
||||||
|
"currentShow": "private",
|
||||||
|
"imageUrl": "https://thumb.live.mmcdn.com/ri/lora_lifelover.jpg",
|
||||||
|
"source": "watched"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modelKey": "phantomlace",
|
||||||
|
"url": "https://chaturbate.com/phantomlace/",
|
||||||
|
"mode": "wait_public",
|
||||||
|
"currentShow": "private",
|
||||||
|
"imageUrl": "https://thumb.live.mmcdn.com/ri/phantomlace.jpg",
|
||||||
|
"source": "watched"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modelKey": "aurora_the_ballerina",
|
||||||
|
"url": "https://chaturbate.com/aurora_the_ballerina/",
|
||||||
|
"mode": "wait_public",
|
||||||
|
"currentShow": "private",
|
||||||
|
"imageUrl": "https://thumb.live.mmcdn.com/ri/aurora_the_ballerina.jpg",
|
||||||
|
"source": "watched"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modelKey": "anabel054",
|
||||||
|
"url": "https://chaturbate.com/anabel054/",
|
||||||
|
"mode": "wait_public",
|
||||||
|
"currentShow": "private",
|
||||||
|
"imageUrl": "https://thumb.live.mmcdn.com/ri/anabel054.jpg",
|
||||||
|
"source": "watched"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modelKey": "phqqipi",
|
||||||
|
"url": "https://chaturbate.com/phqqipi/",
|
||||||
"mode": "wait_public",
|
"mode": "wait_public",
|
||||||
"currentShow": "hidden",
|
"currentShow": "hidden",
|
||||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/troybailly.jpg",
|
"imageUrl": "https://thumb.live.mmcdn.com/ri/phqqipi.jpg",
|
||||||
"source": "watched"
|
"source": "watched"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"modelKey": "vonen_ray",
|
"modelKey": "evewilder",
|
||||||
"url": "https://chaturbate.com/vonen_ray/",
|
"url": "https://chaturbate.com/evewilder/",
|
||||||
"mode": "wait_public",
|
|
||||||
"currentShow": "private",
|
|
||||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/vonen_ray.jpg",
|
|
||||||
"source": "watched"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"modelKey": "sharitaklemme",
|
|
||||||
"url": "https://chaturbate.com/sharitaklemme/",
|
|
||||||
"mode": "wait_public",
|
|
||||||
"currentShow": "private",
|
|
||||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/sharitaklemme.jpg",
|
|
||||||
"source": "watched"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"modelKey": "sosaguillypleasure",
|
|
||||||
"url": "https://chaturbate.com/sosaguillypleasure/",
|
|
||||||
"mode": "wait_public",
|
|
||||||
"currentShow": "private",
|
|
||||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/sosaguillypleasure.jpg",
|
|
||||||
"source": "watched"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"modelKey": "kittymeow_xxx",
|
|
||||||
"url": "https://chaturbate.com/kittymeow_xxx/",
|
|
||||||
"mode": "wait_public",
|
|
||||||
"currentShow": "private",
|
|
||||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/kittymeow_xxx.jpg",
|
|
||||||
"source": "watched"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"modelKey": "superstar090",
|
|
||||||
"url": "https://chaturbate.com/superstar090/",
|
|
||||||
"mode": "wait_public",
|
|
||||||
"currentShow": "private",
|
|
||||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/superstar090.jpg",
|
|
||||||
"source": "watched"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"modelKey": "victoryglow",
|
|
||||||
"url": "https://chaturbate.com/victoryglow/",
|
|
||||||
"mode": "wait_public",
|
|
||||||
"currentShow": "private",
|
|
||||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/victoryglow.jpg",
|
|
||||||
"source": "watched"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"modelKey": "palmiratownzen",
|
|
||||||
"url": "https://chaturbate.com/palmiratownzen/",
|
|
||||||
"mode": "wait_public",
|
|
||||||
"currentShow": "hidden",
|
|
||||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/palmiratownzen.jpg",
|
|
||||||
"source": "watched"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"modelKey": "rafaelaloghry",
|
|
||||||
"url": "https://chaturbate.com/rafaelaloghry/",
|
|
||||||
"mode": "probe_retry",
|
"mode": "probe_retry",
|
||||||
"nextProbeAtMs": 1776172331673,
|
"nextProbeAtMs": 1777292606854,
|
||||||
"currentShow": "unknown",
|
"currentShow": "unknown",
|
||||||
"source": "watched"
|
"source": "watched"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ go 1.25.3
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.11.0
|
github.com/PuerkitoBio/goquery v1.11.0
|
||||||
|
github.com/getlantern/systray v1.2.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/grafov/m3u8 v0.12.1
|
github.com/grafov/m3u8 v0.12.1
|
||||||
github.com/jackc/pgx/v5 v5.8.0
|
github.com/jackc/pgx/v5 v5.8.0
|
||||||
@ -15,11 +16,19 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||||
|
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
|
||||||
|
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
|
||||||
|
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
|
||||||
|
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
|
||||||
|
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
|
||||||
|
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
|
||||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
|
github.com/go-stack/stack v1.8.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||||
|
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||||
|
|||||||
@ -9,8 +9,24 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBW
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
|
||||||
|
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
|
||||||
|
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
|
||||||
|
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
|
||||||
|
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
|
||||||
|
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
|
||||||
|
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
|
||||||
|
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
|
||||||
|
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
|
||||||
|
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
|
||||||
|
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
|
||||||
|
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
|
||||||
|
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
|
||||||
|
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
|
||||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
|
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
|
||||||
|
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
@ -28,6 +44,10 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
|
|||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||||
|
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
|
||||||
|
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
|
||||||
|
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
|
||||||
|
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||||
@ -42,6 +62,7 @@ github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFt
|
|||||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||||
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||||
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
||||||
|
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
||||||
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 h1:2JL2wmHXWIAxDofCK+AdkFi1KEg3dgkefCsm7isADzQ=
|
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 h1:2JL2wmHXWIAxDofCK+AdkFi1KEg3dgkefCsm7isADzQ=
|
||||||
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw=
|
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
@ -96,11 +117,13 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
|||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@ -138,6 +161,7 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
|
|||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
|
||||||
gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y=
|
gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y=
|
||||||
gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI=
|
gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -536,6 +537,11 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h
|
|||||||
)
|
)
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, ffmpegPath, hqArgs...)
|
cmd := exec.CommandContext(ctx, ffmpegPath, hqArgs...)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||||||
|
}
|
||||||
|
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
@ -828,6 +834,10 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||||||
|
}
|
||||||
|
|
||||||
stdout, err := cmd.StdoutPipe()
|
stdout, err := cmd.StdoutPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
208
backend/main.go
208
backend/main.go
@ -20,6 +20,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/grafov/m3u8"
|
"github.com/grafov/m3u8"
|
||||||
@ -36,6 +37,10 @@ type Playlist struct {
|
|||||||
Framerate int
|
Framerate int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var recordedAssetStemPrefixRe = regexp.MustCompile(
|
||||||
|
`^(.+?_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2})`,
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
JobRunning JobStatus = "running"
|
JobRunning JobStatus = "running"
|
||||||
JobPostwork JobStatus = "postwork" // ✅ NEU: Aufnahme vorbei, Nacharbeiten laufen noch
|
JobPostwork JobStatus = "postwork" // ✅ NEU: Aufnahme vorbei, Nacharbeiten laufen noch
|
||||||
@ -130,6 +135,29 @@ type ffprobeInfo struct {
|
|||||||
Streams []ffprobeStreamInfo `json:"streams"`
|
Streams []ffprobeStreamInfo `json:"streams"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func canonicalAssetIDFromName(name string) string {
|
||||||
|
base := strings.TrimSpace(filepath.Base(name))
|
||||||
|
if base == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
stem := strings.TrimSuffix(base, filepath.Ext(base))
|
||||||
|
stem = stripHotPrefix(strings.TrimSpace(stem))
|
||||||
|
stem = strings.TrimPrefix(stem, ".")
|
||||||
|
if stem == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wichtig:
|
||||||
|
// ".chaesoffel_04_22_2026__12-22-16.tmp.20260422_134354_000"
|
||||||
|
// -> "chaesoffel_04_22_2026__12-22-16"
|
||||||
|
if m := recordedAssetStemPrefixRe.FindStringSubmatch(stem); m != nil {
|
||||||
|
return strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
return stem
|
||||||
|
}
|
||||||
|
|
||||||
func publishJobUpsertSnapshot(s jobEventSnapshot) {
|
func publishJobUpsertSnapshot(s jobEventSnapshot) {
|
||||||
ev := map[string]any{
|
ev := map[string]any{
|
||||||
"type": "job_upsert",
|
"type": "job_upsert",
|
||||||
@ -230,6 +258,10 @@ func probeVideoProps(ctx context.Context, filePath string) (w int, h int, fps fl
|
|||||||
"-of", "json",
|
"-of", "json",
|
||||||
filePath,
|
filePath,
|
||||||
)
|
)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||||||
|
}
|
||||||
|
|
||||||
out, err := cmd.Output()
|
out, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -447,6 +479,11 @@ func durationSecondsCached(ctx context.Context, path string) (float64, error) {
|
|||||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||||
path,
|
path,
|
||||||
)
|
)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||||||
|
}
|
||||||
|
|
||||||
out, err := cmd.Output()
|
out, err := cmd.Output()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
s := strings.TrimSpace(string(out))
|
s := strings.TrimSpace(string(out))
|
||||||
@ -461,6 +498,10 @@ func durationSecondsCached(ctx context.Context, path string) (float64, error) {
|
|||||||
|
|
||||||
// 2) Fallback: ffmpeg -i "Duration: HH:MM:SS.xx" parsen
|
// 2) Fallback: ffmpeg -i "Duration: HH:MM:SS.xx" parsen
|
||||||
cmd2 := exec.CommandContext(ctx, ffmpegPath, "-i", path)
|
cmd2 := exec.CommandContext(ctx, ffmpegPath, "-i", path)
|
||||||
|
cmd2.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||||||
|
}
|
||||||
b, _ := cmd2.CombinedOutput() // ffmpeg liefert hier oft ExitCode!=0, Output ist trotzdem da
|
b, _ := cmd2.CombinedOutput() // ffmpeg liefert hier oft ExitCode!=0, Output ist trotzdem da
|
||||||
text := string(b)
|
text := string(b)
|
||||||
|
|
||||||
@ -569,21 +610,41 @@ func removeGeneratedForID(id string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
id = stripHotPrefix(id)
|
||||||
|
|
||||||
if isAssetIDInUse(id) {
|
if isAssetIDInUse(id) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id = strings.TrimSuffix(id, filepath.Ext(id))
|
|
||||||
id = stripHotPrefix(id)
|
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
id, err = sanitizeID(id)
|
id, err = sanitizeID(id)
|
||||||
if err != nil || id == "" {
|
if err != nil || id == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removed := map[string]struct{}{}
|
||||||
|
|
||||||
|
removeDir := func(p string) {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cp := filepath.Clean(p)
|
||||||
|
if _, seen := removed[cp]; seen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.RemoveAll(cp)
|
||||||
|
removed[cp] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Asset-Ordner mit preview.jpg / preview.mp4 / preview-sprite.jpg
|
||||||
|
if dir, err := generatedDirForID(id); err == nil && strings.TrimSpace(dir) != "" {
|
||||||
|
removeDir(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Separater Meta-Ordner generated/meta/<id>
|
||||||
if root, _ := generatedMetaRoot(); strings.TrimSpace(root) != "" {
|
if root, _ := generatedMetaRoot(); strings.TrimSpace(root) != "" {
|
||||||
_ = os.RemoveAll(filepath.Join(root, id))
|
removeDir(filepath.Join(root, id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -766,6 +827,11 @@ func remuxTSToMP4(tsPath, mp4Path string) error {
|
|||||||
"-movflags", "+faststart",
|
"-movflags", "+faststart",
|
||||||
mp4Path,
|
mp4Path,
|
||||||
)
|
)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||||||
|
}
|
||||||
|
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
@ -800,16 +866,35 @@ func remuxTSToMP4WithProgress(
|
|||||||
onRatio func(r float64),
|
onRatio func(r float64),
|
||||||
) error {
|
) error {
|
||||||
// ffmpeg progress kommt auf stdout als key=value
|
// ffmpeg progress kommt auf stdout als key=value
|
||||||
cmd := exec.CommandContext(ctx, ffmpegPath,
|
args := []string{
|
||||||
"-y",
|
"-y",
|
||||||
"-nostats",
|
"-nostats",
|
||||||
"-progress", "pipe:1",
|
"-progress", "pipe:1",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel", "warning",
|
||||||
|
}
|
||||||
|
args = append(args, ffmpegInputTol...)
|
||||||
|
args = append(args,
|
||||||
"-i", tsPath,
|
"-i", tsPath,
|
||||||
|
"-map", "0:v:0",
|
||||||
|
"-map", "0:a:0?",
|
||||||
|
"-dn",
|
||||||
|
"-ignore_unknown",
|
||||||
|
"-avoid_negative_ts", "make_zero",
|
||||||
|
"-max_interleave_delta", "0",
|
||||||
|
"-muxpreload", "0",
|
||||||
|
"-muxdelay", "0",
|
||||||
"-c", "copy",
|
"-c", "copy",
|
||||||
"-movflags", "+faststart",
|
"-movflags", "+faststart",
|
||||||
mp4Path,
|
mp4Path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||||||
|
}
|
||||||
|
|
||||||
stdout, err := cmd.StdoutPipe()
|
stdout, err := cmd.StdoutPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -996,6 +1081,10 @@ func ensureFastStartMP4(path string) error {
|
|||||||
"-movflags", "+faststart",
|
"-movflags", "+faststart",
|
||||||
tmp,
|
tmp,
|
||||||
)
|
)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||||||
|
}
|
||||||
|
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
@ -1119,8 +1208,7 @@ func uniqueDestPath(dstDir, file string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func idFromVideoPath(videoPath string) string {
|
func idFromVideoPath(videoPath string) string {
|
||||||
name := filepath.Base(strings.TrimSpace(videoPath))
|
return canonicalAssetIDFromName(videoPath)
|
||||||
return strings.TrimSuffix(name, filepath.Ext(name))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func assetIDForJob(job *RecordJob) string {
|
func assetIDForJob(job *RecordJob) string {
|
||||||
@ -1521,3 +1609,109 @@ func fileExists(path string) bool {
|
|||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateVideoDecodesNearEnd(ctx context.Context, path string) error {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return fmt.Errorf("empty path")
|
||||||
|
}
|
||||||
|
|
||||||
|
dur, err := durationSecondsCached(ctx, path)
|
||||||
|
if err != nil || dur <= 0 {
|
||||||
|
return nil // best effort
|
||||||
|
}
|
||||||
|
|
||||||
|
t := dur * 0.90
|
||||||
|
if t < 0.1 {
|
||||||
|
t = 0.1
|
||||||
|
}
|
||||||
|
|
||||||
|
img, err := extractFrameAtTimeJPG(path, t)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode near end failed at %.3fs: %w", t, err)
|
||||||
|
}
|
||||||
|
if len(img) == 0 {
|
||||||
|
return fmt.Errorf("decode near end returned empty frame at %.3fs", t)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueSiblingTempPath(finalPath, suffix string) string {
|
||||||
|
finalPath = strings.TrimSpace(finalPath)
|
||||||
|
|
||||||
|
dir := filepath.Dir(finalPath)
|
||||||
|
ext := filepath.Ext(finalPath)
|
||||||
|
base := strings.TrimSuffix(filepath.Base(finalPath), ext)
|
||||||
|
|
||||||
|
ts := time.Now().Format("20060102_150405_000")
|
||||||
|
|
||||||
|
if strings.TrimSpace(suffix) == "" {
|
||||||
|
return filepath.Join(dir, fmt.Sprintf(".%s.%s%s", base, ts, ext))
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(dir, fmt.Sprintf(".%s.%s.%s%s", base, suffix, ts, ext))
|
||||||
|
}
|
||||||
|
|
||||||
|
func reencodeTSToMP4(ctx context.Context, tsPath, mp4Path string) error {
|
||||||
|
tmp := uniqueSiblingTempPath(mp4Path, "tmp")
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"-y",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel", "warning",
|
||||||
|
}
|
||||||
|
args = append(args, ffmpegInputTol...)
|
||||||
|
args = append(args,
|
||||||
|
"-i", tsPath,
|
||||||
|
"-map", "0:v:0",
|
||||||
|
"-map", "0:a:0?",
|
||||||
|
"-dn",
|
||||||
|
"-ignore_unknown",
|
||||||
|
|
||||||
|
"-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-preset", "veryfast",
|
||||||
|
"-crf", "20",
|
||||||
|
"-pix_fmt", "yuv420p",
|
||||||
|
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-b:a", "128k",
|
||||||
|
"-ac", "2",
|
||||||
|
|
||||||
|
"-af", "aresample=async=1:first_pts=0",
|
||||||
|
"-shortest",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
tmp,
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: 0x08000000,
|
||||||
|
}
|
||||||
|
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stdout = io.Discard
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return fmt.Errorf("ffmpeg reencode failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := requireNonEmptyRegularFile(tmp, "reencode tmp result"); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = os.Remove(mp4Path)
|
||||||
|
|
||||||
|
if err := os.Rename(tmp, mp4Path); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return fmt.Errorf("rename reencode tmp -> target failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@ -93,11 +93,12 @@ type videoMeta struct {
|
|||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
UpdatedAtUnix int64 `json:"updatedAtUnix"`
|
UpdatedAtUnix int64 `json:"updatedAtUnix"`
|
||||||
|
|
||||||
File videoFileMeta `json:"file"`
|
File videoFileMeta `json:"file"`
|
||||||
Media videoMediaMeta `json:"media"`
|
Media videoMediaMeta `json:"media"`
|
||||||
Preview previewMeta `json:"preview,omitempty"`
|
Preview previewMeta `json:"preview,omitempty"`
|
||||||
Analysis analysisMeta `json:"analysis,omitempty"`
|
Analysis analysisMeta `json:"analysis,omitempty"`
|
||||||
Postwork postworkMeta `json:"postwork,omitempty"`
|
Postwork postworkMeta `json:"postwork,omitempty"`
|
||||||
|
Validation map[string]any `json:"validation,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type aiSegmentMeta struct {
|
type aiSegmentMeta struct {
|
||||||
@ -436,6 +437,7 @@ func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64, w int, h int,
|
|||||||
m.Preview = existing.Preview
|
m.Preview = existing.Preview
|
||||||
m.Analysis = existing.Analysis
|
m.Analysis = existing.Analysis
|
||||||
m.Postwork = existing.Postwork
|
m.Postwork = existing.Postwork
|
||||||
|
m.Validation = existing.Validation
|
||||||
|
|
||||||
if m.File.SourceURL == "" {
|
if m.File.SourceURL == "" {
|
||||||
m.File.SourceURL = existing.File.SourceURL
|
m.File.SourceURL = existing.File.SourceURL
|
||||||
|
|||||||
@ -10,22 +10,27 @@ import (
|
|||||||
|
|
||||||
// Eine Nacharbeit (kann ffmpeg, ffprobe, thumbnails, rename, etc. enthalten)
|
// Eine Nacharbeit (kann ffmpeg, ffprobe, thumbnails, rename, etc. enthalten)
|
||||||
type PostWorkTask struct {
|
type PostWorkTask struct {
|
||||||
Key string // z.B. Dateiname oder Job-ID, zum Deduplizieren
|
Key string // z.B. Dateiname oder Job-ID, zum Deduplizieren
|
||||||
Run func(ctx context.Context) error
|
Run func(ctx context.Context) error
|
||||||
Added time.Time
|
Added time.Time
|
||||||
|
SortBucket int // 0 = /done, 1 = /done/keep
|
||||||
|
SortName string // Dateiname lower-case
|
||||||
}
|
}
|
||||||
|
|
||||||
type PostWorkQueue struct {
|
type PostWorkQueue struct {
|
||||||
q chan PostWorkTask
|
|
||||||
ffmpegSem chan struct{} // "heavy" gate: maxParallelFFmpeg gleichzeitig
|
ffmpegSem chan struct{} // "heavy" gate: maxParallelFFmpeg gleichzeitig
|
||||||
|
maxQueued int
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
cond *sync.Cond
|
||||||
|
|
||||||
|
queue []PostWorkTask
|
||||||
inflight map[string]struct{} // dedupe: queued ODER running
|
inflight map[string]struct{} // dedupe: queued ODER running
|
||||||
|
|
||||||
queued int // Anzahl inflight (queued + running)
|
queued int // Anzahl inflight (queued + running)
|
||||||
|
|
||||||
// ✅ für UI: Warteschlange + Running-Keys tracken
|
// ✅ für UI: Warteschlange + Running-Keys tracken
|
||||||
waitingKeys []string // FIFO der Keys, die noch NICHT gestartet sind
|
waitingKeys []string // sortierte wartende Keys
|
||||||
runningKeys map[string]struct{} // Keys, die gerade wirklich laufen (Semaphor gehalten)
|
runningKeys map[string]struct{} // Keys, die gerade wirklich laufen (Semaphor gehalten)
|
||||||
cancelByKey map[string]context.CancelFunc
|
cancelByKey map[string]context.CancelFunc
|
||||||
}
|
}
|
||||||
@ -38,16 +43,71 @@ func NewPostWorkQueue(queueSize int, maxParallelFFmpeg int) *PostWorkQueue {
|
|||||||
maxParallelFFmpeg = 1 // Default: "nacheinander"
|
maxParallelFFmpeg = 1 // Default: "nacheinander"
|
||||||
}
|
}
|
||||||
|
|
||||||
return &PostWorkQueue{
|
pq := &PostWorkQueue{
|
||||||
q: make(chan PostWorkTask, queueSize),
|
|
||||||
ffmpegSem: make(chan struct{}, maxParallelFFmpeg),
|
ffmpegSem: make(chan struct{}, maxParallelFFmpeg),
|
||||||
|
maxQueued: queueSize,
|
||||||
|
|
||||||
|
queue: make([]PostWorkTask, 0, queueSize),
|
||||||
inflight: make(map[string]struct{}),
|
inflight: make(map[string]struct{}),
|
||||||
|
|
||||||
waitingKeys: make([]string, 0, queueSize),
|
waitingKeys: make([]string, 0, queueSize),
|
||||||
runningKeys: make(map[string]struct{}),
|
runningKeys: make(map[string]struct{}),
|
||||||
cancelByKey: make(map[string]context.CancelFunc),
|
cancelByKey: make(map[string]context.CancelFunc),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pq.cond = sync.NewCond(&pq.mu)
|
||||||
|
return pq
|
||||||
|
}
|
||||||
|
|
||||||
|
func lessPostWorkTask(a, b PostWorkTask) bool {
|
||||||
|
aHasSort := strings.TrimSpace(a.SortName) != ""
|
||||||
|
bHasSort := strings.TrimSpace(b.SortName) != ""
|
||||||
|
|
||||||
|
if aHasSort || bHasSort {
|
||||||
|
ab := a.SortBucket
|
||||||
|
bb := b.SortBucket
|
||||||
|
|
||||||
|
if !aHasSort {
|
||||||
|
ab = 999
|
||||||
|
}
|
||||||
|
if !bHasSort {
|
||||||
|
bb = 999
|
||||||
|
}
|
||||||
|
|
||||||
|
if ab != bb {
|
||||||
|
return ab < bb
|
||||||
|
}
|
||||||
|
|
||||||
|
an := strings.ToLower(strings.TrimSpace(a.SortName))
|
||||||
|
bn := strings.ToLower(strings.TrimSpace(b.SortName))
|
||||||
|
if an != bn {
|
||||||
|
return an < bn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !a.Added.Equal(b.Added) {
|
||||||
|
return a.Added.Before(b.Added)
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.Key < b.Key
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsWaitingKey(keys []string, key string) bool {
|
||||||
|
for _, k := range keys {
|
||||||
|
if k == key {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeQueuedTaskLocked(tasks []PostWorkTask, key string) []PostWorkTask {
|
||||||
|
for i, t := range tasks {
|
||||||
|
if t.Key == key {
|
||||||
|
return append(tasks[:i], tasks[i+1:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tasks
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enqueue dedupliziert nach Key (damit du nicht durch Events doppelt queue-st)
|
// Enqueue dedupliziert nach Key (damit du nicht durch Events doppelt queue-st)
|
||||||
@ -55,31 +115,41 @@ func (pq *PostWorkQueue) Enqueue(task PostWorkTask) bool {
|
|||||||
if task.Key == "" || task.Run == nil {
|
if task.Key == "" || task.Run == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if task.Added.IsZero() {
|
||||||
|
task.Added = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
pq.mu.Lock()
|
pq.mu.Lock()
|
||||||
|
defer pq.mu.Unlock()
|
||||||
|
|
||||||
if _, ok := pq.inflight[task.Key]; ok {
|
if _, ok := pq.inflight[task.Key]; ok {
|
||||||
pq.mu.Unlock()
|
|
||||||
return false // schon queued oder läuft
|
return false // schon queued oder läuft
|
||||||
}
|
}
|
||||||
pq.inflight[task.Key] = struct{}{}
|
if pq.maxQueued > 0 && len(pq.queue) >= pq.maxQueued {
|
||||||
pq.waitingKeys = append(pq.waitingKeys, task.Key) // ✅ queued für UI
|
|
||||||
pq.queued++
|
|
||||||
pq.mu.Unlock()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case pq.q <- task:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
// Queue voll -> inflight zurückrollen
|
|
||||||
pq.mu.Lock()
|
|
||||||
delete(pq.inflight, task.Key)
|
|
||||||
if pq.queued > 0 {
|
|
||||||
pq.queued--
|
|
||||||
}
|
|
||||||
pq.removeWaitingKeyLocked(task.Key)
|
|
||||||
pq.mu.Unlock()
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pq.inflight[task.Key] = struct{}{}
|
||||||
|
pq.queued++
|
||||||
|
|
||||||
|
insertAt := len(pq.queue)
|
||||||
|
for i, existing := range pq.queue {
|
||||||
|
if lessPostWorkTask(task, existing) {
|
||||||
|
insertAt = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pq.queue = append(pq.queue, PostWorkTask{})
|
||||||
|
copy(pq.queue[insertAt+1:], pq.queue[insertAt:])
|
||||||
|
pq.queue[insertAt] = task
|
||||||
|
|
||||||
|
pq.waitingKeys = append(pq.waitingKeys, "")
|
||||||
|
copy(pq.waitingKeys[insertAt+1:], pq.waitingKeys[insertAt:])
|
||||||
|
pq.waitingKeys[insertAt] = task.Key
|
||||||
|
|
||||||
|
pq.cond.Signal()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pq *PostWorkQueue) Cancel(key string) bool {
|
func (pq *PostWorkQueue) Cancel(key string) bool {
|
||||||
@ -138,27 +208,30 @@ func (pq *PostWorkQueue) RemoveQueued(key string) bool {
|
|||||||
pq.queued--
|
pq.queued--
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pq.queue = removeQueuedTaskLocked(pq.queue, key)
|
||||||
|
|
||||||
// Das Task-Element bleibt evtl. noch im Channel liegen.
|
// Das Task-Element bleibt evtl. noch im Channel liegen.
|
||||||
// Beim Worker-Start muss es dann erkannt und übersprungen werden.
|
// Beim Worker-Start muss es dann erkannt und übersprungen werden.
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pq *PostWorkQueue) workerLoop(id int) {
|
func (pq *PostWorkQueue) workerLoop(id int) {
|
||||||
for task := range pq.q {
|
for {
|
||||||
|
pq.mu.Lock()
|
||||||
|
for len(pq.queue) == 0 {
|
||||||
|
pq.cond.Wait()
|
||||||
|
}
|
||||||
|
task := pq.queue[0]
|
||||||
|
pq.queue = pq.queue[1:]
|
||||||
|
pq.mu.Unlock()
|
||||||
|
|
||||||
pq.mu.Lock()
|
pq.mu.Lock()
|
||||||
_, stillInflight := pq.inflight[task.Key]
|
_, stillInflight := pq.inflight[task.Key]
|
||||||
_, alreadyRunning := pq.runningKeys[task.Key]
|
_, alreadyRunning := pq.runningKeys[task.Key]
|
||||||
|
waitingFound := containsWaitingKey(pq.waitingKeys, task.Key)
|
||||||
waitingFound := false
|
|
||||||
for _, k := range pq.waitingKeys {
|
|
||||||
if k == task.Key {
|
|
||||||
waitingFound = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pq.mu.Unlock()
|
pq.mu.Unlock()
|
||||||
|
|
||||||
// queued Job wurde zwischenzeitlich entfernt -> Task aus dem Channel verwerfen
|
// queued Job wurde zwischenzeitlich entfernt -> verwerfen
|
||||||
if !stillInflight || (!waitingFound && !alreadyRunning) {
|
if !stillInflight || (!waitingFound && !alreadyRunning) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -188,7 +261,6 @@ func (pq *PostWorkQueue) workerLoop(id int) {
|
|||||||
|
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// ffmpeg/heavy-slot holen
|
|
||||||
select {
|
select {
|
||||||
case pq.ffmpegSem <- struct{}{}:
|
case pq.ffmpegSem <- struct{}{}:
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@ -198,7 +270,6 @@ func (pq *PostWorkQueue) workerLoop(id int) {
|
|||||||
<-pq.ffmpegSem
|
<-pq.ffmpegSem
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// jetzt wirklich von waiting -> running
|
|
||||||
pq.mu.Lock()
|
pq.mu.Lock()
|
||||||
pq.removeWaitingKeyLocked(task.Key)
|
pq.removeWaitingKeyLocked(task.Key)
|
||||||
pq.runningKeys[task.Key] = struct{}{}
|
pq.runningKeys[task.Key] = struct{}{}
|
||||||
@ -277,7 +348,7 @@ func (pq *PostWorkQueue) StatusForKey(key string) PostWorkKeyStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// global (oder in deinem app struct halten)
|
// global (oder in deinem app struct halten)
|
||||||
var postWorkQ = NewPostWorkQueue(512, 2) // schneller kritischer Pfad
|
var postWorkQ = NewPostWorkQueue(512, 1) // schneller kritischer Pfad
|
||||||
var enrichQ = NewPostWorkQueue(512, 1) // langsame Hintergrundaufgaben
|
var enrichQ = NewPostWorkQueue(512, 1) // langsame Hintergrundaufgaben
|
||||||
|
|
||||||
// --- Status Refresher (ehemals postwork_refresh.go) ---
|
// --- Status Refresher (ehemals postwork_refresh.go) ---
|
||||||
@ -320,6 +391,26 @@ func clearEnrichStatusSnapshot(key string) {
|
|||||||
enrichStatusLastMu.Unlock()
|
enrichStatusLastMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func waitUntilEnrichMayRun(ctx context.Context) error {
|
||||||
|
t := time.NewTicker(2 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
waiting, running, _ := postWorkQ.Stats()
|
||||||
|
|
||||||
|
// enrich erst starten, wenn keine primäre Postwork mehr läuft
|
||||||
|
if waiting == 0 && running == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-t.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func startEnrichStatusRefresher() {
|
func startEnrichStatusRefresher() {
|
||||||
t := time.NewTicker(2 * time.Second)
|
t := time.NewTicker(2 * time.Second)
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
@ -18,7 +17,6 @@ import (
|
|||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
"image/png"
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@ -32,6 +30,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/image/font"
|
"golang.org/x/image/font"
|
||||||
@ -1194,6 +1193,10 @@ func extractLastFrameJPG(path string) ([]byte, error) {
|
|||||||
"-f", "image2pipe",
|
"-f", "image2pipe",
|
||||||
"pipe:1",
|
"pipe:1",
|
||||||
)
|
)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||||||
|
}
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
@ -1233,6 +1236,10 @@ func extractFrameAtTimeJPG(path string, seconds float64) ([]byte, error) {
|
|||||||
"-f", "image2pipe",
|
"-f", "image2pipe",
|
||||||
"pipe:1",
|
"pipe:1",
|
||||||
)
|
)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||||||
|
}
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
@ -1277,6 +1284,10 @@ func extractLastFrameJPGScaled(path string, width int, quality int) ([]byte, err
|
|||||||
"-f", "image2pipe",
|
"-f", "image2pipe",
|
||||||
"pipe:1",
|
"pipe:1",
|
||||||
)
|
)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||||||
|
}
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
@ -1312,6 +1323,10 @@ func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, er
|
|||||||
"-f", "image2pipe",
|
"-f", "image2pipe",
|
||||||
"pipe:1",
|
"pipe:1",
|
||||||
)
|
)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||||||
|
}
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
@ -1361,6 +1376,10 @@ func extractLiveFrameFromM3U8ThumbJPG(ctx context.Context, m3u8URL, cookie, user
|
|||||||
)
|
)
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||||||
|
}
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
@ -2163,525 +2182,3 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
|
|||||||
_ = atomicWriteFile(thumbPath, img)
|
_ = atomicWriteFile(thumbPath, img)
|
||||||
servePreviewJPGBytes(w, img)
|
servePreviewJPGBytes(w, img)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Teaser generation (used by /api/generated/teaser)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
const minSegmentDuration = 0.75
|
|
||||||
const defaultTeaserSegments = 12
|
|
||||||
|
|
||||||
type TeaserPreviewOptions struct {
|
|
||||||
Segments int
|
|
||||||
SegmentDuration float64
|
|
||||||
Width int
|
|
||||||
Preset string
|
|
||||||
CRF int
|
|
||||||
Audio bool
|
|
||||||
AudioBitrate string
|
|
||||||
UseVsync2 bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o TeaserPreviewOptions) stepSizeAndOffset(dur float64) (float64, float64) {
|
|
||||||
if dur <= 0 {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
n := o.Segments
|
|
||||||
if n < 1 {
|
|
||||||
n = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
segDur := o.SegmentDuration
|
|
||||||
if segDur <= 0 {
|
|
||||||
segDur = 1
|
|
||||||
}
|
|
||||||
if segDur < minSegmentDuration {
|
|
||||||
segDur = minSegmentDuration
|
|
||||||
}
|
|
||||||
|
|
||||||
maxStart := dur - 0.05 - segDur
|
|
||||||
if maxStart < 0 {
|
|
||||||
maxStart = 0
|
|
||||||
}
|
|
||||||
if n == 1 {
|
|
||||||
return 0, maxStart * 0.5
|
|
||||||
}
|
|
||||||
|
|
||||||
margin := 0.05 * maxStart
|
|
||||||
if margin < 0 {
|
|
||||||
margin = 0
|
|
||||||
}
|
|
||||||
span := maxStart - 2*margin
|
|
||||||
if span < 0 {
|
|
||||||
span = maxStart
|
|
||||||
margin = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
step := 0.0
|
|
||||||
if n > 1 {
|
|
||||||
step = span / float64(n-1)
|
|
||||||
}
|
|
||||||
return step, margin
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateTeaserClipsMP4(ctx context.Context, srcPath, outPath string, clipLenSec float64, maxClips int) error {
|
|
||||||
return generateTeaserClipsMP4WithProgress(ctx, srcPath, outPath, clipLenSec, maxClips, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateTeaserClipsMP4WithProgress(ctx context.Context, srcPath, outPath string, clipLenSec float64, maxClips int, onRatio func(r float64)) error {
|
|
||||||
opts := TeaserPreviewOptions{
|
|
||||||
Segments: maxClips,
|
|
||||||
SegmentDuration: clipLenSec,
|
|
||||||
Width: 640,
|
|
||||||
Preset: "veryfast",
|
|
||||||
CRF: 21,
|
|
||||||
Audio: true,
|
|
||||||
AudioBitrate: "128k",
|
|
||||||
UseVsync2: false,
|
|
||||||
}
|
|
||||||
return generateTeaserPreviewMP4WithProgress(ctx, srcPath, outPath, opts, onRatio)
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateTeaserChunkMP4(ctx context.Context, src, out string, start, dur float64, opts TeaserPreviewOptions) error {
|
|
||||||
opts.Audio = true
|
|
||||||
|
|
||||||
tmp := strings.TrimSuffix(out, ".mp4") + ".part.mp4"
|
|
||||||
segDur := dur
|
|
||||||
if segDur < minSegmentDuration {
|
|
||||||
segDur = minSegmentDuration
|
|
||||||
}
|
|
||||||
|
|
||||||
args := []string{"-y", "-hide_banner", "-loglevel", "error"}
|
|
||||||
args = append(args, ffmpegInputTol...)
|
|
||||||
args = append(args,
|
|
||||||
"-ss", fmt.Sprintf("%.3f", start),
|
|
||||||
"-t", fmt.Sprintf("%.3f", segDur),
|
|
||||||
"-i", src,
|
|
||||||
"-map", "0:v:0",
|
|
||||||
"-c:v", "libx264",
|
|
||||||
"-pix_fmt", "yuv420p",
|
|
||||||
"-profile:v", "high",
|
|
||||||
"-level", "4.2",
|
|
||||||
"-preset", opts.Preset,
|
|
||||||
"-crf", strconv.Itoa(opts.CRF),
|
|
||||||
"-threads", "2",
|
|
||||||
)
|
|
||||||
|
|
||||||
if opts.UseVsync2 {
|
|
||||||
args = append(args, "-vsync", "2")
|
|
||||||
}
|
|
||||||
|
|
||||||
args = append(args,
|
|
||||||
"-map", "0:a:0",
|
|
||||||
"-c:a", "aac",
|
|
||||||
"-b:a", opts.AudioBitrate,
|
|
||||||
"-ac", "2",
|
|
||||||
"-shortest",
|
|
||||||
)
|
|
||||||
|
|
||||||
args = append(args, "-movflags", "+faststart", tmp)
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
_ = os.Remove(tmp)
|
|
||||||
return fmt.Errorf("ffmpeg teaser chunk failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
|
|
||||||
}
|
|
||||||
_ = os.Remove(out)
|
|
||||||
return os.Rename(tmp, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
func computeTeaserStarts(dur float64, opts TeaserPreviewOptions) (starts []float64, segDur float64, usedSegments int) {
|
|
||||||
if opts.SegmentDuration <= 0 {
|
|
||||||
opts.SegmentDuration = 1
|
|
||||||
}
|
|
||||||
if opts.Segments <= 0 {
|
|
||||||
opts.Segments = defaultTeaserSegments
|
|
||||||
}
|
|
||||||
segDur = opts.SegmentDuration
|
|
||||||
if segDur < minSegmentDuration {
|
|
||||||
segDur = minSegmentDuration
|
|
||||||
}
|
|
||||||
|
|
||||||
if dur > 0 && dur < segDur*float64(opts.Segments) {
|
|
||||||
opts.Segments = 1
|
|
||||||
segDur = dur
|
|
||||||
}
|
|
||||||
|
|
||||||
usedSegments = opts.Segments
|
|
||||||
if !(dur > 0) {
|
|
||||||
return []float64{0}, segDur, 1
|
|
||||||
}
|
|
||||||
|
|
||||||
stepSize, offset := opts.stepSizeAndOffset(dur)
|
|
||||||
starts = make([]float64, 0, opts.Segments)
|
|
||||||
for i := 0; i < opts.Segments; i++ {
|
|
||||||
t := offset + float64(i)*stepSize
|
|
||||||
maxStart := math.Max(0, dur-0.05-segDur)
|
|
||||||
if t < 0 {
|
|
||||||
t = 0
|
|
||||||
}
|
|
||||||
if t > maxStart {
|
|
||||||
t = maxStart
|
|
||||||
}
|
|
||||||
if t < 0.05 {
|
|
||||||
t = 0.05
|
|
||||||
}
|
|
||||||
starts = append(starts, t)
|
|
||||||
}
|
|
||||||
return starts, segDur, usedSegments
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateTeaserPreviewMP4WithProgress(ctx context.Context, srcPath, outPath string, opts TeaserPreviewOptions, onRatio func(r float64)) error {
|
|
||||||
opts.Audio = true
|
|
||||||
if opts.SegmentDuration <= 0 {
|
|
||||||
opts.SegmentDuration = 1
|
|
||||||
}
|
|
||||||
if opts.Segments <= 0 {
|
|
||||||
opts.Segments = defaultTeaserSegments
|
|
||||||
}
|
|
||||||
if opts.Width <= 0 {
|
|
||||||
opts.Width = 640
|
|
||||||
}
|
|
||||||
if opts.Preset == "" {
|
|
||||||
opts.Preset = "veryfast"
|
|
||||||
}
|
|
||||||
if opts.CRF <= 0 {
|
|
||||||
opts.CRF = 21
|
|
||||||
}
|
|
||||||
if opts.AudioBitrate == "" {
|
|
||||||
opts.AudioBitrate = "128k"
|
|
||||||
}
|
|
||||||
segDur := opts.SegmentDuration
|
|
||||||
if segDur < minSegmentDuration {
|
|
||||||
segDur = minSegmentDuration
|
|
||||||
}
|
|
||||||
|
|
||||||
dur, _ := durationSecondsCached(ctx, srcPath)
|
|
||||||
if dur > 0 && dur < segDur*float64(opts.Segments) {
|
|
||||||
opts.Segments = 1
|
|
||||||
segDur = dur
|
|
||||||
}
|
|
||||||
|
|
||||||
if !(dur > 0) {
|
|
||||||
if onRatio != nil {
|
|
||||||
onRatio(0)
|
|
||||||
}
|
|
||||||
err := generateTeaserChunkMP4(ctx, srcPath, outPath, 0, math.Min(8, segDur), opts)
|
|
||||||
if onRatio != nil {
|
|
||||||
onRatio(1)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
starts, segDurComputed, _ := computeTeaserStarts(dur, opts)
|
|
||||||
segDur = segDurComputed
|
|
||||||
|
|
||||||
expectedOutSec := float64(len(starts)) * segDur
|
|
||||||
tmp := strings.TrimSuffix(outPath, ".mp4") + ".part.mp4"
|
|
||||||
|
|
||||||
args := []string{"-y", "-nostats", "-progress", "pipe:1", "-hide_banner", "-loglevel", "error"}
|
|
||||||
for _, t := range starts {
|
|
||||||
args = append(args, ffmpegInputTol...)
|
|
||||||
args = append(args, "-ss", fmt.Sprintf("%.3f", t), "-t", fmt.Sprintf("%.3f", segDur), "-i", srcPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
var fc strings.Builder
|
|
||||||
for i := range starts {
|
|
||||||
fmt.Fprintf(&fc, "[%d:v]scale=%d:-2,setsar=1,setpts=PTS-STARTPTS[v%d];", i, opts.Width, i)
|
|
||||||
fmt.Fprintf(&fc, "[%d:a]aresample=48000,aformat=channel_layouts=stereo,asetpts=PTS-STARTPTS[a%d];", i, i)
|
|
||||||
}
|
|
||||||
for i := range starts {
|
|
||||||
fmt.Fprintf(&fc, "[v%d][a%d]", i, i)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&fc, "concat=n=%d:v=1:a=1[v][a]", len(starts))
|
|
||||||
|
|
||||||
args = append(args, "-filter_complex", fc.String())
|
|
||||||
args = append(args, "-map", "[v]", "-map", "[a]")
|
|
||||||
args = append(args,
|
|
||||||
"-c:v", "libx264",
|
|
||||||
"-pix_fmt", "yuv420p",
|
|
||||||
"-profile:v", "high",
|
|
||||||
"-level", "4.2",
|
|
||||||
"-preset", opts.Preset,
|
|
||||||
"-crf", strconv.Itoa(opts.CRF),
|
|
||||||
"-threads", "2",
|
|
||||||
)
|
|
||||||
if opts.UseVsync2 {
|
|
||||||
args = append(args, "-vsync", "2")
|
|
||||||
}
|
|
||||||
args = append(args,
|
|
||||||
"-c:a", "aac",
|
|
||||||
"-b:a", opts.AudioBitrate,
|
|
||||||
"-ac", "2",
|
|
||||||
"-shortest",
|
|
||||||
)
|
|
||||||
args = append(args, "-movflags", "+faststart", tmp)
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
|
||||||
stdout, err := cmd.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
sc := bufio.NewScanner(stdout)
|
|
||||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
||||||
|
|
||||||
var outSec float64
|
|
||||||
var lastSent float64
|
|
||||||
var lastAt time.Time
|
|
||||||
|
|
||||||
send := func(outSec float64, force bool) {
|
|
||||||
if onRatio == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if expectedOutSec > 0 && outSec > 0 {
|
|
||||||
r := outSec / expectedOutSec
|
|
||||||
if r < 0 {
|
|
||||||
r = 0
|
|
||||||
}
|
|
||||||
if r > 1 {
|
|
||||||
r = 1
|
|
||||||
}
|
|
||||||
if r-lastSent < 0.01 && !force {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !lastAt.IsZero() && time.Since(lastAt) < 150*time.Millisecond && !force {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lastSent = r
|
|
||||||
lastAt = time.Now()
|
|
||||||
onRatio(r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if force {
|
|
||||||
onRatio(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for sc.Scan() {
|
|
||||||
line := strings.TrimSpace(sc.Text())
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parts := strings.SplitN(line, "=", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
k, v := parts[0], parts[1]
|
|
||||||
switch k {
|
|
||||||
case "out_time_ms":
|
|
||||||
if n, perr := strconv.ParseInt(strings.TrimSpace(v), 10, 64); perr == nil && n > 0 {
|
|
||||||
outSec = float64(n) / 1_000_000.0
|
|
||||||
send(outSec, false)
|
|
||||||
}
|
|
||||||
case "out_time":
|
|
||||||
if s := parseFFmpegOutTime(v); s > 0 {
|
|
||||||
outSec = s
|
|
||||||
send(outSec, false)
|
|
||||||
}
|
|
||||||
case "progress":
|
|
||||||
if strings.TrimSpace(v) == "end" {
|
|
||||||
send(outSec, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := cmd.Wait(); err != nil {
|
|
||||||
_ = os.Remove(tmp)
|
|
||||||
return fmt.Errorf("ffmpeg teaser preview failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = os.Remove(outPath)
|
|
||||||
return os.Rename(tmp, outPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func serveTeaserFile(w http.ResponseWriter, r *http.Request, path string) {
|
|
||||||
f, err := openForReadShareDelete(path)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "datei öffnen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
fi, err := f.Stat()
|
|
||||||
if err != nil || fi.IsDir() || fi.Size() == 0 {
|
|
||||||
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Cache-Control", "public, max-age=31536000")
|
|
||||||
w.Header().Set("Content-Type", "video/mp4")
|
|
||||||
http.ServeContent(w, r, filepath.Base(path), fi.ModTime(), f)
|
|
||||||
}
|
|
||||||
|
|
||||||
// tolerante Input-Flags für kaputte/abgeschnittene H264/TS Streams
|
|
||||||
var ffmpegInputTol = []string{
|
|
||||||
"-fflags", "+discardcorrupt+genpts",
|
|
||||||
"-err_detect", "ignore_err",
|
|
||||||
"-max_error_rate", "1.0",
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateTeaserMP4(ctx context.Context, srcPath, outPath string, startSec, durSec float64) error {
|
|
||||||
if durSec <= 0 {
|
|
||||||
durSec = 8
|
|
||||||
}
|
|
||||||
if startSec < 0 {
|
|
||||||
startSec = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// temp schreiben -> rename
|
|
||||||
tmp := outPath + ".tmp.mp4"
|
|
||||||
|
|
||||||
args := []string{
|
|
||||||
"-y",
|
|
||||||
"-hide_banner",
|
|
||||||
"-loglevel", "error",
|
|
||||||
}
|
|
||||||
args = append(args, ffmpegInputTol...)
|
|
||||||
args = append(args,
|
|
||||||
"-ss", fmt.Sprintf("%.3f", startSec),
|
|
||||||
"-i", srcPath,
|
|
||||||
"-t", fmt.Sprintf("%.3f", durSec),
|
|
||||||
|
|
||||||
// Video
|
|
||||||
"-vf", "scale=720:-2",
|
|
||||||
"-map", "0:v:0",
|
|
||||||
|
|
||||||
// Audio (optional: falls kein Audio vorhanden ist, bricht ffmpeg NICHT ab)
|
|
||||||
"-map", "0:a:0",
|
|
||||||
"-c:a", "aac",
|
|
||||||
"-b:a", "128k",
|
|
||||||
"-ac", "2",
|
|
||||||
|
|
||||||
"-c:v", "libx264",
|
|
||||||
"-preset", "veryfast",
|
|
||||||
"-threads", "2",
|
|
||||||
"-crf", "28",
|
|
||||||
"-pix_fmt", "yuv420p",
|
|
||||||
|
|
||||||
// Wenn Audio minimal kürzer/länger ist, sauber beenden
|
|
||||||
"-shortest",
|
|
||||||
|
|
||||||
"-movflags", "+faststart",
|
|
||||||
"-f", "mp4",
|
|
||||||
"-threads", "2",
|
|
||||||
tmp,
|
|
||||||
)
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
|
||||||
_ = os.Remove(tmp)
|
|
||||||
return fmt.Errorf("ffmpeg teaser failed: %v (%s)", err, strings.TrimSpace(string(out)))
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = os.Remove(outPath)
|
|
||||||
return os.Rename(tmp, outPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func generatedTeaser(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
|
||||||
if id == "" {
|
|
||||||
http.Error(w, "id fehlt", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var err error
|
|
||||||
id, err = sanitizeID(id)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
outPath, err := findFinishedFileByID(id)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ensureGeneratedDirs(); err != nil {
|
|
||||||
http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
assetID := stripHotPrefix(id)
|
|
||||||
if assetID == "" {
|
|
||||||
assetID = id
|
|
||||||
}
|
|
||||||
|
|
||||||
assetDir, err := ensureGeneratedDir(assetID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
previewPath := filepath.Join(assetDir, "preview.mp4")
|
|
||||||
|
|
||||||
// ✅ NEU: noGenerate=1 -> niemals on-the-fly erzeugen, nur liefern wenn vorhanden
|
|
||||||
qNoGen := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("noGenerate")))
|
|
||||||
noGen := qNoGen == "1" || qNoGen == "true" || qNoGen == "yes"
|
|
||||||
|
|
||||||
// Cache hit (neu)
|
|
||||||
if fi, err := os.Stat(previewPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
|
|
||||||
serveTeaserFile(w, r, previewPath)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy: generated/teaser/<id>_teaser.mp4 oder <id>.mp4
|
|
||||||
if teaserLegacy, _ := generatedTeaserRoot(); strings.TrimSpace(teaserLegacy) != "" {
|
|
||||||
cids := []string{assetID, id}
|
|
||||||
for _, cid := range cids {
|
|
||||||
candidates := []string{
|
|
||||||
filepath.Join(teaserLegacy, cid+"_teaser.mp4"),
|
|
||||||
filepath.Join(teaserLegacy, cid+".mp4"),
|
|
||||||
}
|
|
||||||
for _, c := range candidates {
|
|
||||||
if fi, err := os.Stat(c); err == nil && !fi.IsDir() && fi.Size() > 0 {
|
|
||||||
if _, err2 := os.Stat(previewPath); os.IsNotExist(err2) {
|
|
||||||
_ = os.MkdirAll(filepath.Dir(previewPath), 0o755)
|
|
||||||
_ = os.Rename(c, previewPath)
|
|
||||||
}
|
|
||||||
if fi2, err2 := os.Stat(previewPath); err2 == nil && !fi2.IsDir() && fi2.Size() > 0 {
|
|
||||||
serveTeaserFile(w, r, previewPath)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
serveTeaserFile(w, r, c)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ NEU: wenn noGenerate aktiv und bisher kein Teaser gefunden -> 404
|
|
||||||
if noGen {
|
|
||||||
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Neu erzeugen
|
|
||||||
if err := genSem.Acquire(r.Context()); err != nil {
|
|
||||||
http.Error(w, "abgebrochen: "+err.Error(), http.StatusRequestTimeout)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer genSem.Release()
|
|
||||||
|
|
||||||
genCtx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if err := generateTeaserClipsMP4(genCtx, outPath, previewPath, 1.0, 18); err != nil {
|
|
||||||
// Fallback: einzelner kurzer Teaser ab Anfang (trifft seltener kaputte Stellen)
|
|
||||||
if err2 := generateTeaserMP4(genCtx, outPath, previewPath, 0, 8); err2 != nil {
|
|
||||||
http.Error(w, "konnte preview nicht erzeugen: "+err.Error()+" (fallback ebenfalls fehlgeschlagen: "+err2.Error()+")", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
serveTeaserFile(w, r, previewPath)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -248,8 +248,8 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// laufende/queued Nacharbeit abbrechen
|
// laufende/queued Nacharbeit abbrechen + Datei freigeben
|
||||||
cancelDeferredEnrichForFile(file)
|
releaseFileForMutation(file)
|
||||||
|
|
||||||
target, from, fi, err := resolveDoneFileByName(doneAbs, file)
|
target, from, fi, err := resolveDoneFileByName(doneAbs, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -391,7 +391,7 @@ func handleKeepMany(w http.ResponseWriter, r *http.Request) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelDeferredEnrichForFile(file)
|
releaseFileForMutation(file)
|
||||||
|
|
||||||
src, from, fi, err := resolveDoneFileByName(doneAbs, file)
|
src, from, fi, err := resolveDoneFileByName(doneAbs, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -932,9 +932,7 @@ func applyPreviewSpriteTruthToRecordJobMeta(j *RecordJob) {
|
|||||||
if outPath == "" {
|
if outPath == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
base := filepath.Base(outPath)
|
id := canonicalAssetIDFromName(outPath)
|
||||||
id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base)))
|
|
||||||
id = strings.TrimSpace(id)
|
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -1009,8 +1007,7 @@ func applyFinishedPhaseTruthToRecordJobMeta(j *RecordJob) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
base := filepath.Base(outPath)
|
id := canonicalAssetIDFromName(outPath)
|
||||||
id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base)))
|
|
||||||
id = strings.TrimSpace(id)
|
id = strings.TrimSpace(id)
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return
|
return
|
||||||
@ -1348,8 +1345,7 @@ func recordRemoveQueuedPostwork(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = removeWithRetry(out)
|
_ = removeWithRetry(out)
|
||||||
purgeDurationCacheForPath(out)
|
purgeDurationCacheForPath(out)
|
||||||
|
|
||||||
base := filepath.Base(out)
|
id1 := canonicalAssetIDFromName(out)
|
||||||
id1 := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base)))
|
|
||||||
if strings.TrimSpace(id1) != "" {
|
if strings.TrimSpace(id1) != "" {
|
||||||
removeGeneratedForID(id1)
|
removeGeneratedForID(id1)
|
||||||
}
|
}
|
||||||
@ -1409,22 +1405,8 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// ---- TS -> MP4 (on-demand remux) ----
|
// ---- TS -> MP4 (on-demand remux) ----
|
||||||
if strings.ToLower(filepath.Ext(outPath)) == ".ts" {
|
if strings.ToLower(filepath.Ext(outPath)) == ".ts" {
|
||||||
newOut, err := maybeRemuxTS(outPath)
|
writeErr(http.StatusConflict, "TS-Datei ist noch nicht nachbearbeitet und kann nicht direkt abgespielt werden")
|
||||||
if err != nil {
|
return
|
||||||
writeErr(http.StatusInternalServerError, "TS Remux fehlgeschlagen: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(newOut) == "" {
|
|
||||||
writeErr(http.StatusInternalServerError, "TS kann im Browser nicht abgespielt werden; Remux hat keine MP4 erzeugt")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
outPath = filepath.Clean(strings.TrimSpace(newOut))
|
|
||||||
|
|
||||||
fi, err := os.Stat(outPath)
|
|
||||||
if err != nil || fi == nil || fi.IsDir() || fi.Size() == 0 || strings.ToLower(filepath.Ext(outPath)) != ".mp4" {
|
|
||||||
writeErr(http.StatusInternalServerError, "Remux-Ergebnis ungültig")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Falls Datei ".mp4" heißt, aber eigentlich TS/HTML ist -> nicht als MP4 ausliefern
|
// ✅ Falls Datei ".mp4" heißt, aber eigentlich TS/HTML ist -> nicht als MP4 ausliefern
|
||||||
@ -1432,12 +1414,8 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
kind, _ := sniffVideoKind(outPath)
|
kind, _ := sniffVideoKind(outPath)
|
||||||
switch kind {
|
switch kind {
|
||||||
case "ts":
|
case "ts":
|
||||||
newOut, err := maybeRemuxTS(outPath)
|
writeErr(http.StatusConflict, "Datei hat .mp4-Endung, enthält aber TS-Daten und wird nicht on-demand remuxt")
|
||||||
if err != nil {
|
return
|
||||||
writeErr(http.StatusInternalServerError, "Datei ist TS (nur .mp4 benannt); Remux fehlgeschlagen: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
outPath = filepath.Clean(strings.TrimSpace(newOut))
|
|
||||||
case "html":
|
case "html":
|
||||||
writeErr(http.StatusInternalServerError, "Server liefert HTML statt Video (Pfad/Lookup prüfen)")
|
writeErr(http.StatusInternalServerError, "Server liefert HTML statt Video (Pfad/Lookup prüfen)")
|
||||||
return
|
return
|
||||||
@ -1592,6 +1570,30 @@ func normalizeQueryModel(raw string) string {
|
|||||||
return strings.ToLower(strings.TrimSpace(s))
|
return strings.ToLower(strings.TrimSpace(s))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isTemporaryDoneVideoName(name string) bool {
|
||||||
|
n := strings.ToLower(strings.TrimSpace(filepath.Base(name)))
|
||||||
|
if n == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// versteckte / temporäre Dateien wie:
|
||||||
|
// ".chaesoffel_04_22_2026__12-22-16.tmp.20260422_134354_000.mp4"
|
||||||
|
if strings.HasPrefix(n, ".") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// typische temporäre Marker
|
||||||
|
if strings.Contains(n, ".tmp.") ||
|
||||||
|
strings.HasSuffix(n, ".tmp.mp4") ||
|
||||||
|
strings.HasSuffix(n, ".tmp.ts") ||
|
||||||
|
strings.Contains(n, ".partial.") ||
|
||||||
|
strings.Contains(n, ".part.") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// buildDoneIndex: identical logic as your previous record_handlers.go (indexing done + keep)
|
// buildDoneIndex: identical logic as your previous record_handlers.go (indexing done + keep)
|
||||||
func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
|
func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
|
||||||
items := make([]doneIndexItem, 0, 2048)
|
items := make([]doneIndexItem, 0, 2048)
|
||||||
@ -1611,6 +1613,10 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
name := filepath.Base(full)
|
name := filepath.Base(full)
|
||||||
|
if isTemporaryDoneVideoName(name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(name))
|
ext := strings.ToLower(filepath.Ext(name))
|
||||||
if ext != ".mp4" && ext != ".ts" {
|
if ext != ".mp4" && ext != ".ts" {
|
||||||
return
|
return
|
||||||
@ -1625,7 +1631,17 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
|
|||||||
start := t
|
start := t
|
||||||
|
|
||||||
base := strings.TrimSuffix(name, filepath.Ext(name))
|
base := strings.TrimSuffix(name, filepath.Ext(name))
|
||||||
stem := strings.TrimPrefix(base, "HOT ")
|
|
||||||
|
// WICHTIG:
|
||||||
|
// stabile ID ohne HOT-Prefix, damit Rename HOT <-> non-HOT
|
||||||
|
// im Frontend nicht zu einem neuen React-Key führt.
|
||||||
|
stableID := stripHotPrefix(base)
|
||||||
|
if strings.TrimSpace(stableID) == "" {
|
||||||
|
stableID = base
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timestamp immer auf Basis des HOT-bereinigten Namens parsen
|
||||||
|
stem := stableID
|
||||||
if m := startedAtFromFilenameRe.FindStringSubmatch(stem); m != nil {
|
if m := startedAtFromFilenameRe.FindStringSubmatch(stem); m != nil {
|
||||||
mm, _ := strconv.Atoi(m[2])
|
mm, _ := strconv.Atoi(m[2])
|
||||||
dd, _ := strconv.Atoi(m[3])
|
dd, _ := strconv.Atoi(m[3])
|
||||||
@ -1671,7 +1687,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
|
|||||||
ended := t
|
ended := t
|
||||||
items = append(items, doneIndexItem{
|
items = append(items, doneIndexItem{
|
||||||
job: &RecordJob{
|
job: &RecordJob{
|
||||||
ID: base,
|
ID: stableID,
|
||||||
Output: full,
|
Output: full,
|
||||||
SourceURL: srcURL,
|
SourceURL: srcURL,
|
||||||
Status: JobFinished,
|
Status: JobFinished,
|
||||||
@ -1692,9 +1708,10 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e.IsDir() {
|
if e.IsDir() {
|
||||||
if strings.EqualFold(e.Name(), ".trash") {
|
if strings.EqualFold(e.Name(), ".trash") || strings.EqualFold(e.Name(), ".postwork_tmp") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if skipKeep && e.Name() == "keep" {
|
if skipKeep && e.Name() == "keep" {
|
||||||
@ -1731,6 +1748,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
|
|||||||
|
|
||||||
// done (ohne keep)
|
// done (ohne keep)
|
||||||
scanDir(doneAbs, true)
|
scanDir(doneAbs, true)
|
||||||
|
|
||||||
// keep
|
// keep
|
||||||
scanDir(filepath.Join(doneAbs, "keep"), false)
|
scanDir(filepath.Join(doneAbs, "keep"), false)
|
||||||
|
|
||||||
@ -1755,12 +1773,14 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
|
|||||||
B := items[idx[b]]
|
B := items[idx[b]]
|
||||||
|
|
||||||
ta, tb := A.endedAt, B.endedAt
|
ta, tb := A.endedAt, B.endedAt
|
||||||
|
|
||||||
switch sortMode {
|
switch sortMode {
|
||||||
case "completed_asc":
|
case "completed_asc":
|
||||||
if !ta.Equal(tb) {
|
if !ta.Equal(tb) {
|
||||||
return ta.Before(tb)
|
return ta.Before(tb)
|
||||||
}
|
}
|
||||||
return A.fileSort < B.fileSort
|
return A.fileSort < B.fileSort
|
||||||
|
|
||||||
case "completed_desc":
|
case "completed_desc":
|
||||||
if !ta.Equal(tb) {
|
if !ta.Equal(tb) {
|
||||||
return ta.After(tb)
|
return ta.After(tb)
|
||||||
@ -1775,6 +1795,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
|
|||||||
return ta.After(tb)
|
return ta.After(tb)
|
||||||
}
|
}
|
||||||
return A.fileSort < B.fileSort
|
return A.fileSort < B.fileSort
|
||||||
|
|
||||||
case "file_desc":
|
case "file_desc":
|
||||||
if A.fileSort != B.fileSort {
|
if A.fileSort != B.fileSort {
|
||||||
return A.fileSort > B.fileSort
|
return A.fileSort > B.fileSort
|
||||||
@ -1797,6 +1818,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
|
|||||||
return ta.After(tb)
|
return ta.After(tb)
|
||||||
}
|
}
|
||||||
return A.fileSort < B.fileSort
|
return A.fileSort < B.fileSort
|
||||||
|
|
||||||
case "duration_desc":
|
case "duration_desc":
|
||||||
da, okA := durationForSort(A)
|
da, okA := durationForSort(A)
|
||||||
db, okB := durationForSort(B)
|
db, okB := durationForSort(B)
|
||||||
@ -1819,6 +1841,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
|
|||||||
return ta.After(tb)
|
return ta.After(tb)
|
||||||
}
|
}
|
||||||
return A.fileSort < B.fileSort
|
return A.fileSort < B.fileSort
|
||||||
|
|
||||||
case "size_desc":
|
case "size_desc":
|
||||||
if A.job.SizeBytes != B.job.SizeBytes {
|
if A.job.SizeBytes != B.job.SizeBytes {
|
||||||
return A.job.SizeBytes > B.job.SizeBytes
|
return A.job.SizeBytes > B.job.SizeBytes
|
||||||
@ -1827,6 +1850,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
|
|||||||
return ta.After(tb)
|
return ta.After(tb)
|
||||||
}
|
}
|
||||||
return A.fileSort < B.fileSort
|
return A.fileSort < B.fileSort
|
||||||
|
|
||||||
default:
|
default:
|
||||||
if !ta.Equal(tb) {
|
if !ta.Equal(tb) {
|
||||||
return ta.After(tb)
|
return ta.After(tb)
|
||||||
@ -1844,6 +1868,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
|
|||||||
"duration_asc", "duration_desc",
|
"duration_asc", "duration_desc",
|
||||||
"size_asc", "size_desc",
|
"size_asc", "size_desc",
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range modes {
|
for _, m := range modes {
|
||||||
sortedIdx["0|"+m] = mkSorted(false, m)
|
sortedIdx["0|"+m] = mkSorted(false, m)
|
||||||
sortedIdx["1|"+m] = mkSorted(true, m)
|
sortedIdx["1|"+m] = mkSorted(true, m)
|
||||||
@ -1914,20 +1939,12 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
outPath := filepath.Clean(strings.TrimSpace(full))
|
outPath := filepath.Clean(strings.TrimSpace(full))
|
||||||
if strings.ToLower(filepath.Ext(outPath)) == ".ts" {
|
|
||||||
if newOut, rerr := maybeRemuxTS(outPath); rerr == nil && strings.TrimSpace(newOut) != "" {
|
|
||||||
outPath = filepath.Clean(strings.TrimSpace(newOut))
|
|
||||||
if nfi, serr := os.Stat(outPath); serr == nil && nfi != nil {
|
|
||||||
fi = nfi
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureMetaJSONForPlayback(r.Context(), outPath)
|
ensureMetaJSONForPlayback(r.Context(), outPath)
|
||||||
|
|
||||||
resp := doneMetaFileResp{File: filepath.Base(outPath)}
|
resp := doneMetaFileResp{File: filepath.Base(outPath)}
|
||||||
|
|
||||||
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(outPath), filepath.Ext(outPath)))
|
id := canonicalAssetIDFromName(outPath)
|
||||||
|
|
||||||
if strings.TrimSpace(id) != "" {
|
if strings.TrimSpace(id) != "" {
|
||||||
if mp, merr := generatedMetaFile(id); merr == nil && strings.TrimSpace(mp) != "" {
|
if mp, merr := generatedMetaFile(id); merr == nil && strings.TrimSpace(mp) != "" {
|
||||||
@ -2362,7 +2379,7 @@ func cancelDeferredEnrichForFile(file string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)))
|
id := canonicalAssetIDFromName(file)
|
||||||
id = strings.TrimSpace(id)
|
id = strings.TrimSpace(id)
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return
|
return
|
||||||
@ -2412,6 +2429,28 @@ func cancelDeferredEnrichForFile(file string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func releaseFileForMutation(file string) {
|
||||||
|
file = strings.TrimSpace(file)
|
||||||
|
if file == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assetsReleased := requestAssetsTaskSkip(file)
|
||||||
|
|
||||||
|
// Enrich für genau diese Datei stoppen / aus Queue nehmen
|
||||||
|
cancelDeferredEnrichForFile(file)
|
||||||
|
|
||||||
|
// WICHTIG:
|
||||||
|
// releaseFileTasks(file) darf NICHT mehr den globalen generate-assets Task abbrechen.
|
||||||
|
// Falls das dort aktuell passiert: rausnehmen.
|
||||||
|
resp := releaseFileTasks(file)
|
||||||
|
|
||||||
|
// Kurz warten, damit Handles der betroffenen Datei schließen
|
||||||
|
if assetsReleased || resp.ReleasedAny {
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
|
func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
|
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
|
||||||
http.Error(w, "Nur POST oder DELETE erlaubt", http.StatusMethodNotAllowed)
|
http.Error(w, "Nur POST oder DELETE erlaubt", http.StatusMethodNotAllowed)
|
||||||
@ -2428,8 +2467,7 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ laufende/queued späte Nacharbeiten für dieses Asset vorher stoppen
|
releaseFileForMutation(file)
|
||||||
cancelDeferredEnrichForFile(file)
|
|
||||||
|
|
||||||
s := getSettings()
|
s := getSettings()
|
||||||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||||
@ -2698,8 +2736,7 @@ func recordUnkeepVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ späte Nacharbeiten abbrechen, bevor die Datei aus keep zurück verschoben wird
|
releaseFileForMutation(file)
|
||||||
cancelDeferredEnrichForFile(file)
|
|
||||||
|
|
||||||
s := getSettings()
|
s := getSettings()
|
||||||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||||
@ -2771,8 +2808,7 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ späte Nacharbeiten abbrechen, bevor die Datei verschoben wird
|
releaseFileForMutation(file)
|
||||||
cancelDeferredEnrichForFile(file)
|
|
||||||
|
|
||||||
s := getSettings()
|
s := getSettings()
|
||||||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||||
@ -2880,7 +2916,7 @@ func recordToggleHot(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelDeferredEnrichForFile(file)
|
releaseFileForMutation(file)
|
||||||
|
|
||||||
s := getSettings()
|
s := getSettings()
|
||||||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -95,9 +97,32 @@ func RecordStream(
|
|||||||
|
|
||||||
const maxPlaylistRefreshes = 12
|
const maxPlaylistRefreshes = 12
|
||||||
refreshes := 0
|
refreshes := 0
|
||||||
|
attempt := 0
|
||||||
|
useChunkMerge := strings.EqualFold(filepath.Ext(outputPath), ".ts")
|
||||||
|
|
||||||
for {
|
for {
|
||||||
err = handleM3U8Mode(ctx, stream, outputPath, job, httpCookie, hc.userAgent, pageURL)
|
attempt++
|
||||||
|
|
||||||
|
attemptOut := outputPath
|
||||||
|
mergeAfterRun := false
|
||||||
|
|
||||||
|
// Nur ab dem ZWEITEN Versuch in Retry-Chunk schreiben.
|
||||||
|
// Der erste Versuch muss direkt in outputPath schreiben,
|
||||||
|
// damit der laufende Download eine echte Hauptdatei hat.
|
||||||
|
if useChunkMerge && attempt > 1 {
|
||||||
|
attemptOut = hlsRetryChunkPath(outputPath, attempt)
|
||||||
|
_ = os.Remove(attemptOut)
|
||||||
|
mergeAfterRun = true
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handleM3U8Mode(ctx, stream, attemptOut, job, httpCookie, hc.userAgent, pageURL)
|
||||||
|
|
||||||
|
if mergeAfterRun && fileExistsNonEmpty(attemptOut) {
|
||||||
|
if aerr := appendFileAndRemove(outputPath, attemptOut); aerr != nil {
|
||||||
|
return fmt.Errorf("retry-chunk merge failed: %w", aerr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -133,7 +158,6 @@ func RecordStream(
|
|||||||
|
|
||||||
newStream, rerr := loadFreshHLS()
|
newStream, rerr := loadFreshHLS()
|
||||||
if rerr != nil {
|
if rerr != nil {
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/grafov/m3u8"
|
"github.com/grafov/m3u8"
|
||||||
@ -334,6 +335,62 @@ func buildFFmpegInputHeaders(httpCookie, userAgent, referer string) string {
|
|||||||
return strings.Join(lines, "\r\n") + "\r\n"
|
return strings.Join(lines, "\r\n") + "\r\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hlsRetryChunkPath(outFile string, attempt int) string {
|
||||||
|
dir := filepath.Dir(outFile)
|
||||||
|
ext := filepath.Ext(outFile)
|
||||||
|
base := strings.TrimSuffix(filepath.Base(outFile), ext)
|
||||||
|
|
||||||
|
canonical := canonicalAssetIDFromName(base)
|
||||||
|
if canonical == "" {
|
||||||
|
canonical = base
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := time.Now().Format("20060102_150405_000")
|
||||||
|
return filepath.Join(dir, fmt.Sprintf(".%s.retry.%02d.%s%s", canonical, attempt, ts, ext))
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendFileAndRemove(dstPath, srcPath string) error {
|
||||||
|
srcPath = strings.TrimSpace(srcPath)
|
||||||
|
dstPath = strings.TrimSpace(dstPath)
|
||||||
|
|
||||||
|
if srcPath == "" || dstPath == "" {
|
||||||
|
return fmt.Errorf("append path leer")
|
||||||
|
}
|
||||||
|
if filepath.Clean(srcPath) == filepath.Clean(dstPath) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sfi, err := os.Stat(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if sfi == nil || sfi.IsDir() || sfi.Size() <= 0 {
|
||||||
|
_ = os.Remove(srcPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := os.Open(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(dst, src); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.Remove(srcPath)
|
||||||
|
}
|
||||||
|
|
||||||
func handleM3U8Mode(
|
func handleM3U8Mode(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
stream *selectedHLSStream,
|
stream *selectedHLSStream,
|
||||||
@ -439,6 +496,10 @@ func handleM3U8Mode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||||||
|
}
|
||||||
|
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
cmd.Stdout = io.Discard
|
cmd.Stdout = io.Discard
|
||||||
|
|||||||
@ -532,6 +532,27 @@ func requireNonEmptyRegularFile(path string, label string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildPostworkTempMP4Path(tsPath string) (string, error) {
|
||||||
|
s := getSettings()
|
||||||
|
|
||||||
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("doneDir auflösung fehlgeschlagen: %w", err)
|
||||||
|
}
|
||||||
|
doneAbs = strings.TrimSpace(doneAbs)
|
||||||
|
if doneAbs == "" {
|
||||||
|
return "", fmt.Errorf("doneDir ist leer")
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir := filepath.Join(doneAbs, ".postwork_tmp")
|
||||||
|
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("postwork tmp dir erstellen fehlgeschlagen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
base := strings.TrimSuffix(filepath.Base(strings.TrimSpace(tsPath)), filepath.Ext(tsPath)) + ".mp4"
|
||||||
|
return uniqueDestPath(tmpDir, base)
|
||||||
|
}
|
||||||
|
|
||||||
func runPrimaryPostworkPipeline(
|
func runPrimaryPostworkPipeline(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
job *RecordJob,
|
job *RecordJob,
|
||||||
@ -543,6 +564,9 @@ func runPrimaryPostworkPipeline(
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cleanupTSAfterMove := ""
|
||||||
|
removeTempOutOnMoveFailure := false
|
||||||
|
|
||||||
// 1) TS -> MP4 zuerst
|
// 1) TS -> MP4 zuerst
|
||||||
if strings.EqualFold(filepath.Ext(out), ".ts") {
|
if strings.EqualFold(filepath.Ext(out), ".ts") {
|
||||||
tsPath := out
|
tsPath := out
|
||||||
@ -557,8 +581,12 @@ func runPrimaryPostworkPipeline(
|
|||||||
}
|
}
|
||||||
|
|
||||||
inSize := fi.Size()
|
inSize := fi.Size()
|
||||||
mp4Path := strings.TrimSuffix(tsPath, filepath.Ext(tsPath)) + ".mp4"
|
|
||||||
_ = os.Remove(mp4Path)
|
mp4Path, err := buildPostworkTempMP4Path(tsPath)
|
||||||
|
if err != nil {
|
||||||
|
return tsPath, fmt.Errorf("postwork temp mp4 path failed: %w", err)
|
||||||
|
}
|
||||||
|
_ = removeWithRetry(mp4Path)
|
||||||
|
|
||||||
dur := 0.0
|
dur := 0.0
|
||||||
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||||
@ -587,6 +615,7 @@ func runPrimaryPostworkPipeline(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
_ = removeWithRetry(mp4Path)
|
||||||
return tsPath, fmt.Errorf("ts remux failed: %w", err)
|
return tsPath, fmt.Errorf("ts remux failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -595,20 +624,31 @@ func runPrimaryPostworkPipeline(
|
|||||||
return tsPath, err
|
return tsPath, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// best effort cleanup der alten TS
|
if err := validateVideoDecodesNearEnd(ctx, mp4Path); err != nil {
|
||||||
if err := removeWithRetry(tsPath); err != nil && !os.IsNotExist(err) {
|
fmt.Println("⚠️ remux mp4 decode validation failed, fallback to reencode:", err)
|
||||||
fmt.Println("⚠️ ts cleanup after remux:", err)
|
|
||||||
}
|
|
||||||
purgeDurationCacheForPath(tsPath)
|
|
||||||
|
|
||||||
|
_ = removeWithRetry(mp4Path)
|
||||||
|
|
||||||
|
if rerr := reencodeTSToMP4(ctx, tsPath, mp4Path); rerr != nil {
|
||||||
|
_ = removeWithRetry(mp4Path)
|
||||||
|
return tsPath, fmt.Errorf("remux invalid (%v), reencode fallback failed: %w", err, rerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err2 := requireNonEmptyRegularFile(mp4Path, "reencode result"); err2 != nil {
|
||||||
|
_ = removeWithRetry(mp4Path)
|
||||||
|
return tsPath, err2
|
||||||
|
}
|
||||||
|
|
||||||
|
if err2 := validateVideoDecodesNearEnd(ctx, mp4Path); err2 != nil {
|
||||||
|
_ = removeWithRetry(mp4Path)
|
||||||
|
return tsPath, fmt.Errorf("reencoded mp4 still invalid: %w", err2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TS erst NACH erfolgreichem Move löschen
|
||||||
|
cleanupTSAfterMove = tsPath
|
||||||
|
removeTempOutOnMoveFailure = true
|
||||||
out = mp4Path
|
out = mp4Path
|
||||||
|
|
||||||
if fi2, err := os.Stat(out); err == nil && fi2 != nil && !fi2.IsDir() {
|
|
||||||
jobsMu.Lock()
|
|
||||||
job.Output = out
|
|
||||||
job.SizeBytes = fi2.Size()
|
|
||||||
jobsMu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Danach nach done verschieben
|
// 2) Danach nach done verschieben
|
||||||
@ -616,20 +656,42 @@ func runPrimaryPostworkPipeline(
|
|||||||
setPhase("moving", 0)
|
setPhase("moving", 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fallbackOut := out
|
||||||
|
if cleanupTSAfterMove != "" {
|
||||||
|
fallbackOut = cleanupTSAfterMove
|
||||||
|
}
|
||||||
|
|
||||||
moved, err := moveToDoneDir(out)
|
moved, err := moveToDoneDir(out)
|
||||||
if err != nil || strings.TrimSpace(moved) == "" {
|
if err != nil || strings.TrimSpace(moved) == "" {
|
||||||
if err != nil {
|
if removeTempOutOnMoveFailure {
|
||||||
return out, fmt.Errorf("moveToDoneDir failed: %w", err)
|
_ = removeWithRetry(out)
|
||||||
|
purgeDurationCacheForPath(out)
|
||||||
}
|
}
|
||||||
return out, fmt.Errorf("moveToDoneDir returned empty path")
|
|
||||||
|
if err != nil {
|
||||||
|
return fallbackOut, fmt.Errorf("moveToDoneDir failed: %w", err)
|
||||||
|
}
|
||||||
|
return fallbackOut, fmt.Errorf("moveToDoneDir returned empty path")
|
||||||
}
|
}
|
||||||
|
|
||||||
out = strings.TrimSpace(moved)
|
out = strings.TrimSpace(moved)
|
||||||
|
|
||||||
if err := requireNonEmptyRegularFile(out, "moved output"); err != nil {
|
if err := requireNonEmptyRegularFile(out, "moved output"); err != nil {
|
||||||
|
if cleanupTSAfterMove != "" {
|
||||||
|
_ = removeWithRetry(out)
|
||||||
|
purgeDurationCacheForPath(out)
|
||||||
|
return cleanupTSAfterMove, err
|
||||||
|
}
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cleanupTSAfterMove != "" {
|
||||||
|
if err := removeWithRetry(cleanupTSAfterMove); err != nil && !os.IsNotExist(err) {
|
||||||
|
fmt.Println("⚠️ ts cleanup after successful move:", err)
|
||||||
|
}
|
||||||
|
purgeDurationCacheForPath(cleanupTSAfterMove)
|
||||||
|
}
|
||||||
|
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
job.Output = out
|
job.Output = out
|
||||||
if fi, err := os.Stat(out); err == nil && fi != nil && !fi.IsDir() {
|
if fi, err := os.Stat(out); err == nil && fi != nil && !fi.IsDir() {
|
||||||
@ -1209,7 +1271,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
truth := finishedPhaseTruthForID(assetID)
|
truth := assetsTruthForVideo(outPath)
|
||||||
|
|
||||||
// -------------------------------------------------
|
// -------------------------------------------------
|
||||||
// Fehlende Vorstufen nur nachziehen, NICHT alles neu
|
// Fehlende Vorstufen nur nachziehen, NICHT alles neu
|
||||||
@ -1222,7 +1284,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
|
|||||||
if cerr := abortIfCanceled(err, "postwork", "meta"); cerr != nil {
|
if cerr := abortIfCanceled(err, "postwork", "meta"); cerr != nil {
|
||||||
return cerr
|
return cerr
|
||||||
}
|
}
|
||||||
if err != nil || !okMeta || !finishedPhaseTruthForID(assetID).MetaReady {
|
if err != nil || !okMeta || !assetsTruthForVideo(outPath).MetaReady {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = fmt.Errorf("meta konnte nicht erzeugt werden")
|
err = fmt.Errorf("meta konnte nicht erzeugt werden")
|
||||||
}
|
}
|
||||||
@ -1231,7 +1293,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
|
|||||||
}
|
}
|
||||||
|
|
||||||
publishDeferredPhase(fileName, assetID, "postwork", "meta", "done", "Meta", nil)
|
publishDeferredPhase(fileName, assetID, "postwork", "meta", "done", "Meta", nil)
|
||||||
truth = finishedPhaseTruthForID(assetID)
|
truth = assetsTruthForVideo(outPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !truth.ThumbReady {
|
if !truth.ThumbReady {
|
||||||
@ -1241,7 +1303,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
|
|||||||
if cerr := abortIfCanceled(err, "postwork", "thumb"); cerr != nil {
|
if cerr := abortIfCanceled(err, "postwork", "thumb"); cerr != nil {
|
||||||
return cerr
|
return cerr
|
||||||
}
|
}
|
||||||
if err != nil || !finishedPhaseTruthForID(assetID).ThumbReady {
|
if err != nil || !assetsTruthForVideo(outPath).ThumbReady {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = fmt.Errorf("vorschaubild konnte nicht erzeugt werden")
|
err = fmt.Errorf("vorschaubild konnte nicht erzeugt werden")
|
||||||
}
|
}
|
||||||
@ -1250,7 +1312,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
|
|||||||
}
|
}
|
||||||
|
|
||||||
publishDeferredPhase(fileName, assetID, "postwork", "thumb", "done", "Vorschaubild", nil)
|
publishDeferredPhase(fileName, assetID, "postwork", "thumb", "done", "Vorschaubild", nil)
|
||||||
truth = finishedPhaseTruthForID(assetID)
|
truth = assetsTruthForVideo(outPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !truth.TeaserReady {
|
if !truth.TeaserReady {
|
||||||
@ -1260,7 +1322,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
|
|||||||
if cerr := abortIfCanceled(err, "postwork", "teaser"); cerr != nil {
|
if cerr := abortIfCanceled(err, "postwork", "teaser"); cerr != nil {
|
||||||
return cerr
|
return cerr
|
||||||
}
|
}
|
||||||
if err != nil || !finishedPhaseTruthForID(assetID).TeaserReady {
|
if err != nil || !assetsTruthForVideo(outPath).TeaserReady {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = fmt.Errorf("teaser konnte nicht erzeugt werden")
|
err = fmt.Errorf("teaser konnte nicht erzeugt werden")
|
||||||
}
|
}
|
||||||
@ -1269,7 +1331,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
|
|||||||
}
|
}
|
||||||
|
|
||||||
publishDeferredPhase(fileName, assetID, "postwork", "teaser", "done", "Teaser", nil)
|
publishDeferredPhase(fileName, assetID, "postwork", "teaser", "done", "Teaser", nil)
|
||||||
truth = finishedPhaseTruthForID(assetID)
|
truth = assetsTruthForVideo(outPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ab hier erst die langsamen Hintergrundphasen
|
// Ab hier erst die langsamen Hintergrundphasen
|
||||||
@ -1282,7 +1344,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
|
|||||||
if cerr := abortIfCanceled(err, "postwork", "sprites"); cerr != nil {
|
if cerr := abortIfCanceled(err, "postwork", "sprites"); cerr != nil {
|
||||||
return cerr
|
return cerr
|
||||||
}
|
}
|
||||||
if err != nil || !finishedPhaseTruthForID(assetID).SpritesReady {
|
if err != nil || !assetsTruthForVideo(outPath).SpritesReady {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = fmt.Errorf("sprites konnten nicht erzeugt werden")
|
err = fmt.Errorf("sprites konnten nicht erzeugt werden")
|
||||||
}
|
}
|
||||||
@ -1291,29 +1353,33 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st
|
|||||||
firstErr = err
|
firstErr = err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if _, checkErr := runTailBlackoutCheck(ctx, outPath); checkErr != nil {
|
||||||
|
fmt.Println("⚠️ sprite tail blackout check failed:", outPath, checkErr)
|
||||||
|
}
|
||||||
|
|
||||||
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "done", "Sprites", nil)
|
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "done", "Sprites", nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
truth = finishedPhaseTruthForID(assetID)
|
truth = assetsTruthForVideo(outPath)
|
||||||
|
|
||||||
if !truth.AnalyzeReady {
|
if !truth.AnalyzeReady {
|
||||||
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "running", "KI-Analyse", nil)
|
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "running", "Analyse", nil)
|
||||||
|
|
||||||
_, err := ensureAnalyzeForVideoCtx(ctx, outPath, sourceURL, "nsfw")
|
_, err := ensureAnalyzeForVideoCtx(ctx, outPath, sourceURL, "nsfw")
|
||||||
if cerr := abortIfCanceled(err, "enrich", "analyze"); cerr != nil {
|
if cerr := abortIfCanceled(err, "enrich", "analyze"); cerr != nil {
|
||||||
return cerr
|
return cerr
|
||||||
}
|
}
|
||||||
if err != nil || !finishedPhaseTruthForID(assetID).AnalyzeReady {
|
if err != nil || !assetsTruthForVideo(outPath).AnalyzeReady {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = fmt.Errorf("ki-analyse konnte nicht erzeugt werden")
|
err = fmt.Errorf("Analyse konnte nicht erzeugt werden")
|
||||||
}
|
}
|
||||||
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen", err)
|
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "Analyse fehlgeschlagen", err)
|
||||||
if firstErr == nil {
|
if firstErr == nil {
|
||||||
firstErr = err
|
firstErr = err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "done", "KI-Analyse", nil)
|
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "done", "Analyse", nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,5 +16,10 @@
|
|||||||
"teaserPlayback": "all",
|
"teaserPlayback": "all",
|
||||||
"teaserAudio": true,
|
"teaserAudio": true,
|
||||||
"enableNotifications": true,
|
"enableNotifications": true,
|
||||||
|
"generateAssetsMeta": true,
|
||||||
|
"generateAssetsThumb": true,
|
||||||
|
"generateAssetsTeaser": true,
|
||||||
|
"generateAssetsSprites": false,
|
||||||
|
"generateAssetsAnalyze": false,
|
||||||
"encryptedCookies": "FEPj/igIPaDy8xr709QjHKOqXWVGesegmwzhTP1Whnusetrl1WenN7t0V1Rm/lw2nLkyis78kz+4yjsDtVgO3MXLU4Uq85AhAJQOE7SMVD+YMNenCS8Qr0JLkmI6cr/kR6sKrx7Jm6x6DY1BolFFMX1Ii3EO+8b4Re2GYaFi1iuh97oI8Zg4r5sMYmt7FIMRkr1uy0u0ToH8hsO0iDoExcxubQNHIZTQ07kkWP70Up78tYs59INxzosijATbiVhOT1H0YwS23iWC3bwLgeo2lOzMejJWpEG16CN55CoEAymFd2ktlQZ4Lmv0FlnBJ48H27Cj0TKR1yRbBxpGjgch9x0421C5qYGeXNB9jgczEKh28oYdt8ljXejoDsV7/oKYNaAFfIlICsC/Zz97ZpPRrKiRTSTkTLmIXjnWtVdwGRfCvJrYjJpoe3ZOaubulqCQs6Ug501b6JTzdZujKAl68tL5stZD7hH9XGQSpquZWGeSUGOrI5jU8dULbxCLpkGtiOEmxGb93OLYdHQ8JJg6zHeExVtPSqANHoKX3NpFc33fqOX1vOQVeg8Ei4ymxntIMOwQWs8xnbuKXmnNx6GTBSqHiD7X/a8oeEkMSw1+bCODvwuXlNBcdxHlf3/wkvHb9lhnh231BD1+Ufrnij65Apko8A=="
|
"encryptedCookies": "FEPj/igIPaDy8xr709QjHKOqXWVGesegmwzhTP1Whnusetrl1WenN7t0V1Rm/lw2nLkyis78kz+4yjsDtVgO3MXLU4Uq85AhAJQOE7SMVD+YMNenCS8Qr0JLkmI6cr/kR6sKrx7Jm6x6DY1BolFFMX1Ii3EO+8b4Re2GYaFi1iuh97oI8Zg4r5sMYmt7FIMRkr1uy0u0ToH8hsO0iDoExcxubQNHIZTQ07kkWP70Up78tYs59INxzosijATbiVhOT1H0YwS23iWC3bwLgeo2lOzMejJWpEG16CN55CoEAymFd2ktlQZ4Lmv0FlnBJ48H27Cj0TKR1yRbBxpGjgch9x0421C5qYGeXNB9jgczEKh28oYdt8ljXejoDsV7/oKYNaAFfIlICsC/Zz97ZpPRrKiRTSTkTLmIXjnWtVdwGRfCvJrYjJpoe3ZOaubulqCQs6Ug501b6JTzdZujKAl68tL5stZD7hH9XGQSpquZWGeSUGOrI5jU8dULbxCLpkGtiOEmxGb93OLYdHQ8JJg6zHeExVtPSqANHoKX3NpFc33fqOX1vOQVeg8Ei4ymxntIMOwQWs8xnbuKXmnNx6GTBSqHiD7X/a8oeEkMSw1+bCODvwuXlNBcdxHlf3/wkvHb9lhnh231BD1+Ufrnij65Apko8A=="
|
||||||
}
|
}
|
||||||
|
|||||||
@ -81,6 +81,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
|||||||
// Tasks
|
// Tasks
|
||||||
api.HandleFunc("/api/tasks/status", tasksStatusHandler)
|
api.HandleFunc("/api/tasks/status", tasksStatusHandler)
|
||||||
api.HandleFunc("/api/tasks/generate-assets", tasksGenerateAssets)
|
api.HandleFunc("/api/tasks/generate-assets", tasksGenerateAssets)
|
||||||
|
api.HandleFunc("/api/tasks/check-videos", checkVideosTaskHandler)
|
||||||
|
|
||||||
// --------------------------
|
// --------------------------
|
||||||
// 3) ModelStore (Postgres)
|
// 3) ModelStore (Postgres)
|
||||||
|
|||||||
@ -4,21 +4,103 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func escapePowerShell(s string) string {
|
||||||
|
return strings.ReplaceAll(s, "'", "''")
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeAppleScript(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||||
|
s = strings.ReplaceAll(s, `"`, `\"`)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func showAppNotification(title, message string) error {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
ps := fmt.Sprintf(`
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
|
||||||
|
$n = New-Object System.Windows.Forms.NotifyIcon
|
||||||
|
$n.Icon = [System.Drawing.SystemIcons]::Information
|
||||||
|
$n.BalloonTipTitle = '%s'
|
||||||
|
$n.BalloonTipText = '%s'
|
||||||
|
$n.Visible = $true
|
||||||
|
$n.ShowBalloonTip(3000)
|
||||||
|
Start-Sleep -Milliseconds 3500
|
||||||
|
$n.Dispose()
|
||||||
|
`, escapePowerShell(title), escapePowerShell(message))
|
||||||
|
|
||||||
|
cmd := exec.Command(
|
||||||
|
"powershell",
|
||||||
|
"-NoProfile",
|
||||||
|
"-NonInteractive",
|
||||||
|
"-WindowStyle", "Hidden",
|
||||||
|
"-ExecutionPolicy", "Bypass",
|
||||||
|
"-Command", ps,
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd.Run()
|
||||||
|
|
||||||
|
case "darwin":
|
||||||
|
return exec.Command(
|
||||||
|
"osascript",
|
||||||
|
"-e",
|
||||||
|
fmt.Sprintf(
|
||||||
|
`display notification "%s" with title "%s"`,
|
||||||
|
escapeAppleScript(message),
|
||||||
|
escapeAppleScript(title),
|
||||||
|
),
|
||||||
|
).Run()
|
||||||
|
|
||||||
|
case "linux":
|
||||||
|
return exec.Command("notify-send", title, message).Run()
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildStartupNotificationMessage() string {
|
||||||
|
return "Die App läuft im Hintergrund."
|
||||||
|
}
|
||||||
|
|
||||||
// --- main ---
|
// --- main ---
|
||||||
func main() {
|
func main() {
|
||||||
loadSettings()
|
loadSettings()
|
||||||
|
|
||||||
|
// Nur eine Instanz erlauben:
|
||||||
|
appLn, err := net.Listen("tcp", ":9999")
|
||||||
|
if err != nil {
|
||||||
|
_ = showAppNotification(
|
||||||
|
"Recorder",
|
||||||
|
"Die Anwendung wird bereits ausgeführt.",
|
||||||
|
)
|
||||||
|
fmt.Println("⚠️ Die App läuft bereits oder Port 9999 ist bereits belegt.")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
defer appLn.Close()
|
||||||
|
|
||||||
fixKeepRootFilesIntoModelSubdirs()
|
fixKeepRootFilesIntoModelSubdirs()
|
||||||
|
|
||||||
postWorkQ.StartWorkers(2)
|
postWorkQ.StartWorkers(1)
|
||||||
enrichQ.StartWorkers(1)
|
enrichQ.StartWorkers(1)
|
||||||
startPostWorkStatusRefresher()
|
startPostWorkStatusRefresher()
|
||||||
startEnrichStatusRefresher()
|
startEnrichStatusRefresher()
|
||||||
@ -86,25 +168,52 @@ func main() {
|
|||||||
Handler: handler,
|
Handler: handler,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var shutdownOnce sync.Once
|
||||||
|
shutdown := func() {
|
||||||
|
shutdownOnce.Do(func() {
|
||||||
|
fmt.Println("🛑 Beende Server...")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_ = srv.Shutdown(ctx)
|
||||||
|
closeNSFW()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
stopSig := make(chan os.Signal, 1)
|
stopSig := make(chan os.Signal, 1)
|
||||||
signal.Notify(stopSig, os.Interrupt, syscall.SIGTERM)
|
signal.Notify(stopSig, os.Interrupt, syscall.SIGTERM)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
<-stopSig
|
<-stopSig
|
||||||
fmt.Println("🛑 Beende Server...")
|
shutdown()
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
_ = srv.Shutdown(ctx)
|
|
||||||
closeNSFW()
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
serverErrCh := make(chan error, 1)
|
||||||
fmt.Println("❌ HTTP-Server Fehler:", err)
|
|
||||||
closeNSFW()
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
closeNSFW()
|
go func() {
|
||||||
|
if err := srv.Serve(appLn); err != nil && err != http.ErrServerClosed {
|
||||||
|
serverErrCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serverErrCh <- nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_ = showAppNotification(
|
||||||
|
"Recorder gestartet",
|
||||||
|
buildStartupNotificationMessage(),
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
err := <-serverErrCh
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("❌ HTTP-Server Fehler:", err)
|
||||||
|
shutdown()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
runTray(shutdown, buildTrayStats)
|
||||||
|
shutdown()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,6 +39,12 @@ type RecorderSettings struct {
|
|||||||
|
|
||||||
EnableNotifications bool `json:"enableNotifications"`
|
EnableNotifications bool `json:"enableNotifications"`
|
||||||
|
|
||||||
|
GenerateAssetsMeta bool `json:"generateAssetsMeta"`
|
||||||
|
GenerateAssetsThumb bool `json:"generateAssetsThumb"`
|
||||||
|
GenerateAssetsTeaser bool `json:"generateAssetsTeaser"`
|
||||||
|
GenerateAssetsSprites bool `json:"generateAssetsSprites"`
|
||||||
|
GenerateAssetsAnalyze bool `json:"generateAssetsAnalyze"`
|
||||||
|
|
||||||
// EncryptedCookies contains base64(nonce+ciphertext) of a JSON cookie map.
|
// EncryptedCookies contains base64(nonce+ciphertext) of a JSON cookie map.
|
||||||
EncryptedCookies string `json:"encryptedCookies"`
|
EncryptedCookies string `json:"encryptedCookies"`
|
||||||
}
|
}
|
||||||
@ -69,6 +75,12 @@ var (
|
|||||||
|
|
||||||
EnableNotifications: true,
|
EnableNotifications: true,
|
||||||
|
|
||||||
|
GenerateAssetsMeta: true,
|
||||||
|
GenerateAssetsThumb: true,
|
||||||
|
GenerateAssetsTeaser: true,
|
||||||
|
GenerateAssetsSprites: false,
|
||||||
|
GenerateAssetsAnalyze: false,
|
||||||
|
|
||||||
EncryptedCookies: "",
|
EncryptedCookies: "",
|
||||||
}
|
}
|
||||||
settingsFile = "recorder_settings.json"
|
settingsFile = "recorder_settings.json"
|
||||||
@ -223,6 +235,12 @@ type RecorderSettingsPublic struct {
|
|||||||
TeaserAudio bool `json:"teaserAudio"`
|
TeaserAudio bool `json:"teaserAudio"`
|
||||||
|
|
||||||
EnableNotifications bool `json:"enableNotifications"`
|
EnableNotifications bool `json:"enableNotifications"`
|
||||||
|
|
||||||
|
GenerateAssetsMeta bool `json:"generateAssetsMeta"`
|
||||||
|
GenerateAssetsThumb bool `json:"generateAssetsThumb"`
|
||||||
|
GenerateAssetsTeaser bool `json:"generateAssetsTeaser"`
|
||||||
|
GenerateAssetsSprites bool `json:"generateAssetsSprites"`
|
||||||
|
GenerateAssetsAnalyze bool `json:"generateAssetsAnalyze"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
|
func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
|
||||||
@ -250,6 +268,12 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
|
|||||||
TeaserAudio: s.TeaserAudio,
|
TeaserAudio: s.TeaserAudio,
|
||||||
|
|
||||||
EnableNotifications: s.EnableNotifications,
|
EnableNotifications: s.EnableNotifications,
|
||||||
|
|
||||||
|
GenerateAssetsMeta: s.GenerateAssetsMeta,
|
||||||
|
GenerateAssetsThumb: s.GenerateAssetsThumb,
|
||||||
|
GenerateAssetsTeaser: s.GenerateAssetsTeaser,
|
||||||
|
GenerateAssetsSprites: s.GenerateAssetsSprites,
|
||||||
|
GenerateAssetsAnalyze: s.GenerateAssetsAnalyze,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -401,6 +425,12 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
next.EnableNotifications = in.EnableNotifications
|
next.EnableNotifications = in.EnableNotifications
|
||||||
|
|
||||||
|
next.GenerateAssetsMeta = in.GenerateAssetsMeta
|
||||||
|
next.GenerateAssetsThumb = in.GenerateAssetsThumb
|
||||||
|
next.GenerateAssetsTeaser = in.GenerateAssetsTeaser
|
||||||
|
next.GenerateAssetsSprites = in.GenerateAssetsSprites
|
||||||
|
next.GenerateAssetsAnalyze = in.GenerateAssetsAnalyze
|
||||||
|
|
||||||
dbChanged :=
|
dbChanged :=
|
||||||
strings.TrimSpace(next.DatabaseURL) != strings.TrimSpace(current.DatabaseURL) ||
|
strings.TrimSpace(next.DatabaseURL) != strings.TrimSpace(current.DatabaseURL) ||
|
||||||
strings.TrimSpace(next.EncryptedDBPassword) != strings.TrimSpace(current.EncryptedDBPassword)
|
strings.TrimSpace(next.EncryptedDBPassword) != strings.TrimSpace(current.EncryptedDBPassword)
|
||||||
|
|||||||
503
backend/split.go
503
backend/split.go
@ -3,6 +3,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@ -14,16 +15,26 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type splitVideoSegmentIn struct {
|
||||||
|
Start float64 `json:"start"`
|
||||||
|
End float64 `json:"end"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type splitVideoRequest struct {
|
type splitVideoRequest struct {
|
||||||
File string `json:"file"` // z. B. "model_01_01_2026__12-00-00.mp4"
|
File string `json:"file"` // z. B. "model_01_01_2026__12-00-00.mp4"
|
||||||
Splits []float64 `json:"splits"` // Sekunden, z. B. [120.5, 300.0]
|
Splits []float64 `json:"splits"` // Sekunden, z. B. [120.5, 300.0]
|
||||||
|
Segments []splitVideoSegmentIn `json:"segments"` // echte Segmente
|
||||||
|
DeleteOriginal bool `json:"deleteOriginal,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type splitVideoSegmentResponse struct {
|
type splitVideoSegmentResponse struct {
|
||||||
Index int `json:"index"`
|
Index int `json:"index"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
Start float64 `json:"start"`
|
Start float64 `json:"start"`
|
||||||
End float64 `json:"end"`
|
End float64 `json:"end"`
|
||||||
Duration float64 `json:"duration"`
|
Duration float64 `json:"duration"`
|
||||||
@ -38,6 +49,21 @@ type splitVideoResponse struct {
|
|||||||
Segments []splitVideoSegmentResponse `json:"segments"`
|
Segments []splitVideoSegmentResponse `json:"segments"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type splitProgressEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
OK bool `json:"ok,omitempty"`
|
||||||
|
File string `json:"file,omitempty"`
|
||||||
|
Current int `json:"current,omitempty"`
|
||||||
|
Total int `json:"total,omitempty"`
|
||||||
|
Progress float64 `json:"progress,omitempty"` // 0..1 aktuelles Segment
|
||||||
|
Overall float64 `json:"overall,omitempty"` // 0..1 gesamt
|
||||||
|
Start float64 `json:"start,omitempty"`
|
||||||
|
End float64 `json:"end,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Segment *splitVideoSegmentResponse `json:"segment,omitempty"`
|
||||||
|
Segments []splitVideoSegmentResponse `json:"segments,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func recordSplitVideo(w http.ResponseWriter, r *http.Request) {
|
func recordSplitVideo(w http.ResponseWriter, r *http.Request) {
|
||||||
if !mustMethod(w, r, http.MethodPost) {
|
if !mustMethod(w, r, http.MethodPost) {
|
||||||
return
|
return
|
||||||
@ -59,6 +85,8 @@ func recordSplitVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stream := r.URL.Query().Get("stream") == "1"
|
||||||
|
|
||||||
s := getSettings()
|
s := getSettings()
|
||||||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -82,31 +110,43 @@ func recordSplitVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
srcPath = filepath.Clean(srcPath)
|
srcPath = filepath.Clean(srcPath)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
probeCtx, probeCancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||||
defer cancel()
|
durationSec, err := durationSecondsCached(probeCtx, srcPath)
|
||||||
|
probeCancel()
|
||||||
durationSec, err := durationSecondsCached(ctx, srcPath)
|
|
||||||
if err != nil || durationSec <= 0 {
|
if err != nil || durationSec <= 0 {
|
||||||
http.Error(w, "videodauer konnte nicht ermittelt werden", http.StatusInternalServerError)
|
http.Error(w, "videodauer konnte nicht ermittelt werden", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
points, err := normalizeSplitPoints(req.Splits, durationSec)
|
var segments []normalizedSegment
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "splits ungültig: "+err.Error(), http.StatusBadRequest)
|
if len(req.Segments) > 0 {
|
||||||
return
|
segments, err = normalizeSegments(req.Segments, durationSec)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "segments ungültig: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
points, err := normalizeSplitPoints(req.Splits, durationSec)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "splits ungültig: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(points) == 0 {
|
||||||
|
http.Error(w, "keine gültigen splits übergeben", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
segments = buildSplitSegments(points, durationSec)
|
||||||
|
if len(segments) < 2 {
|
||||||
|
http.Error(w, "zu wenige segmente nach split-berechnung", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(points) == 0 {
|
splitCtx, splitCancel := context.WithTimeout(r.Context(), splitTimeoutForSegments(len(segments)))
|
||||||
http.Error(w, "keine gültigen splits übergeben", http.StatusBadRequest)
|
defer splitCancel()
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
segments := buildSplitSegments(points, durationSec)
|
|
||||||
if len(segments) < 2 {
|
|
||||||
http.Error(w, "zu wenige segmente nach split-berechnung", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
outDir := filepath.Join(filepath.Dir(srcPath), "_split")
|
outDir := filepath.Join(filepath.Dir(srcPath), "_split")
|
||||||
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
||||||
@ -126,11 +166,78 @@ func recordSplitVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
Source: srcPath,
|
Source: srcPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, seg := range segments {
|
var flusher http.Flusher
|
||||||
outName := fmt.Sprintf("%s__part_%02d%s", base, i+1, ext)
|
if stream {
|
||||||
outPath := filepath.Join(outDir, outName)
|
var ok bool
|
||||||
|
flusher, ok = w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "streaming wird von diesem server nicht unterstützt", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
_ = writeSplitProgressEvent(w, flusher, splitProgressEvent{
|
||||||
|
Type: "start",
|
||||||
|
OK: true,
|
||||||
|
File: req.File,
|
||||||
|
Total: len(segments),
|
||||||
|
Message: "Split gestartet",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, seg := range segments {
|
||||||
|
rawName := fmt.Sprintf("%s_%d%s", base, i+1, ext)
|
||||||
|
outPath := uniqueSplitOutPath(outDir, rawName)
|
||||||
|
outName := filepath.Base(outPath)
|
||||||
|
|
||||||
|
if stream {
|
||||||
|
_ = writeSplitProgressEvent(w, flusher, splitProgressEvent{
|
||||||
|
Type: "segment_start",
|
||||||
|
File: req.File,
|
||||||
|
Current: i + 1,
|
||||||
|
Total: len(segments),
|
||||||
|
Start: seg.Start,
|
||||||
|
End: seg.End,
|
||||||
|
Overall: float64(i) / float64(len(segments)),
|
||||||
|
Message: fmt.Sprintf("Segment %d/%d wird gesplittet", i+1, len(segments)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
err := splitSingleSegment(splitCtx, srcPath, outPath, seg.Start, seg.Duration, func(ratio float64) {
|
||||||
|
if !stream {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = writeSplitProgressEvent(w, flusher, splitProgressEvent{
|
||||||
|
Type: "segment_progress",
|
||||||
|
File: req.File,
|
||||||
|
Current: i + 1,
|
||||||
|
Total: len(segments),
|
||||||
|
Start: seg.Start,
|
||||||
|
End: seg.End,
|
||||||
|
Progress: clamp01(ratio),
|
||||||
|
Overall: clamp01((float64(i) + clamp01(ratio)) / float64(len(segments))),
|
||||||
|
Message: fmt.Sprintf("Segment %d/%d wird gesplittet", i+1, len(segments)),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if stream {
|
||||||
|
_ = writeSplitProgressEvent(w, flusher, splitProgressEvent{
|
||||||
|
Type: "error",
|
||||||
|
File: req.File,
|
||||||
|
Current: i + 1,
|
||||||
|
Total: len(segments),
|
||||||
|
Start: seg.Start,
|
||||||
|
End: seg.End,
|
||||||
|
Message: fmt.Sprintf("segment %d konnte nicht erzeugt werden: %v", i+1, err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := splitSingleSegment(r.Context(), srcPath, outPath, seg.Start, seg.Duration); err != nil {
|
|
||||||
http.Error(
|
http.Error(
|
||||||
w,
|
w,
|
||||||
fmt.Sprintf("segment %d konnte nicht erzeugt werden: %v", i+1, err),
|
fmt.Sprintf("segment %d konnte nicht erzeugt werden: %v", i+1, err),
|
||||||
@ -139,26 +246,242 @@ func recordSplitVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp.Segments = append(resp.Segments, splitVideoSegmentResponse{
|
segResp := splitVideoSegmentResponse{
|
||||||
Index: i + 1,
|
Index: i + 1,
|
||||||
|
Label: seg.Label,
|
||||||
Start: seg.Start,
|
Start: seg.Start,
|
||||||
End: seg.End,
|
End: seg.End,
|
||||||
Duration: seg.Duration,
|
Duration: seg.Duration,
|
||||||
File: outName,
|
File: outName,
|
||||||
Path: outPath,
|
Path: outPath,
|
||||||
})
|
}
|
||||||
|
resp.Segments = append(resp.Segments, segResp)
|
||||||
|
|
||||||
|
if stream {
|
||||||
|
_ = writeSplitProgressEvent(w, flusher, splitProgressEvent{
|
||||||
|
Type: "segment_done",
|
||||||
|
File: req.File,
|
||||||
|
Current: i + 1,
|
||||||
|
Total: len(segments),
|
||||||
|
Start: seg.Start,
|
||||||
|
End: seg.End,
|
||||||
|
Overall: float64(i+1) / float64(len(segments)),
|
||||||
|
Segment: &segResp,
|
||||||
|
Message: fmt.Sprintf("Segment %d/%d fertig", i+1, len(segments)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DeleteOriginal {
|
||||||
|
originalFile := filepath.Base(srcPath)
|
||||||
|
originalID := stripHotPrefix(strings.TrimSuffix(originalFile, filepath.Ext(originalFile)))
|
||||||
|
|
||||||
|
releaseFileForMutation(originalFile)
|
||||||
|
|
||||||
|
if err := removeWithRetry(srcPath); err != nil {
|
||||||
|
if stream {
|
||||||
|
_ = writeSplitProgressEvent(w, flusher, splitProgressEvent{
|
||||||
|
Type: "error",
|
||||||
|
File: req.File,
|
||||||
|
Total: len(resp.Segments),
|
||||||
|
Overall: 1,
|
||||||
|
Message: "Originaldatei konnte nicht gelöscht werden: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, "Originaldatei konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
purgeDurationCacheForPath(srcPath)
|
||||||
|
removeJobsByOutputBasename(originalFile)
|
||||||
|
|
||||||
|
if strings.TrimSpace(originalID) != "" {
|
||||||
|
removeGeneratedForID(originalID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stream {
|
||||||
|
_ = writeSplitProgressEvent(w, flusher, splitProgressEvent{
|
||||||
|
Type: "source_deleted",
|
||||||
|
File: req.File,
|
||||||
|
Total: len(resp.Segments),
|
||||||
|
Overall: 1,
|
||||||
|
Message: "Originaldatei gelöscht",
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyDoneChanged()
|
notifyDoneChanged()
|
||||||
|
|
||||||
|
if stream {
|
||||||
|
_ = writeSplitProgressEvent(w, flusher, splitProgressEvent{
|
||||||
|
Type: "done",
|
||||||
|
OK: true,
|
||||||
|
File: req.File,
|
||||||
|
Total: len(resp.Segments),
|
||||||
|
Overall: 1,
|
||||||
|
Segments: resp.Segments,
|
||||||
|
Message: "Split abgeschlossen",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
respondJSON(w, resp)
|
respondJSON(w, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sanitizeTimeForFilename(v float64) string {
|
||||||
|
if v < 0 {
|
||||||
|
v = 0
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(formatFFSec(v), ".", "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitTimeoutForSegments(count int) time.Duration {
|
||||||
|
if count < 1 {
|
||||||
|
count = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// grob 2 Minuten pro Segment + Puffer
|
||||||
|
d := time.Duration(count)*2*time.Minute + 30*time.Second
|
||||||
|
|
||||||
|
if d < 5*time.Minute {
|
||||||
|
d = 5 * time.Minute
|
||||||
|
}
|
||||||
|
if d > 2*time.Hour {
|
||||||
|
d = 2 * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp01(v float64) float64 {
|
||||||
|
if v < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if v > 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSplitProgressEvent(w http.ResponseWriter, flusher http.Flusher, ev splitProgressEvent) error {
|
||||||
|
b, err := json.Marshal(ev)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := w.Write(append(b, '\n')); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
flusher.Flush()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type normalizedSegment struct {
|
type normalizedSegment struct {
|
||||||
|
Label string
|
||||||
Start float64
|
Start float64
|
||||||
End float64
|
End float64
|
||||||
Duration float64
|
Duration float64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeSegments(raw []splitVideoSegmentIn, duration float64) ([]normalizedSegment, error) {
|
||||||
|
if duration <= 0 {
|
||||||
|
return nil, fmt.Errorf("duration <= 0")
|
||||||
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil, fmt.Errorf("keine segmente übergeben")
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]normalizedSegment, 0, len(raw))
|
||||||
|
|
||||||
|
for _, seg := range raw {
|
||||||
|
start := seg.Start
|
||||||
|
end := seg.End
|
||||||
|
|
||||||
|
if start > end {
|
||||||
|
start, end = end, start
|
||||||
|
}
|
||||||
|
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
if end > duration {
|
||||||
|
end = duration
|
||||||
|
}
|
||||||
|
|
||||||
|
dur := end - start
|
||||||
|
if dur <= 0.10 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append(out, normalizedSegment{
|
||||||
|
Label: strings.TrimSpace(seg.Label),
|
||||||
|
Start: start,
|
||||||
|
End: end,
|
||||||
|
Duration: dur,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, fmt.Errorf("keine gültigen segmente übrig")
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Start == out[j].Start {
|
||||||
|
return out[i].End < out[j].End
|
||||||
|
}
|
||||||
|
return out[i].Start < out[j].Start
|
||||||
|
})
|
||||||
|
|
||||||
|
// near-duplicate entfernen
|
||||||
|
dedup := make([]normalizedSegment, 0, len(out))
|
||||||
|
for _, seg := range out {
|
||||||
|
if len(dedup) == 0 {
|
||||||
|
dedup = append(dedup, seg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
last := dedup[len(dedup)-1]
|
||||||
|
sameStart := absFloat(last.Start-seg.Start) < 0.20
|
||||||
|
sameEnd := absFloat(last.End-seg.End) < 0.20
|
||||||
|
|
||||||
|
if sameStart && sameEnd {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dedup = append(dedup, seg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(dedup) == 0 {
|
||||||
|
return nil, fmt.Errorf("keine eindeutigen segmente übrig")
|
||||||
|
}
|
||||||
|
|
||||||
|
return dedup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueSplitOutPath(dir, fileName string) string {
|
||||||
|
ext := filepath.Ext(fileName)
|
||||||
|
base := strings.TrimSuffix(fileName, ext)
|
||||||
|
|
||||||
|
outPath := filepath.Join(dir, fileName)
|
||||||
|
if _, err := os.Stat(outPath); err == nil {
|
||||||
|
// existiert schon -> unten nächsten Namen suchen
|
||||||
|
} else if os.IsNotExist(err) {
|
||||||
|
return outPath
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 2; i < 10000; i++ {
|
||||||
|
candidate := filepath.Join(dir, fmt.Sprintf("%s__%02d%s", base, i, ext))
|
||||||
|
if _, err := os.Stat(candidate); os.IsNotExist(err) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(dir, fmt.Sprintf("%s__%d%s", base, time.Now().UnixNano(), ext))
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeSplitPoints(raw []float64, duration float64) ([]float64, error) {
|
func normalizeSplitPoints(raw []float64, duration float64) ([]float64, error) {
|
||||||
if duration <= 0 {
|
if duration <= 0 {
|
||||||
return nil, fmt.Errorf("duration <= 0")
|
return nil, fmt.Errorf("duration <= 0")
|
||||||
@ -218,7 +541,29 @@ func buildSplitSegments(points []float64, duration float64) []normalizedSegment
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func splitSingleSegment(parentCtx context.Context, srcPath, outPath string, startSec, durSec float64) error {
|
func splitSingleSegmentTimeout(durSec float64) time.Duration {
|
||||||
|
if durSec <= 0 {
|
||||||
|
return 10 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
d := time.Duration(durSec*4)*time.Second + 2*time.Minute
|
||||||
|
|
||||||
|
if d < 5*time.Minute {
|
||||||
|
d = 5 * time.Minute
|
||||||
|
}
|
||||||
|
if d > 90*time.Minute {
|
||||||
|
d = 90 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitSingleSegment(
|
||||||
|
parentCtx context.Context,
|
||||||
|
srcPath, outPath string,
|
||||||
|
startSec, durSec float64,
|
||||||
|
onProgress func(ratio float64),
|
||||||
|
) error {
|
||||||
if strings.TrimSpace(srcPath) == "" {
|
if strings.TrimSpace(srcPath) == "" {
|
||||||
return fmt.Errorf("srcPath leer")
|
return fmt.Errorf("srcPath leer")
|
||||||
}
|
}
|
||||||
@ -229,19 +574,27 @@ func splitSingleSegment(parentCtx context.Context, srcPath, outPath string, star
|
|||||||
return fmt.Errorf("dauer <= 0")
|
return fmt.Errorf("dauer <= 0")
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpPath := outPath + ".part"
|
ext := strings.ToLower(filepath.Ext(outPath))
|
||||||
|
base := strings.TrimSuffix(outPath, filepath.Ext(outPath))
|
||||||
|
tmpPath := base + ".part" + ext
|
||||||
_ = os.Remove(tmpPath)
|
_ = os.Remove(tmpPath)
|
||||||
_ = os.Remove(outPath)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Minute)
|
timeout := splitSingleSegmentTimeout(durSec)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(parentCtx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Re-Encode ist robuster/framesauberer als -c copy
|
formatName := "mp4"
|
||||||
|
if ext == ".ts" {
|
||||||
|
formatName = "mpegts"
|
||||||
|
}
|
||||||
|
|
||||||
args := []string{
|
args := []string{
|
||||||
"-y",
|
"-y",
|
||||||
|
"-nostats",
|
||||||
|
"-progress", "pipe:1",
|
||||||
"-hide_banner",
|
"-hide_banner",
|
||||||
"-loglevel", "error",
|
"-loglevel", "warning",
|
||||||
"-ss", formatFFSec(startSec),
|
"-ss", formatFFSec(startSec),
|
||||||
"-i", srcPath,
|
"-i", srcPath,
|
||||||
"-t", formatFFSec(durSec),
|
"-t", formatFFSec(durSec),
|
||||||
@ -255,15 +608,93 @@ func splitSingleSegment(parentCtx context.Context, srcPath, outPath string, star
|
|||||||
"-c:a", "aac",
|
"-c:a", "aac",
|
||||||
"-b:a", "128k",
|
"-b:a", "128k",
|
||||||
"-movflags", "+faststart",
|
"-movflags", "+faststart",
|
||||||
|
"-f", formatName,
|
||||||
tmpPath,
|
tmpPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: 0x08000000,
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if onProgress != nil {
|
||||||
|
onProgress(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
sc := bufio.NewScanner(stdout)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
|
||||||
|
lastRatio := -1.0
|
||||||
|
send := func(outSec float64, force bool) {
|
||||||
|
if onProgress == nil || durSec <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ratio := clamp01(outSec / durSec)
|
||||||
|
if !force && lastRatio >= 0 && absFloat(lastRatio-ratio) < 0.01 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastRatio = ratio
|
||||||
|
onProgress(ratio)
|
||||||
|
}
|
||||||
|
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimSpace(sc.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
k, v, ok := strings.Cut(line, "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch k {
|
||||||
|
case "out_time_us":
|
||||||
|
if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && n > 0 {
|
||||||
|
send(float64(n)/1_000_000.0, false)
|
||||||
|
}
|
||||||
|
case "out_time_ms":
|
||||||
|
if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && n > 0 {
|
||||||
|
send(float64(n)/1_000.0, false)
|
||||||
|
}
|
||||||
|
case "out_time":
|
||||||
|
if s := parseFFmpegOutTime(v); s > 0 {
|
||||||
|
send(s, false)
|
||||||
|
}
|
||||||
|
case "progress":
|
||||||
|
if strings.TrimSpace(v) == "end" {
|
||||||
|
send(durSec, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
_ = os.Remove(tmpPath)
|
_ = os.Remove(tmpPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Wait(); err != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return fmt.Errorf("ffmpeg timeout nach %s: %w", timeout, ctx.Err())
|
||||||
|
}
|
||||||
|
|
||||||
msg := strings.TrimSpace(stderr.String())
|
msg := strings.TrimSpace(stderr.String())
|
||||||
if msg != "" {
|
if msg != "" {
|
||||||
return fmt.Errorf("%w (%s)", err, msg)
|
return fmt.Errorf("%w (%s)", err, msg)
|
||||||
@ -282,6 +713,10 @@ func splitSingleSegment(parentCtx context.Context, srcPath, outPath string, star
|
|||||||
return fmt.Errorf("rename fehlgeschlagen: %w", err)
|
return fmt.Errorf("rename fehlgeschlagen: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if onProgress != nil {
|
||||||
|
onProgress(1)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@ -20,13 +21,14 @@ import (
|
|||||||
type AssetsTaskState struct {
|
type AssetsTaskState struct {
|
||||||
Queued bool `json:"queued"`
|
Queued bool `json:"queued"`
|
||||||
Running bool `json:"running"`
|
Running bool `json:"running"`
|
||||||
|
QueuedAt time.Time `json:"queuedAt"`
|
||||||
|
StartedAt time.Time `json:"startedAt"`
|
||||||
|
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
Done int `json:"done"`
|
Done int `json:"done"`
|
||||||
GeneratedThumbs int `json:"generatedThumbs"`
|
GeneratedThumbs int `json:"generatedThumbs"`
|
||||||
GeneratedPreviews int `json:"generatedPreviews"`
|
GeneratedPreviews int `json:"generatedPreviews"`
|
||||||
Skipped int `json:"skipped"`
|
Skipped int `json:"skipped"`
|
||||||
StartedAt time.Time `json:"startedAt"`
|
|
||||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
CurrentFile string `json:"currentFile,omitempty"`
|
CurrentFile string `json:"currentFile,omitempty"`
|
||||||
CurrentQueue string `json:"currentQueue,omitempty"`
|
CurrentQueue string `json:"currentQueue,omitempty"`
|
||||||
@ -35,10 +37,101 @@ type AssetsTaskState struct {
|
|||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type assetsPhaseSelection struct {
|
||||||
|
Meta bool
|
||||||
|
Thumb bool
|
||||||
|
Teaser bool
|
||||||
|
Sprites bool
|
||||||
|
Analyze bool
|
||||||
|
}
|
||||||
|
|
||||||
var assetsTaskMu sync.Mutex
|
var assetsTaskMu sync.Mutex
|
||||||
var assetsTaskState AssetsTaskState
|
var assetsTaskState AssetsTaskState
|
||||||
var assetsTaskCancel context.CancelFunc
|
var assetsTaskCancel context.CancelFunc
|
||||||
|
|
||||||
|
var assetsTaskFileCancels = map[string]context.CancelFunc{}
|
||||||
|
var assetsTaskSkipFiles = map[string]struct{}{}
|
||||||
|
|
||||||
|
func selectedAssetsPhasesFromSettings() assetsPhaseSelection {
|
||||||
|
s := getSettings()
|
||||||
|
return assetsPhaseSelection{
|
||||||
|
Meta: s.GenerateAssetsMeta,
|
||||||
|
Thumb: s.GenerateAssetsThumb,
|
||||||
|
Teaser: s.GenerateAssetsTeaser,
|
||||||
|
Sprites: s.GenerateAssetsSprites,
|
||||||
|
Analyze: s.GenerateAssetsAnalyze,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assetsTaskFileKey(file string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(filepath.Base(file)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetAssetsTaskFileControlLocked() {
|
||||||
|
assetsTaskFileCancels = map[string]context.CancelFunc{}
|
||||||
|
assetsTaskSkipFiles = map[string]struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerAssetsTaskFileCancel(file string, cancel context.CancelFunc) {
|
||||||
|
key := assetsTaskFileKey(file)
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assetsTaskMu.Lock()
|
||||||
|
assetsTaskFileCancels[key] = cancel
|
||||||
|
assetsTaskMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAssetsTaskFileControl(file string) {
|
||||||
|
key := assetsTaskFileKey(file)
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assetsTaskMu.Lock()
|
||||||
|
delete(assetsTaskFileCancels, key)
|
||||||
|
delete(assetsTaskSkipFiles, key)
|
||||||
|
assetsTaskMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestAssetsTaskSkip(file string) bool {
|
||||||
|
key := assetsTaskFileKey(file)
|
||||||
|
if key == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
|
||||||
|
assetsTaskMu.Lock()
|
||||||
|
assetsTaskSkipFiles[key] = struct{}{}
|
||||||
|
cancel = assetsTaskFileCancels[key]
|
||||||
|
assetsTaskMu.Unlock()
|
||||||
|
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAssetsTaskSkipRequested(file string) bool {
|
||||||
|
key := assetsTaskFileKey(file)
|
||||||
|
if key == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
assetsTaskMu.Lock()
|
||||||
|
_, ok := assetsTaskSkipFiles[key]
|
||||||
|
assetsTaskMu.Unlock()
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p assetsPhaseSelection) Any() bool {
|
||||||
|
return p.Meta || p.Thumb || p.Teaser || p.Sprites || p.Analyze
|
||||||
|
}
|
||||||
|
|
||||||
// updateAssetsState mutiert den State atomar und triggert danach SSE notify.
|
// updateAssetsState mutiert den State atomar und triggert danach SSE notify.
|
||||||
// notifyAssetsChanged() muss außerhalb des Locks passieren.
|
// notifyAssetsChanged() muss außerhalb des Locks passieren.
|
||||||
func updateAssetsState(fn func(st *AssetsTaskState)) AssetsTaskState {
|
func updateAssetsState(fn func(st *AssetsTaskState)) AssetsTaskState {
|
||||||
@ -85,13 +178,14 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
|
|||||||
assetsTaskState = AssetsTaskState{
|
assetsTaskState = AssetsTaskState{
|
||||||
Queued: true,
|
Queued: true,
|
||||||
Running: false,
|
Running: false,
|
||||||
|
QueuedAt: time.Now(),
|
||||||
|
StartedAt: time.Time{},
|
||||||
|
FinishedAt: nil,
|
||||||
Total: 0,
|
Total: 0,
|
||||||
Done: 0,
|
Done: 0,
|
||||||
GeneratedThumbs: 0,
|
GeneratedThumbs: 0,
|
||||||
GeneratedPreviews: 0,
|
GeneratedPreviews: 0,
|
||||||
Skipped: 0,
|
Skipped: 0,
|
||||||
StartedAt: time.Time{},
|
|
||||||
FinishedAt: nil,
|
|
||||||
Error: "",
|
Error: "",
|
||||||
CurrentFile: "",
|
CurrentFile: "",
|
||||||
CurrentQueue: "",
|
CurrentQueue: "",
|
||||||
@ -99,6 +193,7 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
|
|||||||
CurrentLabel: "",
|
CurrentLabel: "",
|
||||||
Text: "Wartet…",
|
Text: "Wartet…",
|
||||||
}
|
}
|
||||||
|
resetAssetsTaskFileControlLocked()
|
||||||
st := assetsTaskState
|
st := assetsTaskState
|
||||||
assetsTaskMu.Unlock()
|
assetsTaskMu.Unlock()
|
||||||
|
|
||||||
@ -189,21 +284,117 @@ func publishAssetsTaskPhase(fileName string, queue string, phase string, state s
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearAssetsTaskFinishedPostworkStates(fileName string) {
|
func clearAssetsTaskFinishedPostworkStates(fileName string, sel assetsPhaseSelection) {
|
||||||
defs := []struct {
|
if sel.Meta {
|
||||||
queue string
|
publishAssetsTaskPhase(fileName, "postwork", "meta", "missing", "")
|
||||||
phase string
|
}
|
||||||
}{
|
if sel.Thumb {
|
||||||
{queue: "postwork", phase: "meta"},
|
publishAssetsTaskPhase(fileName, "postwork", "thumb", "missing", "")
|
||||||
{queue: "postwork", phase: "thumb"},
|
}
|
||||||
{queue: "postwork", phase: "teaser"},
|
if sel.Teaser {
|
||||||
{queue: "postwork", phase: "sprites"},
|
publishAssetsTaskPhase(fileName, "postwork", "teaser", "missing", "")
|
||||||
{queue: "enrich", phase: "analyze"},
|
}
|
||||||
|
if sel.Sprites {
|
||||||
|
publishAssetsTaskPhase(fileName, "postwork", "sprites", "missing", "")
|
||||||
|
}
|
||||||
|
if sel.Analyze {
|
||||||
|
publishAssetsTaskPhase(fileName, "enrich", "analyze", "missing", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExistsNonEmpty(path string) bool {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, def := range defs {
|
fi, err := os.Stat(path)
|
||||||
publishAssetsTaskPhase(fileName, def.queue, def.phase, "missing", "")
|
return err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type orderedAssetItem struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func listAssetsDoneThenKeepByFilenameAsc(doneAbs string) []orderedAssetItem {
|
||||||
|
indexedItems, _ := buildDoneIndex(doneAbs)
|
||||||
|
|
||||||
|
doneItems := make([]orderedAssetItem, 0, len(indexedItems))
|
||||||
|
keepItems := make([]orderedAssetItem, 0, len(indexedItems))
|
||||||
|
seen := make(map[string]struct{}, len(indexedItems))
|
||||||
|
|
||||||
|
for _, entry := range indexedItems {
|
||||||
|
if entry.job == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
full := strings.TrimSpace(entry.job.Output)
|
||||||
|
if full == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanKey := strings.ToLower(filepath.Clean(full))
|
||||||
|
if _, ok := seen[cleanKey]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[cleanKey] = struct{}{}
|
||||||
|
|
||||||
|
it := orderedAssetItem{
|
||||||
|
name: filepath.Base(full),
|
||||||
|
path: full,
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.fromKeep {
|
||||||
|
keepItems = append(keepItems, it)
|
||||||
|
} else {
|
||||||
|
doneItems = append(doneItems, it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sort.Slice(doneItems, func(i, j int) bool {
|
||||||
|
ai := strings.ToLower(doneItems[i].name)
|
||||||
|
aj := strings.ToLower(doneItems[j].name)
|
||||||
|
if ai != aj {
|
||||||
|
return ai < aj
|
||||||
|
}
|
||||||
|
return strings.ToLower(filepath.Clean(doneItems[i].path)) < strings.ToLower(filepath.Clean(doneItems[j].path))
|
||||||
|
})
|
||||||
|
|
||||||
|
sort.Slice(keepItems, func(i, j int) bool {
|
||||||
|
ai := strings.ToLower(keepItems[i].name)
|
||||||
|
aj := strings.ToLower(keepItems[j].name)
|
||||||
|
if ai != aj {
|
||||||
|
return ai < aj
|
||||||
|
}
|
||||||
|
return strings.ToLower(filepath.Clean(keepItems[i].path)) < strings.ToLower(filepath.Clean(keepItems[j].path))
|
||||||
|
})
|
||||||
|
|
||||||
|
return append(doneItems, keepItems...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assetsTaskTruthForVideo(id string, videoPath string) finishedPhaseTruth {
|
||||||
|
truth := finishedPhaseTruthForID(id)
|
||||||
|
|
||||||
|
_, thumbPath, previewPath, spritePath, metaPath, err := assetPathsForID(id)
|
||||||
|
if err != nil {
|
||||||
|
return truth
|
||||||
|
}
|
||||||
|
|
||||||
|
// meta muss nicht nur logisch "ready" sein, sondern die Datei muss auch existieren
|
||||||
|
truth.MetaReady = truth.MetaReady && fileExistsNonEmpty(metaPath)
|
||||||
|
|
||||||
|
// dateibasierte Assets IMMER an echter Datei festmachen
|
||||||
|
truth.ThumbReady = fileExistsNonEmpty(thumbPath)
|
||||||
|
truth.TeaserReady = fileExistsNonEmpty(previewPath)
|
||||||
|
truth.SpritesReady = fileExistsNonEmpty(spritePath)
|
||||||
|
|
||||||
|
// Analyse bleibt inhaltlich/meta-basiert
|
||||||
|
if strings.TrimSpace(videoPath) != "" {
|
||||||
|
truth.AnalyzeReady = hasAIResultsForOutput(videoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return truth
|
||||||
}
|
}
|
||||||
|
|
||||||
func runGenerateMissingAssets(ctx context.Context) {
|
func runGenerateMissingAssets(ctx context.Context) {
|
||||||
@ -237,67 +428,27 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
st.Error = err.Error()
|
st.Error = err.Error()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
abortIfCanceled := func(stepErr error, fileName string) bool {
|
assetsTaskMu.Lock()
|
||||||
if errors.Is(stepErr, context.Canceled) || errors.Is(stepErr, context.DeadlineExceeded) || ctx.Err() != nil {
|
resetAssetsTaskFileControlLocked()
|
||||||
if strings.TrimSpace(fileName) != "" {
|
assetsTaskMu.Unlock()
|
||||||
clearAssetsTaskFinishedPostworkStates(fileName)
|
|
||||||
}
|
|
||||||
finishWithErr(context.Canceled)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s := getSettings()
|
s := getSettings()
|
||||||
|
sel := selectedAssetsPhasesFromSettings()
|
||||||
|
if !sel.Any() {
|
||||||
|
finishWithErr(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||||
if err != nil || strings.TrimSpace(doneAbs) == "" {
|
if err != nil || strings.TrimSpace(doneAbs) == "" {
|
||||||
finishWithErr(fmt.Errorf("doneDir auflösung fehlgeschlagen: %v", err))
|
finishWithErr(fmt.Errorf("doneDir auflösung fehlgeschlagen: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
type item struct {
|
items := listAssetsDoneThenKeepByFilenameAsc(doneAbs)
|
||||||
name string
|
|
||||||
path string
|
|
||||||
}
|
|
||||||
|
|
||||||
indexedItems, sortedIdx := buildDoneIndex(doneAbs)
|
|
||||||
|
|
||||||
// gleiche Grundreihenfolge wie FinishedDownloads-Default:
|
|
||||||
// includeKeep = true, sort = completed_desc
|
|
||||||
order := sortedIdx["1|completed_desc"]
|
|
||||||
|
|
||||||
seen := map[string]struct{}{}
|
|
||||||
items := make([]item, 0, len(order))
|
|
||||||
|
|
||||||
for _, idx := range order {
|
|
||||||
if idx < 0 || idx >= len(indexedItems) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
j := indexedItems[idx].job
|
|
||||||
if j == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
full := strings.TrimSpace(j.Output)
|
|
||||||
if full == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := seen[full]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[full] = struct{}{}
|
|
||||||
|
|
||||||
items = append(items, item{
|
|
||||||
name: filepath.Base(full),
|
|
||||||
path: full,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialisierung: Total etc. + SSE Push
|
|
||||||
type assetCandidate struct {
|
type assetCandidate struct {
|
||||||
name string
|
name string
|
||||||
path string
|
path string
|
||||||
@ -307,6 +458,7 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
|
|
||||||
candidates := make([]assetCandidate, 0, len(items))
|
candidates := make([]assetCandidate, 0, len(items))
|
||||||
skippedCount := 0
|
skippedCount := 0
|
||||||
|
initialDone := 0
|
||||||
|
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
@ -317,30 +469,33 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
base := strings.TrimSuffix(it.name, filepath.Ext(it.name))
|
base := strings.TrimSuffix(it.name, filepath.Ext(it.name))
|
||||||
id := stripHotPrefix(base)
|
id := stripHotPrefix(base)
|
||||||
if strings.TrimSpace(id) == "" {
|
if strings.TrimSpace(id) == "" {
|
||||||
clearAssetsTaskFinishedPostworkStates(it.name)
|
clearAssetsTaskFinishedPostworkStates(it.name, sel)
|
||||||
skippedCount++
|
skippedCount++
|
||||||
|
initialDone++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
vfi, verr := os.Stat(it.path)
|
vfi, verr := os.Stat(it.path)
|
||||||
if verr != nil || vfi == nil || vfi.IsDir() || vfi.Size() <= 0 {
|
if verr != nil || vfi == nil || vfi.IsDir() || vfi.Size() <= 0 {
|
||||||
clearAssetsTaskFinishedPostworkStates(it.name)
|
clearAssetsTaskFinishedPostworkStates(it.name, sel)
|
||||||
skippedCount++
|
skippedCount++
|
||||||
|
initialDone++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeTruth := finishedPhaseTruthForID(id)
|
beforeTruth := assetsTaskTruthForVideo(id, it.path)
|
||||||
|
|
||||||
needMeta := !beforeTruth.MetaReady
|
needMeta := sel.Meta && !beforeTruth.MetaReady
|
||||||
needThumb := !beforeTruth.ThumbReady
|
needThumb := sel.Thumb && !beforeTruth.ThumbReady
|
||||||
needTeaser := !beforeTruth.TeaserReady
|
needTeaser := sel.Teaser && !beforeTruth.TeaserReady
|
||||||
needSprites := !beforeTruth.SpritesReady
|
needSprites := sel.Sprites && !beforeTruth.SpritesReady
|
||||||
needAnalyze := !beforeTruth.AnalyzeReady
|
needAnalyze := sel.Analyze && !beforeTruth.AnalyzeReady
|
||||||
|
|
||||||
// Bereits vollständig -> gar nicht erst in die Arbeitsliste aufnehmen
|
// Bereits vollständig bzgl. ausgewählter Phasen -> als erledigt/skipped mitzählen
|
||||||
if !needMeta && !needThumb && !needTeaser && !needSprites && !needAnalyze {
|
if !needMeta && !needThumb && !needTeaser && !needSprites && !needAnalyze {
|
||||||
clearAssetsTaskFinishedPostworkStates(it.name)
|
clearAssetsTaskFinishedPostworkStates(it.name, sel)
|
||||||
skippedCount++
|
skippedCount++
|
||||||
|
initialDone++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -353,8 +508,8 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateAssetsState(func(st *AssetsTaskState) {
|
updateAssetsState(func(st *AssetsTaskState) {
|
||||||
st.Total = len(candidates)
|
st.Total = len(items) // alle Dateien, inkl. keep + skips
|
||||||
st.Done = 0
|
st.Done = initialDone // vorhandene/ungültige direkt als erledigt zählen
|
||||||
st.GeneratedThumbs = 0
|
st.GeneratedThumbs = 0
|
||||||
st.GeneratedPreviews = 0
|
st.GeneratedPreviews = 0
|
||||||
st.Skipped = skippedCount
|
st.Skipped = skippedCount
|
||||||
@ -363,42 +518,109 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
st.CurrentQueue = ""
|
st.CurrentQueue = ""
|
||||||
st.CurrentPhase = ""
|
st.CurrentPhase = ""
|
||||||
st.CurrentLabel = ""
|
st.CurrentLabel = ""
|
||||||
|
st.Text = ""
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if len(items) == 0 {
|
||||||
|
finishWithErr(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if len(candidates) == 0 {
|
if len(candidates) == 0 {
|
||||||
finishWithErr(nil)
|
finishWithErr(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, it := range candidates {
|
for _, it := range candidates {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
finishWithErr(err)
|
finishWithErr(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fileCtx, fileCancel := context.WithCancel(ctx)
|
||||||
|
registerAssetsTaskFileCancel(it.name, fileCancel)
|
||||||
|
|
||||||
|
finishFileControl := func() {
|
||||||
|
fileCancel()
|
||||||
|
clearAssetsTaskFileControl(it.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
skipCurrentFile := func() {
|
||||||
|
clearAssetsTaskFinishedPostworkStates(it.name, sel)
|
||||||
|
updateAssetsState(func(st *AssetsTaskState) {
|
||||||
|
st.Done++
|
||||||
|
st.Skipped++
|
||||||
|
st.CurrentFile = ""
|
||||||
|
st.CurrentQueue = ""
|
||||||
|
st.CurrentPhase = ""
|
||||||
|
st.CurrentLabel = ""
|
||||||
|
})
|
||||||
|
finishFileControl()
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileAbort := func(stepErr error) (skipFile bool, stopAll bool) {
|
||||||
|
if stepErr == nil {
|
||||||
|
if isAssetsTaskSkipRequested(it.name) || !fileExistsNonEmpty(it.path) {
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(stepErr, context.Canceled) || errors.Is(stepErr, context.DeadlineExceeded) {
|
||||||
|
// globaler Task wurde beendet
|
||||||
|
if ctx.Err() != nil && !isAssetsTaskSkipRequested(it.name) {
|
||||||
|
finishFileControl()
|
||||||
|
finishWithErr(context.Canceled)
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// nur diese eine Datei wurde freigegeben/übersprungen
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datei wurde evtl. während des Verarbeitungsschritts gelöscht/verschoben
|
||||||
|
if isAssetsTaskSkipRequested(it.name) || !fileExistsNonEmpty(it.path) {
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
updateAssetsState(func(st *AssetsTaskState) {
|
updateAssetsState(func(st *AssetsTaskState) {
|
||||||
st.CurrentFile = it.name
|
st.CurrentFile = it.name
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if isAssetsTaskSkipRequested(it.name) || !fileExistsNonEmpty(it.path) {
|
||||||
|
skipCurrentFile()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
id := it.id
|
id := it.id
|
||||||
|
|
||||||
vfi, verr := os.Stat(it.path)
|
vfi, verr := os.Stat(it.path)
|
||||||
if verr != nil || vfi == nil || vfi.IsDir() || vfi.Size() <= 0 {
|
if verr != nil || vfi == nil || vfi.IsDir() || vfi.Size() <= 0 {
|
||||||
clearAssetsTaskFinishedPostworkStates(it.name)
|
clearAssetsTaskFinishedPostworkStates(it.name, sel)
|
||||||
|
|
||||||
updateAssetsState(func(st *AssetsTaskState) {
|
updateAssetsState(func(st *AssetsTaskState) {
|
||||||
st.Done = i + 1
|
st.Done++
|
||||||
|
st.Skipped++
|
||||||
|
st.CurrentFile = ""
|
||||||
|
st.CurrentQueue = ""
|
||||||
|
st.CurrentPhase = ""
|
||||||
|
st.CurrentLabel = ""
|
||||||
})
|
})
|
||||||
|
|
||||||
|
finishFileControl()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeTruth := it.beforeTruth
|
beforeTruth := it.beforeTruth
|
||||||
|
|
||||||
needMeta := !beforeTruth.MetaReady
|
needMeta := sel.Meta && !beforeTruth.MetaReady
|
||||||
needThumb := !beforeTruth.ThumbReady
|
needThumb := sel.Thumb && !beforeTruth.ThumbReady
|
||||||
needTeaser := !beforeTruth.TeaserReady
|
needTeaser := sel.Teaser && !beforeTruth.TeaserReady
|
||||||
needSprites := !beforeTruth.SpritesReady
|
needSprites := sel.Sprites && !beforeTruth.SpritesReady
|
||||||
needAnalyze := !beforeTruth.AnalyzeReady
|
needAnalyze := sel.Analyze && !beforeTruth.AnalyzeReady
|
||||||
|
|
||||||
// Pfade einmalig über zentralen Helper
|
// Pfade einmalig über zentralen Helper
|
||||||
_, _, _, _, metaPath, perr := assetPathsForID(id)
|
_, _, _, _, metaPath, perr := assetPathsForID(id)
|
||||||
@ -416,13 +638,19 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen")
|
publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen")
|
||||||
}
|
}
|
||||||
if needAnalyze {
|
if needAnalyze {
|
||||||
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen")
|
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen")
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAssetsState(func(st *AssetsTaskState) {
|
updateAssetsState(func(st *AssetsTaskState) {
|
||||||
st.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)"
|
st.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)"
|
||||||
st.Done = i + 1
|
st.Done++
|
||||||
|
st.CurrentFile = ""
|
||||||
|
st.CurrentQueue = ""
|
||||||
|
st.CurrentPhase = ""
|
||||||
|
st.CurrentLabel = ""
|
||||||
})
|
})
|
||||||
|
|
||||||
|
finishFileControl()
|
||||||
fmt.Println("⚠️ assetPathsForID:", perr)
|
fmt.Println("⚠️ assetPathsForID:", perr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -449,9 +677,12 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
|
|
||||||
publishAssetsTaskPhase(it.name, "postwork", "meta", "running", "Meta")
|
publishAssetsTaskPhase(it.name, "postwork", "meta", "running", "Meta")
|
||||||
|
|
||||||
okMeta, merr := ensureMetaForVideoCtx(ctx, it.path, sourceURL)
|
okMeta, merr := ensureMetaForVideoCtx(fileCtx, it.path, sourceURL)
|
||||||
if abortIfCanceled(merr, it.name) {
|
if skipFile, stopAll := handleFileAbort(merr); stopAll {
|
||||||
return
|
return
|
||||||
|
} else if skipFile {
|
||||||
|
skipCurrentFile()
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if merr != nil || !okMeta {
|
if merr != nil || !okMeta {
|
||||||
publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen")
|
publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen")
|
||||||
@ -473,14 +704,17 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
|
|
||||||
publishAssetsTaskPhase(it.name, "postwork", "thumb", "running", "Vorschaubild")
|
publishAssetsTaskPhase(it.name, "postwork", "thumb", "running", "Vorschaubild")
|
||||||
|
|
||||||
resThumb, terr := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, it.path, sourceURL, nil)
|
thumbGeneratedNow, terr := ensureThumbForVideoCtx(fileCtx, it.path, sourceURL)
|
||||||
if abortIfCanceled(terr, it.name) {
|
if skipFile, stopAll := handleFileAbort(terr); stopAll {
|
||||||
return
|
return
|
||||||
|
} else if skipFile {
|
||||||
|
skipCurrentFile()
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if terr != nil {
|
if terr != nil {
|
||||||
publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen")
|
publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen")
|
||||||
} else if finishedPhaseTruthForID(id).ThumbReady {
|
} else if assetsTaskTruthForVideo(id, it.path).ThumbReady {
|
||||||
if resThumb.ThumbGenerated {
|
if thumbGeneratedNow {
|
||||||
thumbGenerated = true
|
thumbGenerated = true
|
||||||
}
|
}
|
||||||
publishAssetsTaskPhase(it.name, "postwork", "thumb", "done", "Vorschaubild")
|
publishAssetsTaskPhase(it.name, "postwork", "thumb", "done", "Vorschaubild")
|
||||||
@ -502,13 +736,16 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
|
|
||||||
publishAssetsTaskPhase(it.name, "postwork", "teaser", "running", "Teaser")
|
publishAssetsTaskPhase(it.name, "postwork", "teaser", "running", "Teaser")
|
||||||
|
|
||||||
genTeaser, terr := ensureTeaserForVideoCtx(ctx, it.path, sourceURL)
|
genTeaser, terr := ensureTeaserForVideoCtx(fileCtx, it.path, sourceURL)
|
||||||
if abortIfCanceled(terr, it.name) {
|
if skipFile, stopAll := handleFileAbort(terr); stopAll {
|
||||||
return
|
return
|
||||||
|
} else if skipFile {
|
||||||
|
skipCurrentFile()
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if terr != nil {
|
if terr != nil {
|
||||||
publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen")
|
publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen")
|
||||||
} else if finishedPhaseTruthForID(id).TeaserReady {
|
} else if assetsTaskTruthForVideo(id, it.path).TeaserReady {
|
||||||
if genTeaser {
|
if genTeaser {
|
||||||
previewGenerated = true
|
previewGenerated = true
|
||||||
}
|
}
|
||||||
@ -531,13 +768,16 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
|
|
||||||
publishAssetsTaskPhase(it.name, "postwork", "sprites", "running", "Sprites")
|
publishAssetsTaskPhase(it.name, "postwork", "sprites", "running", "Sprites")
|
||||||
|
|
||||||
_, serr := ensureSpritesForVideoCtx(ctx, it.path, sourceURL)
|
_, serr := ensureSpritesForVideoCtx(fileCtx, it.path, sourceURL)
|
||||||
if abortIfCanceled(serr, it.name) {
|
if skipFile, stopAll := handleFileAbort(serr); stopAll {
|
||||||
return
|
return
|
||||||
|
} else if skipFile {
|
||||||
|
skipCurrentFile()
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if serr != nil {
|
if serr != nil {
|
||||||
publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen")
|
publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen")
|
||||||
} else if finishedPhaseTruthForID(id).SpritesReady {
|
} else if assetsTaskTruthForVideo(id, it.path).SpritesReady {
|
||||||
publishAssetsTaskPhase(it.name, "postwork", "sprites", "done", "Sprites")
|
publishAssetsTaskPhase(it.name, "postwork", "sprites", "done", "Sprites")
|
||||||
} else {
|
} else {
|
||||||
publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen")
|
publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen")
|
||||||
@ -552,21 +792,24 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
st.CurrentFile = it.name
|
st.CurrentFile = it.name
|
||||||
st.CurrentQueue = "enrich"
|
st.CurrentQueue = "enrich"
|
||||||
st.CurrentPhase = "analyze"
|
st.CurrentPhase = "analyze"
|
||||||
st.CurrentLabel = "KI-Analyse"
|
st.CurrentLabel = "Analyse"
|
||||||
})
|
})
|
||||||
|
|
||||||
publishAssetsTaskPhase(it.name, "enrich", "analyze", "running", "KI-Analyse")
|
publishAssetsTaskPhase(it.name, "enrich", "analyze", "running", "Analyse")
|
||||||
|
|
||||||
_, aerr := ensureAnalyzeForVideoCtx(ctx, it.path, sourceURL, "nsfw")
|
_, aerr := ensureAnalyzeForVideoCtx(fileCtx, it.path, sourceURL, "nsfw")
|
||||||
if abortIfCanceled(aerr, it.name) {
|
if skipFile, stopAll := handleFileAbort(aerr); stopAll {
|
||||||
return
|
return
|
||||||
|
} else if skipFile {
|
||||||
|
skipCurrentFile()
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if aerr != nil {
|
if aerr != nil {
|
||||||
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen")
|
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen")
|
||||||
} else if finishedPhaseTruthForID(id).AnalyzeReady {
|
} else if assetsTaskTruthForVideo(id, it.path).AnalyzeReady {
|
||||||
publishAssetsTaskPhase(it.name, "enrich", "analyze", "done", "KI-Analyse")
|
publishAssetsTaskPhase(it.name, "enrich", "analyze", "done", "Analyse")
|
||||||
} else {
|
} else {
|
||||||
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen")
|
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -577,8 +820,14 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
if previewGenerated {
|
if previewGenerated {
|
||||||
st.GeneratedPreviews++
|
st.GeneratedPreviews++
|
||||||
}
|
}
|
||||||
st.Done = i + 1
|
st.Done++
|
||||||
|
st.CurrentFile = ""
|
||||||
|
st.CurrentQueue = ""
|
||||||
|
st.CurrentPhase = ""
|
||||||
|
st.CurrentLabel = ""
|
||||||
})
|
})
|
||||||
|
|
||||||
|
finishFileControl()
|
||||||
}
|
}
|
||||||
|
|
||||||
finishWithErr(nil)
|
finishWithErr(nil)
|
||||||
|
|||||||
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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@ -61,7 +62,6 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
s := getSettings()
|
s := getSettings()
|
||||||
|
|
||||||
// doneDir auflösen
|
|
||||||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||||
if err != nil || strings.TrimSpace(doneAbs) == "" {
|
if err != nil || strings.TrimSpace(doneAbs) == "" {
|
||||||
setCleanupTaskError("doneDir auflösung fehlgeschlagen")
|
setCleanupTaskError("doneDir auflösung fehlgeschlagen")
|
||||||
@ -74,11 +74,8 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Threshold: standardmäßig aus Settings
|
|
||||||
mb := int(s.AutoDeleteSmallDownloadsBelowMB)
|
mb := int(s.AutoDeleteSmallDownloadsBelowMB)
|
||||||
|
|
||||||
// optional Body-Override (wenn du es später brauchst)
|
|
||||||
// (Frontend sendet aktuell nichts; ist trotzdem safe)
|
|
||||||
var req cleanupReq
|
var req cleanupReq
|
||||||
if r.Body != nil {
|
if r.Body != nil {
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
@ -90,44 +87,62 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
mb = 0
|
mb = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ exklusiv warten: Cleanup startet erst, wenn andere exklusive Tasks fertig sind
|
cleanupTaskMu.Lock()
|
||||||
if err := acquireExclusiveTask(r.Context()); err != nil {
|
if cleanupTaskState.Queued || cleanupTaskState.Running {
|
||||||
http.Error(w, err.Error(), http.StatusRequestTimeout)
|
st := cleanupTaskState
|
||||||
|
cleanupTaskMu.Unlock()
|
||||||
|
writeJSON(w, http.StatusOK, st)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer releaseExclusiveTask()
|
cleanupTaskMu.Unlock()
|
||||||
|
|
||||||
setCleanupTaskRunning("Räume auf…")
|
setCleanupTaskQueued("Wartet…")
|
||||||
|
st := snapshotCleanupTaskState()
|
||||||
|
|
||||||
resp := cleanupResp{}
|
go func(doneAbs string, mb int) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
// 1) Kleine Downloads löschen (wenn mb > 0)
|
if err := acquireExclusiveTask(ctx); err != nil {
|
||||||
if mb > 0 {
|
setCleanupTaskError(err.Error())
|
||||||
threshold := int64(mb) * 1024 * 1024
|
time.AfterFunc(7*time.Second, func() {
|
||||||
cleanupSmallFiles(doneAbs, threshold, &resp)
|
clearCleanupTaskState()
|
||||||
}
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer releaseExclusiveTask()
|
||||||
|
|
||||||
// ✅ Beim manuellen Aufräumen: Generated-GC synchron laufen lassen,
|
setCleanupTaskRunning("Räume auf…")
|
||||||
// damit die Zahlen in der JSON-Response landen.
|
|
||||||
gcStats := triggerGeneratedGarbageCollectorSync()
|
|
||||||
resp.GeneratedOrphansChecked = gcStats.Checked
|
|
||||||
resp.GeneratedOrphansRemoved = gcStats.Removed
|
|
||||||
resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes
|
|
||||||
resp.GeneratedOrphansRemovedBytesHuman = formatBytesSI(gcStats.RemovedBytes)
|
|
||||||
|
|
||||||
// ✅ Generated-GC zur insgesamt freigegebenen Größe addieren
|
resp := cleanupResp{}
|
||||||
resp.DeletedBytes += gcStats.RemovedBytes
|
|
||||||
resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes)
|
|
||||||
|
|
||||||
setCleanupTaskDone(
|
if mb > 0 {
|
||||||
cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes),
|
threshold := int64(mb) * 1024 * 1024
|
||||||
)
|
cleanupSmallFiles(doneAbs, threshold, &resp)
|
||||||
|
}
|
||||||
|
|
||||||
time.AfterFunc(7*time.Second, func() {
|
gcStats := triggerGeneratedGarbageCollectorSync()
|
||||||
clearCleanupTaskState()
|
resp.GeneratedOrphansChecked = gcStats.Checked
|
||||||
})
|
resp.GeneratedOrphansRemoved = gcStats.Removed
|
||||||
|
resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes
|
||||||
|
resp.GeneratedOrphansRemovedBytesHuman = formatBytesSI(gcStats.RemovedBytes)
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, resp)
|
resp.DeletedBytes += gcStats.RemovedBytes
|
||||||
|
resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes)
|
||||||
|
|
||||||
|
if resp.DeletedFiles > 0 || resp.GeneratedOrphansRemoved > 0 {
|
||||||
|
notifyDoneChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
setCleanupTaskDone(
|
||||||
|
cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes),
|
||||||
|
)
|
||||||
|
|
||||||
|
time.AfterFunc(7*time.Second, func() {
|
||||||
|
clearCleanupTaskState()
|
||||||
|
})
|
||||||
|
}(doneAbs, mb)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, st)
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanupProgressText(deleted int, total int, freedBytes int64) string {
|
func cleanupProgressText(deleted int, total int, freedBytes int64) string {
|
||||||
@ -317,6 +332,67 @@ func startGeneratedGarbageCollector() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func collectLiveIDsFromTrash(trashDir string, live map[string]struct{}) {
|
||||||
|
trashDir = strings.TrimSpace(trashDir)
|
||||||
|
if trashDir == "" || live == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(trashDir)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
if e == nil || e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(e.Name())
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// last.json ignorieren
|
||||||
|
if strings.EqualFold(name, "last.json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format in .trash:
|
||||||
|
// <token>__<original-file>
|
||||||
|
parts := strings.SplitN(name, "__", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
originalFile := strings.TrimSpace(parts[1])
|
||||||
|
if originalFile == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(originalFile))
|
||||||
|
if ext != ".mp4" && ext != ".ts" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
full := filepath.Join(trashDir, name)
|
||||||
|
fi, err := os.Stat(full)
|
||||||
|
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(originalFile), ext))
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if sid, err := sanitizeID(id); err == nil && sid != "" {
|
||||||
|
live[sid] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Core-Logik ohne Delay (für manuelle Trigger, z.B. nach Cleanup)
|
// Core-Logik ohne Delay (für manuelle Trigger, z.B. nach Cleanup)
|
||||||
// Liefert Stats zurück, damit /api/settings/cleanup die Zahlen anzeigen kann.
|
// Liefert Stats zurück, damit /api/settings/cleanup die Zahlen anzeigen kann.
|
||||||
func runGeneratedGarbageCollector() generatedGCStats {
|
func runGeneratedGarbageCollector() generatedGCStats {
|
||||||
@ -334,7 +410,7 @@ func runGeneratedGarbageCollector() generatedGCStats {
|
|||||||
return stats
|
return stats
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Live-IDs sammeln: alle mp4/ts unter /done (rekursiv), .trash ignorieren
|
// 1) Live-IDs sammeln: alle mp4/ts unter /done (rekursiv), .trash zunächst ignorieren
|
||||||
live := make(map[string]struct{}, 4096)
|
live := make(map[string]struct{}, 4096)
|
||||||
|
|
||||||
_ = filepath.WalkDir(doneAbs, func(p string, d fs.DirEntry, err error) error {
|
_ = filepath.WalkDir(doneAbs, func(p string, d fs.DirEntry, err error) error {
|
||||||
@ -371,6 +447,11 @@ func runGeneratedGarbageCollector() generatedGCStats {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 1a) Dateien in .trash ebenfalls als "live" behandeln,
|
||||||
|
// damit ihre Generated-Dateien erst gelöscht werden,
|
||||||
|
// wenn sie wirklich endgültig aus .trash verschwinden.
|
||||||
|
collectLiveIDsFromTrash(filepath.Join(doneAbs, ".trash"), live)
|
||||||
|
|
||||||
// 1b) Auch aktuell laufende / nachbearbeitete Jobs als "live" behandeln.
|
// 1b) Auch aktuell laufende / nachbearbeitete Jobs als "live" behandeln.
|
||||||
// Sonst räumt der GC generated/meta/<id> weg, obwohl RecordStream dort noch schreibt.
|
// Sonst räumt der GC generated/meta/<id> weg, obwohl RecordStream dort noch schreibt.
|
||||||
jobsMu.RLock()
|
jobsMu.RLock()
|
||||||
|
|||||||
@ -4,12 +4,88 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"sync/atomic"
|
||||||
)
|
)
|
||||||
|
|
||||||
var exclusiveTaskSem = make(chan struct{}, 1)
|
type exclusiveTaskRequest struct {
|
||||||
|
id uint64
|
||||||
|
granted chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type exclusiveTaskCancelRequest struct {
|
||||||
|
id uint64
|
||||||
|
ack chan bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
exclusiveTaskReqCh = make(chan *exclusiveTaskRequest)
|
||||||
|
exclusiveTaskCancelCh = make(chan exclusiveTaskCancelRequest)
|
||||||
|
exclusiveTaskReleaseCh = make(chan struct{}, 1)
|
||||||
|
exclusiveTaskNextID atomic.Uint64
|
||||||
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
exclusiveTaskSem <- struct{}{}
|
go runExclusiveTaskQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runExclusiveTaskQueue() {
|
||||||
|
queue := make([]*exclusiveTaskRequest, 0, 8)
|
||||||
|
|
||||||
|
locked := false
|
||||||
|
var activeID uint64
|
||||||
|
|
||||||
|
grantNext := func() {
|
||||||
|
if locked {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for len(queue) > 0 {
|
||||||
|
req := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
|
||||||
|
if req == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
locked = true
|
||||||
|
activeID = req.id
|
||||||
|
close(req.granted)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activeID = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
grantNext()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case req := <-exclusiveTaskReqCh:
|
||||||
|
if req != nil {
|
||||||
|
queue = append(queue, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
case cancelReq := <-exclusiveTaskCancelCh:
|
||||||
|
grantedAlready := locked && activeID == cancelReq.id
|
||||||
|
|
||||||
|
if !grantedAlready {
|
||||||
|
for i, req := range queue {
|
||||||
|
if req != nil && req.id == cancelReq.id {
|
||||||
|
queue = append(queue[:i], queue[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelReq.ack <- grantedAlready
|
||||||
|
|
||||||
|
case <-exclusiveTaskReleaseCh:
|
||||||
|
if locked {
|
||||||
|
locked = false
|
||||||
|
activeID = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func acquireExclusiveTask(ctx context.Context) error {
|
func acquireExclusiveTask(ctx context.Context) error {
|
||||||
@ -17,17 +93,41 @@ func acquireExclusiveTask(ctx context.Context) error {
|
|||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req := &exclusiveTaskRequest{
|
||||||
|
id: exclusiveTaskNextID.Add(1),
|
||||||
|
granted: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
|
case exclusiveTaskReqCh <- req:
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case <-exclusiveTaskSem:
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-req.granted:
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
case <-ctx.Done():
|
||||||
|
ack := make(chan bool, 1)
|
||||||
|
|
||||||
|
exclusiveTaskCancelCh <- exclusiveTaskCancelRequest{
|
||||||
|
id: req.id,
|
||||||
|
ack: ack,
|
||||||
|
}
|
||||||
|
|
||||||
|
grantedAlready := <-ack
|
||||||
|
if grantedAlready {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func releaseExclusiveTask() {
|
func releaseExclusiveTask() {
|
||||||
select {
|
select {
|
||||||
case exclusiveTaskSem <- struct{}{}:
|
case exclusiveTaskReleaseCh <- struct{}{}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,13 +9,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const regenerateAssetsMaxParallel = 1
|
|
||||||
|
|
||||||
type regenerateAssetsRequest struct {
|
type regenerateAssetsRequest struct {
|
||||||
File string `json:"file"`
|
File string `json:"file"`
|
||||||
}
|
}
|
||||||
@ -47,10 +46,8 @@ type regenerateAssetsJob struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
regenerateAssetsMu sync.Mutex
|
regenerateAssetsMu sync.Mutex
|
||||||
regenerateAssetsJobs = map[string]*regenerateAssetsJob{} // key = file
|
regenerateAssetsJobs = map[string]*regenerateAssetsJob{} // key = file
|
||||||
regenerateAssetsQueue []string // file names
|
|
||||||
regenerateAssetsWorkersStarted bool
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func publishFinishedPostworkPhase(
|
func publishFinishedPostworkPhase(
|
||||||
@ -152,56 +149,11 @@ func firstMissingDeferredRegeneratePhaseError(videoPath string, assetID string)
|
|||||||
return errors.New("preview-sprite.jpg fehlt oder ist leer")
|
return errors.New("preview-sprite.jpg fehlt oder ist leer")
|
||||||
}
|
}
|
||||||
if !truth.AnalyzeReady {
|
if !truth.AnalyzeReady {
|
||||||
return errors.New("KI-Analyse fehlt oder wurde nicht in meta.json gespeichert")
|
return errors.New("Analyse fehlt oder wurde nicht in meta.json gespeichert")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureRegenerateAssetsWorkersStarted() {
|
|
||||||
regenerateAssetsMu.Lock()
|
|
||||||
defer regenerateAssetsMu.Unlock()
|
|
||||||
|
|
||||||
if regenerateAssetsWorkersStarted {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
regenerateAssetsWorkersStarted = true
|
|
||||||
|
|
||||||
for i := 0; i < regenerateAssetsMaxParallel; i++ {
|
|
||||||
go regenerateAssetsWorker()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func enqueueRegenerateAssetsJob(job *regenerateAssetsJob) {
|
|
||||||
regenerateAssetsMu.Lock()
|
|
||||||
defer regenerateAssetsMu.Unlock()
|
|
||||||
|
|
||||||
regenerateAssetsJobs[job.File] = job
|
|
||||||
regenerateAssetsQueue = append(regenerateAssetsQueue, job.File)
|
|
||||||
}
|
|
||||||
|
|
||||||
func nextQueuedRegenerateAssetsJob() *regenerateAssetsJob {
|
|
||||||
regenerateAssetsMu.Lock()
|
|
||||||
defer regenerateAssetsMu.Unlock()
|
|
||||||
|
|
||||||
for len(regenerateAssetsQueue) > 0 {
|
|
||||||
file := regenerateAssetsQueue[0]
|
|
||||||
regenerateAssetsQueue = regenerateAssetsQueue[1:]
|
|
||||||
|
|
||||||
job := regenerateAssetsJobs[file]
|
|
||||||
if job == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if job.State != "queued" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
return job
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func markRegenerateAssetsJobRunning(file string) *regenerateAssetsJob {
|
func markRegenerateAssetsJobRunning(file string) *regenerateAssetsJob {
|
||||||
regenerateAssetsMu.Lock()
|
regenerateAssetsMu.Lock()
|
||||||
defer regenerateAssetsMu.Unlock()
|
defer regenerateAssetsMu.Unlock()
|
||||||
@ -270,14 +222,69 @@ func removeRegenerateAssetsJobLater(file string, after time.Duration) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func regeneratePhaseOrder(queue string, phase string) int {
|
||||||
|
q := strings.ToLower(strings.TrimSpace(queue))
|
||||||
|
p := strings.ToLower(strings.TrimSpace(phase))
|
||||||
|
|
||||||
|
switch q + ":" + p {
|
||||||
|
case "postwork:meta":
|
||||||
|
return 0
|
||||||
|
case "postwork:thumb":
|
||||||
|
return 1
|
||||||
|
case "postwork:teaser":
|
||||||
|
return 2
|
||||||
|
case "postwork:sprites":
|
||||||
|
return 3
|
||||||
|
case "enrich:analyze":
|
||||||
|
return 4
|
||||||
|
default:
|
||||||
|
return 999
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func getRegenerateAssetsJobsSnapshot() []map[string]any {
|
func getRegenerateAssetsJobsSnapshot() []map[string]any {
|
||||||
regenerateAssetsMu.Lock()
|
regenerateAssetsMu.Lock()
|
||||||
defer regenerateAssetsMu.Unlock()
|
defer regenerateAssetsMu.Unlock()
|
||||||
|
|
||||||
out := make([]map[string]any, 0, len(regenerateAssetsJobs))
|
jobs := make([]*regenerateAssetsJob, 0, len(regenerateAssetsJobs))
|
||||||
for _, job := range regenerateAssetsJobs {
|
for _, job := range regenerateAssetsJobs {
|
||||||
phases := make([]map[string]any, 0, len(job.Phases))
|
if job != nil {
|
||||||
|
jobs = append(jobs, job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(jobs, func(i, j int) bool {
|
||||||
|
ai := jobs[i].EnqueuedAt.UnixMilli()
|
||||||
|
aj := jobs[j].EnqueuedAt.UnixMilli()
|
||||||
|
|
||||||
|
if ai != aj {
|
||||||
|
return ai < aj
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobs[i].File < jobs[j].File
|
||||||
|
})
|
||||||
|
|
||||||
|
out := make([]map[string]any, 0, len(jobs))
|
||||||
|
|
||||||
|
for _, job := range jobs {
|
||||||
|
phaseList := make([]regenerateAssetsPhase, 0, len(job.Phases))
|
||||||
for _, p := range job.Phases {
|
for _, p := range job.Phases {
|
||||||
|
phaseList = append(phaseList, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(phaseList, func(i, j int) bool {
|
||||||
|
oi := regeneratePhaseOrder(phaseList[i].Queue, phaseList[i].Phase)
|
||||||
|
oj := regeneratePhaseOrder(phaseList[j].Queue, phaseList[j].Phase)
|
||||||
|
|
||||||
|
if oi != oj {
|
||||||
|
return oi < oj
|
||||||
|
}
|
||||||
|
|
||||||
|
return phaseList[i].TS < phaseList[j].TS
|
||||||
|
})
|
||||||
|
|
||||||
|
phases := make([]map[string]any, 0, len(phaseList))
|
||||||
|
for _, p := range phaseList {
|
||||||
phases = append(phases, map[string]any{
|
phases = append(phases, map[string]any{
|
||||||
"queue": p.Queue,
|
"queue": p.Queue,
|
||||||
"phase": p.Phase,
|
"phase": p.Phase,
|
||||||
@ -288,12 +295,17 @@ func getRegenerateAssetsJobsSnapshot() []map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out = append(out, map[string]any{
|
out = append(out, map[string]any{
|
||||||
"file": job.File,
|
"file": job.File,
|
||||||
"assetId": job.AssetID,
|
"assetId": job.AssetID,
|
||||||
"state": job.State,
|
"state": job.State,
|
||||||
"error": job.Error,
|
"error": job.Error,
|
||||||
"phases": phases,
|
"phases": phases,
|
||||||
"enqueuedAt": job.EnqueuedAt.UnixMilli(),
|
"enqueuedAt": func() int64 {
|
||||||
|
if job.EnqueuedAt.IsZero() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return job.EnqueuedAt.UnixMilli()
|
||||||
|
}(),
|
||||||
"startedAt": func() int64 {
|
"startedAt": func() int64 {
|
||||||
if job.StartedAt.IsZero() {
|
if job.StartedAt.IsZero() {
|
||||||
return 0
|
return 0
|
||||||
@ -312,35 +324,6 @@ func getRegenerateAssetsJobsSnapshot() []map[string]any {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func regenerateAssetsWorker() {
|
|
||||||
for {
|
|
||||||
job := nextQueuedRegenerateAssetsJob()
|
|
||||||
if job == nil {
|
|
||||||
time.Sleep(250 * time.Millisecond)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ wartet global, bis kein anderer exklusiver Task läuft
|
|
||||||
if err := acquireExclusiveTask(job.Ctx); err != nil {
|
|
||||||
finishRegenerateAssetsJob(job.File, "cancelled", "Abgebrochen")
|
|
||||||
setRegenerateAssetsTaskError(job.File, "Abgebrochen")
|
|
||||||
removeRegenerateAssetsJobLater(job.File, 2*time.Second)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
job = markRegenerateAssetsJobRunning(job.File)
|
|
||||||
if job == nil {
|
|
||||||
releaseExclusiveTask()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
func() {
|
|
||||||
defer releaseExclusiveTask()
|
|
||||||
runRegenerateAssetsJob(job)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runRegenerateAssetsJob(job *regenerateAssetsJob) {
|
func runRegenerateAssetsJob(job *regenerateAssetsJob) {
|
||||||
file := job.File
|
file := job.File
|
||||||
videoPath := job.VideoPath
|
videoPath := job.VideoPath
|
||||||
@ -385,7 +368,7 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
|
|||||||
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild", "")
|
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild", "")
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser", "")
|
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser", "")
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites", "")
|
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites", "")
|
||||||
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse", "")
|
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "Analyse", "")
|
||||||
|
|
||||||
// Alte Assets löschen
|
// Alte Assets löschen
|
||||||
if id != "" {
|
if id != "" {
|
||||||
@ -471,8 +454,8 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
|
|||||||
|
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites", "")
|
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites", "")
|
||||||
|
|
||||||
// 5) KI-Analyse
|
// 5) Analyse
|
||||||
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "KI-Analyse", "")
|
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "Analyse", "")
|
||||||
|
|
||||||
if _, err := ensureAnalyzeForVideoCtx(ctx, videoPath, "", "nsfw"); err != nil {
|
if _, err := ensureAnalyzeForVideoCtx(ctx, videoPath, "", "nsfw"); err != nil {
|
||||||
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
|
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
|
||||||
@ -484,11 +467,11 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !regeneratePhaseTruthForVideo(videoPath, id).AnalyzeReady {
|
if !regeneratePhaseTruthForVideo(videoPath, id).AnalyzeReady {
|
||||||
failJob("enrich", "analyze", "KI-Analyse fehlt oder wurde nicht in meta.json gespeichert")
|
failJob("enrich", "analyze", "Analyse fehlt oder wurde nicht in meta.json gespeichert")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "KI-Analyse", "")
|
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "Analyse", "")
|
||||||
|
|
||||||
finishRegenerateAssetsJob(file, "done", "")
|
finishRegenerateAssetsJob(file, "done", "")
|
||||||
setRegenerateAssetsTaskDone(file, "Fertig")
|
setRegenerateAssetsTaskDone(file, "Fertig")
|
||||||
@ -577,15 +560,30 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureRegenerateAssetsWorkersStarted()
|
|
||||||
|
|
||||||
regenerateAssetsMu.Lock()
|
regenerateAssetsMu.Lock()
|
||||||
if existing := regenerateAssetsJobs[file]; existing != nil {
|
if existing := regenerateAssetsJobs[file]; existing != nil {
|
||||||
|
state := existing.State
|
||||||
|
errText := existing.Error
|
||||||
|
|
||||||
|
phaseList := make([]regenerateAssetsPhase, 0, len(existing.Phases))
|
||||||
|
for _, p := range existing.Phases {
|
||||||
|
phaseList = append(phaseList, p)
|
||||||
|
}
|
||||||
regenerateAssetsMu.Unlock()
|
regenerateAssetsMu.Unlock()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
sort.SliceStable(phaseList, func(i, j int) bool {
|
||||||
phases := make([]map[string]any, 0, len(existing.Phases))
|
oi := regeneratePhaseOrder(phaseList[i].Queue, phaseList[i].Phase)
|
||||||
for _, p := range existing.Phases {
|
oj := regeneratePhaseOrder(phaseList[j].Queue, phaseList[j].Phase)
|
||||||
|
|
||||||
|
if oi != oj {
|
||||||
|
return oi < oj
|
||||||
|
}
|
||||||
|
|
||||||
|
return phaseList[i].TS < phaseList[j].TS
|
||||||
|
})
|
||||||
|
|
||||||
|
phases := make([]map[string]any, 0, len(phaseList))
|
||||||
|
for _, p := range phaseList {
|
||||||
phases = append(phases, map[string]any{
|
phases = append(phases, map[string]any{
|
||||||
"queue": p.Queue,
|
"queue": p.Queue,
|
||||||
"phase": p.Phase,
|
"phase": p.Phase,
|
||||||
@ -595,11 +593,12 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"file": file,
|
"file": file,
|
||||||
"state": existing.State,
|
"state": state,
|
||||||
"error": existing.Error,
|
"error": errText,
|
||||||
"phases": phases,
|
"phases": phases,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@ -624,13 +623,39 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
|
|||||||
Phases: make(map[string]regenerateAssetsPhase),
|
Phases: make(map[string]regenerateAssetsPhase),
|
||||||
}
|
}
|
||||||
|
|
||||||
enqueueRegenerateAssetsJob(job)
|
regenerateAssetsMu.Lock()
|
||||||
|
regenerateAssetsJobs[file] = job
|
||||||
|
regenerateAssetsMu.Unlock()
|
||||||
|
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "meta", "queued", "Meta", "")
|
publishFinishedPostworkPhase(file, id, "postwork", "meta", "queued", "Meta", "")
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild", "")
|
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild", "")
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser", "")
|
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser", "")
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites", "")
|
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites", "")
|
||||||
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse", "")
|
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "Analyse", "")
|
||||||
|
|
||||||
|
go func(file string, runCtx context.Context) {
|
||||||
|
if err := acquireExclusiveTask(runCtx); err != nil {
|
||||||
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
finishRegenerateAssetsJob(file, "cancelled", "Abgebrochen")
|
||||||
|
setRegenerateAssetsTaskError(file, "Abgebrochen")
|
||||||
|
removeRegenerateAssetsJobLater(file, 2*time.Second)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
finishRegenerateAssetsJob(file, "error", err.Error())
|
||||||
|
setRegenerateAssetsTaskError(file, err.Error())
|
||||||
|
removeRegenerateAssetsJobLater(file, 5*time.Second)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer releaseExclusiveTask()
|
||||||
|
|
||||||
|
runningJob := markRegenerateAssetsJobRunning(file)
|
||||||
|
if runningJob == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runRegenerateAssetsJob(runningJob)
|
||||||
|
}(file, runCtx)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type releaseFileTasksRequest struct {
|
type releaseFileTasksRequest struct {
|
||||||
@ -44,20 +43,24 @@ func sameBaseFile(a, b string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cancelAssetsTaskForFile(file string) bool {
|
func cancelAssetsTaskForFile(file string) bool {
|
||||||
|
file = strings.TrimSpace(file)
|
||||||
|
if file == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
assetsTaskMu.Lock()
|
assetsTaskMu.Lock()
|
||||||
cancel := assetsTaskCancel
|
active := assetsTaskState.Running || assetsTaskState.Queued
|
||||||
running := assetsTaskState.Running
|
|
||||||
currentFile := assetsTaskState.CurrentFile
|
|
||||||
assetsTaskMu.Unlock()
|
assetsTaskMu.Unlock()
|
||||||
|
|
||||||
if !running || cancel == nil {
|
if !active {
|
||||||
return false
|
|
||||||
}
|
|
||||||
if !sameBaseFile(currentFile, file) {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel()
|
// Wichtig:
|
||||||
|
// NICHT den globalen Assets-Task canceln.
|
||||||
|
// Stattdessen nur diese Datei im Assets-Task zum Überspringen markieren.
|
||||||
|
_ = requestAssetsTaskSkip(file)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,11 +186,6 @@ func releaseFileTasks(file string) releaseFileTasksResponse {
|
|||||||
resp.PostworkCancelled ||
|
resp.PostworkCancelled ||
|
||||||
resp.EnrichCancelled
|
resp.EnrichCancelled
|
||||||
|
|
||||||
// ffmpeg / Analyse / Sprite-Generierung einen Moment zum Beenden geben
|
|
||||||
if resp.ReleasedAny {
|
|
||||||
time.Sleep(900 * time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,13 +11,29 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type CleanupTaskState struct {
|
type CleanupTaskState struct {
|
||||||
Running bool `json:"running"`
|
Queued bool `json:"queued"`
|
||||||
Done int `json:"done"`
|
Running bool `json:"running"`
|
||||||
Total int `json:"total"`
|
QueuedAt time.Time `json:"queuedAt"`
|
||||||
Text string `json:"text,omitempty"`
|
StartedAt time.Time `json:"startedAt"`
|
||||||
CurrentFile string `json:"currentFile,omitempty"`
|
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
FinishedAt *string `json:"finishedAt,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
CurrentFile string `json:"currentFile,omitempty"`
|
||||||
|
Done int `json:"done"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CheckVideosTaskState struct {
|
||||||
|
Queued bool `json:"queued"`
|
||||||
|
Running bool `json:"running"`
|
||||||
|
QueuedAt time.Time `json:"queuedAt"`
|
||||||
|
StartedAt time.Time `json:"startedAt"`
|
||||||
|
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
CurrentFile string `json:"currentFile,omitempty"`
|
||||||
|
Done int `json:"done"`
|
||||||
|
Total int `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RegenerateAssetsTaskState struct {
|
type RegenerateAssetsTaskState struct {
|
||||||
@ -31,6 +47,7 @@ type TasksStatusResponse struct {
|
|||||||
GenerateAssets AssetsTaskState `json:"generateAssets"`
|
GenerateAssets AssetsTaskState `json:"generateAssets"`
|
||||||
RegenerateAssets RegenerateAssetsTaskState `json:"regenerateAssets"`
|
RegenerateAssets RegenerateAssetsTaskState `json:"regenerateAssets"`
|
||||||
RegenerateAssetsJobs []map[string]any `json:"regenerateAssetsJobs"`
|
RegenerateAssetsJobs []map[string]any `json:"regenerateAssetsJobs"`
|
||||||
|
CheckVideos CheckVideosTaskState `json:"checkVideos"`
|
||||||
Cleanup CleanupTaskState `json:"cleanup"`
|
Cleanup CleanupTaskState `json:"cleanup"`
|
||||||
AnyRunning bool `json:"anyRunning"`
|
AnyRunning bool `json:"anyRunning"`
|
||||||
}
|
}
|
||||||
@ -43,9 +60,9 @@ var (
|
|||||||
regenerateAssetsTaskState RegenerateAssetsTaskState
|
regenerateAssetsTaskState RegenerateAssetsTaskState
|
||||||
)
|
)
|
||||||
|
|
||||||
func cleanupNowISO() *string {
|
func cleanupNowTime() *time.Time {
|
||||||
s := time.Now().Format(time.RFC3339)
|
t := time.Now()
|
||||||
return &s
|
return &t
|
||||||
}
|
}
|
||||||
|
|
||||||
func anyToString(v any) string {
|
func anyToString(v any) string {
|
||||||
@ -120,16 +137,45 @@ func snapshotCleanupTaskState() CleanupTaskState {
|
|||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
|
|
||||||
func setCleanupTaskRunning(text string) {
|
func setCleanupTaskQueued(text string) {
|
||||||
cleanupTaskMu.Lock()
|
cleanupTaskMu.Lock()
|
||||||
cleanupTaskState = CleanupTaskState{
|
cleanupTaskState = CleanupTaskState{
|
||||||
Running: true,
|
Queued: true,
|
||||||
|
Running: false,
|
||||||
|
QueuedAt: time.Now(),
|
||||||
|
StartedAt: time.Time{},
|
||||||
|
FinishedAt: nil,
|
||||||
|
Text: strings.TrimSpace(text),
|
||||||
|
Error: "",
|
||||||
|
CurrentFile: "",
|
||||||
Done: 0,
|
Done: 0,
|
||||||
Total: 0,
|
Total: 0,
|
||||||
Text: strings.TrimSpace(text),
|
}
|
||||||
CurrentFile: "",
|
cleanupTaskMu.Unlock()
|
||||||
Error: "",
|
|
||||||
|
publishTaskState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCleanupTaskRunning(text string) {
|
||||||
|
cleanupTaskMu.Lock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
queuedAt := cleanupTaskState.QueuedAt
|
||||||
|
if queuedAt.IsZero() {
|
||||||
|
queuedAt = now
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupTaskState = CleanupTaskState{
|
||||||
|
Queued: false,
|
||||||
|
Running: true,
|
||||||
|
QueuedAt: queuedAt,
|
||||||
|
StartedAt: now,
|
||||||
FinishedAt: nil,
|
FinishedAt: nil,
|
||||||
|
Text: strings.TrimSpace(text),
|
||||||
|
Error: "",
|
||||||
|
CurrentFile: "",
|
||||||
|
Done: 0,
|
||||||
|
Total: 0,
|
||||||
}
|
}
|
||||||
cleanupTaskMu.Unlock()
|
cleanupTaskMu.Unlock()
|
||||||
|
|
||||||
@ -138,6 +184,16 @@ func setCleanupTaskRunning(text string) {
|
|||||||
|
|
||||||
func setCleanupTaskProgress(done, total int, currentFile, text string) {
|
func setCleanupTaskProgress(done, total int, currentFile, text string) {
|
||||||
cleanupTaskMu.Lock()
|
cleanupTaskMu.Lock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if cleanupTaskState.QueuedAt.IsZero() {
|
||||||
|
cleanupTaskState.QueuedAt = now
|
||||||
|
}
|
||||||
|
if cleanupTaskState.StartedAt.IsZero() {
|
||||||
|
cleanupTaskState.StartedAt = now
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupTaskState.Queued = false
|
||||||
cleanupTaskState.Running = true
|
cleanupTaskState.Running = true
|
||||||
cleanupTaskState.Done = done
|
cleanupTaskState.Done = done
|
||||||
cleanupTaskState.Total = total
|
cleanupTaskState.Total = total
|
||||||
@ -145,6 +201,7 @@ func setCleanupTaskProgress(done, total int, currentFile, text string) {
|
|||||||
cleanupTaskState.Text = strings.TrimSpace(text)
|
cleanupTaskState.Text = strings.TrimSpace(text)
|
||||||
cleanupTaskState.Error = ""
|
cleanupTaskState.Error = ""
|
||||||
cleanupTaskState.FinishedAt = nil
|
cleanupTaskState.FinishedAt = nil
|
||||||
|
|
||||||
cleanupTaskMu.Unlock()
|
cleanupTaskMu.Unlock()
|
||||||
|
|
||||||
publishTaskState()
|
publishTaskState()
|
||||||
@ -152,11 +209,13 @@ func setCleanupTaskProgress(done, total int, currentFile, text string) {
|
|||||||
|
|
||||||
func setCleanupTaskDone(text string) {
|
func setCleanupTaskDone(text string) {
|
||||||
cleanupTaskMu.Lock()
|
cleanupTaskMu.Lock()
|
||||||
|
|
||||||
|
cleanupTaskState.Queued = false
|
||||||
cleanupTaskState.Running = false
|
cleanupTaskState.Running = false
|
||||||
cleanupTaskState.Text = strings.TrimSpace(text)
|
cleanupTaskState.Text = strings.TrimSpace(text)
|
||||||
cleanupTaskState.CurrentFile = ""
|
cleanupTaskState.CurrentFile = ""
|
||||||
cleanupTaskState.Error = ""
|
cleanupTaskState.Error = ""
|
||||||
cleanupTaskState.FinishedAt = cleanupNowISO()
|
cleanupTaskState.FinishedAt = cleanupNowTime()
|
||||||
|
|
||||||
if cleanupTaskState.Total > 0 {
|
if cleanupTaskState.Total > 0 {
|
||||||
cleanupTaskState.Done = cleanupTaskState.Total
|
cleanupTaskState.Done = cleanupTaskState.Total
|
||||||
@ -169,11 +228,14 @@ func setCleanupTaskDone(text string) {
|
|||||||
|
|
||||||
func setCleanupTaskError(errText string) {
|
func setCleanupTaskError(errText string) {
|
||||||
cleanupTaskMu.Lock()
|
cleanupTaskMu.Lock()
|
||||||
|
|
||||||
|
cleanupTaskState.Queued = false
|
||||||
cleanupTaskState.Running = false
|
cleanupTaskState.Running = false
|
||||||
cleanupTaskState.Text = ""
|
cleanupTaskState.Text = ""
|
||||||
cleanupTaskState.Error = strings.TrimSpace(errText)
|
cleanupTaskState.Error = strings.TrimSpace(errText)
|
||||||
cleanupTaskState.CurrentFile = ""
|
cleanupTaskState.CurrentFile = ""
|
||||||
cleanupTaskState.FinishedAt = cleanupNowISO()
|
cleanupTaskState.FinishedAt = cleanupNowTime()
|
||||||
|
|
||||||
cleanupTaskMu.Unlock()
|
cleanupTaskMu.Unlock()
|
||||||
|
|
||||||
publishTaskState()
|
publishTaskState()
|
||||||
@ -188,6 +250,7 @@ func tasksStatusHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
assets := getGenerateAssetsTaskStatus()
|
assets := getGenerateAssetsTaskStatus()
|
||||||
regenerate := snapshotRegenerateAssetsTaskState()
|
regenerate := snapshotRegenerateAssetsTaskState()
|
||||||
regenerateJobs := getRegenerateAssetsJobsSnapshot()
|
regenerateJobs := getRegenerateAssetsJobsSnapshot()
|
||||||
|
checkVideos := snapshotCheckVideosTaskState()
|
||||||
cleanup := snapshotCleanupTaskState()
|
cleanup := snapshotCleanupTaskState()
|
||||||
|
|
||||||
anyRegenerateRunning := regenerate.Running
|
anyRegenerateRunning := regenerate.Running
|
||||||
@ -205,8 +268,9 @@ func tasksStatusHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
GenerateAssets: assets,
|
GenerateAssets: assets,
|
||||||
RegenerateAssets: regenerate,
|
RegenerateAssets: regenerate,
|
||||||
RegenerateAssetsJobs: regenerateJobs,
|
RegenerateAssetsJobs: regenerateJobs,
|
||||||
|
CheckVideos: checkVideos,
|
||||||
Cleanup: cleanup,
|
Cleanup: cleanup,
|
||||||
AnyRunning: assets.Queued || assets.Running || anyRegenerateRunning || cleanup.Running,
|
AnyRunning: assets.Queued || assets.Running || anyRegenerateRunning || checkVideos.Queued || checkVideos.Running || cleanup.Queued || cleanup.Running,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|||||||
567
backend/teaser.go
Normal file
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
|
## React Compiler
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
// frontend\src\App.tsx
|
// frontend\src\App.tsx
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from 'react'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
import Button from './components/ui/Button'
|
import Button from './components/ui/Button'
|
||||||
import CookieModal from './components/ui/CookieModal'
|
import CookieModal from './components/ui/CookieModal'
|
||||||
@ -26,7 +26,6 @@ import {
|
|||||||
} from '@heroicons/react/24/solid'
|
} from '@heroicons/react/24/solid'
|
||||||
import PerformanceMonitor from './components/ui/PerformanceMonitor'
|
import PerformanceMonitor from './components/ui/PerformanceMonitor'
|
||||||
import { useNotify } from './components/ui/notify'
|
import { useNotify } from './components/ui/notify'
|
||||||
//import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller'
|
|
||||||
import CategoriesTab from './components/ui/CategoriesTab'
|
import CategoriesTab from './components/ui/CategoriesTab'
|
||||||
import LoginPage from './components/ui/LoginPage'
|
import LoginPage from './components/ui/LoginPage'
|
||||||
import VideoSplitModal from './components/ui/VideoSplitModal'
|
import VideoSplitModal from './components/ui/VideoSplitModal'
|
||||||
@ -125,6 +124,25 @@ type TaskStateEvent = {
|
|||||||
ts?: number
|
ts?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BackgroundSplitProgress = {
|
||||||
|
phase: 'preparing' | 'splitting' | 'done' | 'error'
|
||||||
|
total: number
|
||||||
|
current: number
|
||||||
|
completed: number
|
||||||
|
segmentProgress?: number
|
||||||
|
overallProgress?: number
|
||||||
|
message?: string
|
||||||
|
segment?: {
|
||||||
|
index: number
|
||||||
|
label: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
startedAtMs: number
|
||||||
|
sourceFile: string
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
|
const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
|
||||||
recordDir: 'records',
|
recordDir: 'records',
|
||||||
doneDir: 'records/done',
|
doneDir: 'records/done',
|
||||||
@ -602,6 +620,61 @@ function upsertJobById(list: RecordJob[], incoming: RecordJob): RecordJob[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
|
||||||
|
const [splitProgressByFile, setSplitProgressByFile] = useState<Record<string, BackgroundSplitProgress>>({})
|
||||||
|
const splitProgressClearTimersRef = useRef<Record<string, number>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
for (const t of Object.values(splitProgressClearTimersRef.current)) {
|
||||||
|
window.clearTimeout(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearSplitProgressTimer = useCallback((file: string) => {
|
||||||
|
const t = splitProgressClearTimersRef.current[file]
|
||||||
|
if (t != null) {
|
||||||
|
window.clearTimeout(t)
|
||||||
|
delete splitProgressClearTimersRef.current[file]
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const scheduleSplitProgressCleanup = useCallback((file: string, delayMs: number) => {
|
||||||
|
clearSplitProgressTimer(file)
|
||||||
|
|
||||||
|
splitProgressClearTimersRef.current[file] = window.setTimeout(() => {
|
||||||
|
setSplitProgressByFile((prev) => {
|
||||||
|
if (!(file in prev)) return prev
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[file]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
delete splitProgressClearTimersRef.current[file]
|
||||||
|
}, delayMs)
|
||||||
|
}, [clearSplitProgressTimer])
|
||||||
|
|
||||||
|
const pushSplitProgress = useCallback((
|
||||||
|
file: string,
|
||||||
|
progress: Omit<BackgroundSplitProgress, 'sourceFile' | 'startedAtMs'>
|
||||||
|
) => {
|
||||||
|
clearSplitProgressTimer(file)
|
||||||
|
|
||||||
|
setSplitProgressByFile((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[file]: {
|
||||||
|
sourceFile: file,
|
||||||
|
startedAtMs: prev[file]?.startedAtMs ?? Date.now(),
|
||||||
|
...progress,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (progress.phase === 'done') {
|
||||||
|
scheduleSplitProgressCleanup(file, 8000)
|
||||||
|
} else if (progress.phase === 'error') {
|
||||||
|
scheduleSplitProgressCleanup(file, 15000)
|
||||||
|
}
|
||||||
|
}, [clearSplitProgressTimer, scheduleSplitProgressCleanup])
|
||||||
|
|
||||||
const [authChecked, setAuthChecked] = useState(false)
|
const [authChecked, setAuthChecked] = useState(false)
|
||||||
const [authed, setAuthed] = useState(false)
|
const [authed, setAuthed] = useState(false)
|
||||||
@ -3766,7 +3839,7 @@ export default function App() {
|
|||||||
ref={sourceUrlInputRef}
|
ref={sourceUrlInputRef}
|
||||||
value={sourceUrl}
|
value={sourceUrl}
|
||||||
size='sm'
|
size='sm'
|
||||||
onChange={(e) => setSourceUrl(e.target.value)}
|
onChange={(e: { target: { value: SetStateAction<string> } }) => setSourceUrl(e.target.value)}
|
||||||
selectAllOnFocus
|
selectAllOnFocus
|
||||||
selectAllOnMouseDown
|
selectAllOnMouseDown
|
||||||
placeholder="https://…"
|
placeholder="https://…"
|
||||||
@ -3890,6 +3963,7 @@ export default function App() {
|
|||||||
setDonePage(1)
|
setDonePage(1)
|
||||||
donePrefetchRef.current = null
|
donePrefetchRef.current = null
|
||||||
}}
|
}}
|
||||||
|
splitProgressByFile={splitProgressByFile}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -3952,13 +4026,268 @@ export default function App() {
|
|||||||
setSplitModalOpen(false)
|
setSplitModalOpen(false)
|
||||||
setSplitJob(null)
|
setSplitJob(null)
|
||||||
}}
|
}}
|
||||||
onApply={async ({ job, splits, segments }) => {
|
onApply={({ job, splits, segments, deleteOriginal }) => {
|
||||||
console.log('VIDEO_SPLIT_APPLY', {
|
const file = baseName(job.output || '')
|
||||||
jobId: job.id,
|
if (!file) {
|
||||||
output: job.output,
|
throw new Error('Kein Dateiname vorhanden.')
|
||||||
splits,
|
}
|
||||||
segments,
|
|
||||||
})
|
const payload =
|
||||||
|
Array.isArray(segments) && segments.length > 0
|
||||||
|
? {
|
||||||
|
file,
|
||||||
|
segments: segments.map((seg) => ({
|
||||||
|
start: Number(seg.start),
|
||||||
|
end: Number(seg.end),
|
||||||
|
})),
|
||||||
|
deleteOriginal,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
file,
|
||||||
|
splits,
|
||||||
|
deleteOriginal,
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp01 = (n: number) => Math.max(0, Math.min(1, n))
|
||||||
|
|
||||||
|
const totalFallback =
|
||||||
|
Array.isArray(segments) && segments.length > 0
|
||||||
|
? segments.length
|
||||||
|
: Math.max(0, splits.length + 1)
|
||||||
|
|
||||||
|
const selectedSegmentForEvent = (ev: any) => {
|
||||||
|
const pos = Number(ev?.current ?? 0)
|
||||||
|
if (!Number.isFinite(pos) || pos < 1 || pos > segments.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return segments[pos - 1] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const publish = (
|
||||||
|
progress: Omit<BackgroundSplitProgress, 'sourceFile' | 'startedAtMs'>
|
||||||
|
) => {
|
||||||
|
pushSplitProgress(file, progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
let buffer = ''
|
||||||
|
let finalData: any = null
|
||||||
|
let total = totalFallback
|
||||||
|
|
||||||
|
try {
|
||||||
|
publish({
|
||||||
|
phase: 'preparing',
|
||||||
|
total: totalFallback,
|
||||||
|
current: 0,
|
||||||
|
completed: 0,
|
||||||
|
segmentProgress: 0,
|
||||||
|
overallProgress: 0,
|
||||||
|
message: 'Split wird vorbereitet…',
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await fetch('/api/record/split?stream=1', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '')
|
||||||
|
throw new Error(text || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.body) {
|
||||||
|
throw new Error('Split-Stream konnte nicht gelesen werden.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
|
||||||
|
const applyEvent = (ev: any) => {
|
||||||
|
const type = String(ev?.type ?? '').trim()
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'start': {
|
||||||
|
total = Math.max(1, Number(ev?.total ?? totalFallback))
|
||||||
|
publish({
|
||||||
|
phase: 'preparing',
|
||||||
|
total,
|
||||||
|
current: 0,
|
||||||
|
completed: 0,
|
||||||
|
segmentProgress: 0,
|
||||||
|
overallProgress: 0,
|
||||||
|
message: String(ev?.message || 'Split gestartet'),
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'segment_start': {
|
||||||
|
const current = Math.max(1, Number(ev?.current ?? 1))
|
||||||
|
const evTotal = Math.max(1, Number(ev?.total ?? total))
|
||||||
|
const currentSegment = selectedSegmentForEvent(ev)
|
||||||
|
|
||||||
|
publish({
|
||||||
|
phase: 'splitting',
|
||||||
|
total: evTotal,
|
||||||
|
current,
|
||||||
|
completed: current - 1,
|
||||||
|
segmentProgress: 0,
|
||||||
|
overallProgress: clamp01((current - 1) / evTotal),
|
||||||
|
message: String(ev?.message || `Segment ${current}/${evTotal} wird gesplittet`),
|
||||||
|
segment: currentSegment
|
||||||
|
? {
|
||||||
|
index: currentSegment.index,
|
||||||
|
label: currentSegment.label,
|
||||||
|
start: currentSegment.start,
|
||||||
|
end: currentSegment.end,
|
||||||
|
duration: currentSegment.duration,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
index: current,
|
||||||
|
label: `Segment ${current}`,
|
||||||
|
start: Number(ev?.start ?? 0),
|
||||||
|
end: Number(ev?.end ?? 0),
|
||||||
|
duration: Math.max(0, Number(ev?.end ?? 0) - Number(ev?.start ?? 0)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'segment_progress': {
|
||||||
|
const current = Math.max(1, Number(ev?.current ?? 1))
|
||||||
|
const evTotal = Math.max(1, Number(ev?.total ?? total))
|
||||||
|
const segmentProgress = clamp01(Number(ev?.progress ?? 0))
|
||||||
|
const overallProgress = clamp01(
|
||||||
|
Number.isFinite(Number(ev?.overall))
|
||||||
|
? Number(ev?.overall)
|
||||||
|
: (current - 1 + segmentProgress) / evTotal
|
||||||
|
)
|
||||||
|
const currentSegment = selectedSegmentForEvent(ev)
|
||||||
|
|
||||||
|
publish({
|
||||||
|
phase: 'splitting',
|
||||||
|
total: evTotal,
|
||||||
|
current,
|
||||||
|
completed: current - 1,
|
||||||
|
segmentProgress,
|
||||||
|
overallProgress,
|
||||||
|
message: String(ev?.message || `Segment ${current}/${evTotal} wird gesplittet`),
|
||||||
|
segment: currentSegment
|
||||||
|
? {
|
||||||
|
index: currentSegment.index,
|
||||||
|
label: currentSegment.label,
|
||||||
|
start: currentSegment.start,
|
||||||
|
end: currentSegment.end,
|
||||||
|
duration: currentSegment.duration,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
index: current,
|
||||||
|
label: `Segment ${current}`,
|
||||||
|
start: Number(ev?.start ?? 0),
|
||||||
|
end: Number(ev?.end ?? 0),
|
||||||
|
duration: Math.max(0, Number(ev?.end ?? 0) - Number(ev?.start ?? 0)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'segment_done': {
|
||||||
|
const current = Math.max(1, Number(ev?.current ?? 1))
|
||||||
|
const evTotal = Math.max(1, Number(ev?.total ?? total))
|
||||||
|
const currentSegment = selectedSegmentForEvent(ev)
|
||||||
|
|
||||||
|
publish({
|
||||||
|
phase: current >= evTotal ? 'done' : 'splitting',
|
||||||
|
total: evTotal,
|
||||||
|
current,
|
||||||
|
completed: current,
|
||||||
|
segmentProgress: 1,
|
||||||
|
overallProgress: clamp01(current / evTotal),
|
||||||
|
message: String(ev?.message || `Segment ${current}/${evTotal} fertig`),
|
||||||
|
segment: currentSegment
|
||||||
|
? {
|
||||||
|
index: currentSegment.index,
|
||||||
|
label: currentSegment.label,
|
||||||
|
start: currentSegment.start,
|
||||||
|
end: currentSegment.end,
|
||||||
|
duration: currentSegment.duration,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
index: current,
|
||||||
|
label: `Segment ${current}`,
|
||||||
|
start: Number(ev?.start ?? 0),
|
||||||
|
end: Number(ev?.end ?? 0),
|
||||||
|
duration: Math.max(0, Number(ev?.end ?? 0) - Number(ev?.start ?? 0)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'error': {
|
||||||
|
throw new Error(String(ev?.message || 'Split fehlgeschlagen'))
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'done': {
|
||||||
|
finalData = ev
|
||||||
|
publish({
|
||||||
|
phase: 'done',
|
||||||
|
total: Math.max(1, Number(ev?.total ?? total)),
|
||||||
|
current: Math.max(1, Number(ev?.total ?? total)),
|
||||||
|
completed: Math.max(1, Number(ev?.total ?? total)),
|
||||||
|
segmentProgress: 1,
|
||||||
|
overallProgress: 1,
|
||||||
|
message: String(ev?.message || 'Split abgeschlossen'),
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() ?? ''
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trim()
|
||||||
|
if (!line) continue
|
||||||
|
applyEvent(JSON.parse(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode()
|
||||||
|
if (buffer.trim()) {
|
||||||
|
applyEvent(JSON.parse(buffer.trim()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!finalData?.ok) {
|
||||||
|
throw new Error('Split wurde nicht erfolgreich abgeschlossen.')
|
||||||
|
}
|
||||||
|
|
||||||
|
bumpAssets()
|
||||||
|
void loadDonePage(donePageRef.current, doneSortRef.current)
|
||||||
|
void loadDoneCount()
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : 'Split fehlgeschlagen.'
|
||||||
|
|
||||||
|
publish({
|
||||||
|
phase: 'error',
|
||||||
|
total: Math.max(1, totalFallback),
|
||||||
|
current: 0,
|
||||||
|
completed: 0,
|
||||||
|
segmentProgress: 0,
|
||||||
|
overallProgress: 0,
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
|
||||||
|
notify.error('Split fehlgeschlagen', `${file} — ${message}`)
|
||||||
|
}
|
||||||
|
})()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -1,22 +1,22 @@
|
|||||||
// frontend\src\components\ui\Button.tsx
|
// frontend\src\components\ui\Button.tsx
|
||||||
|
|
||||||
import * as React from 'react'
|
import { type ButtonHTMLAttributes, type ReactNode } from 'react'
|
||||||
|
|
||||||
type Variant = 'primary' | 'secondary' | 'soft'
|
type Variant = 'primary' | 'secondary' | 'soft'
|
||||||
type Size = 'xs' | 'sm' | 'md' | 'lg'
|
type Size = 'xs' | 'sm' | 'md' | 'lg'
|
||||||
type Color = 'indigo' | 'blue' | 'emerald' | 'red' | 'amber'
|
type Color = 'indigo' | 'blue' | 'emerald' | 'red' | 'amber'
|
||||||
|
|
||||||
export type ButtonProps = Omit<
|
export type ButtonProps = Omit<
|
||||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
'children'
|
'children'
|
||||||
> & {
|
> & {
|
||||||
children: React.ReactNode
|
children: ReactNode
|
||||||
variant?: Variant
|
variant?: Variant
|
||||||
size?: Size
|
size?: Size
|
||||||
color?: Color
|
color?: Color
|
||||||
rounded?: 'sm' | 'md' | 'full'
|
rounded?: 'sm' | 'md' | 'full'
|
||||||
leadingIcon?: React.ReactNode
|
leadingIcon?: ReactNode
|
||||||
trailingIcon?: React.ReactNode
|
trailingIcon?: ReactNode
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
// components/ui/ButtonGroup.tsx
|
// components/ui/ButtonGroup.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import { type ReactNode } from 'react'
|
||||||
|
|
||||||
type Size = 'sm' | 'md' | 'lg'
|
type Size = 'sm' | 'md' | 'lg'
|
||||||
|
|
||||||
export type ButtonGroupItem = {
|
export type ButtonGroupItem = {
|
||||||
id: string
|
id: string
|
||||||
label?: React.ReactNode
|
label?: ReactNode
|
||||||
icon?: React.ReactNode
|
icon?: ReactNode
|
||||||
srLabel?: string
|
srLabel?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import Card from './Card'
|
import Card from './Card'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
@ -93,25 +93,25 @@ type CoverInfoListItem = {
|
|||||||
|
|
||||||
export default function CategoriesTab() {
|
export default function CategoriesTab() {
|
||||||
|
|
||||||
const [rows, setRows] = React.useState<TagRow[]>([])
|
const [rows, setRows] = useState<TagRow[]>([])
|
||||||
const [loading, setLoading] = React.useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [err, setErr] = React.useState<string | null>(null)
|
const [err, setErr] = useState<string | null>(null)
|
||||||
const [coverBust, setCoverBust] = React.useState<number>(() => Date.now())
|
const [coverBust, setCoverBust] = useState<number>(() => Date.now())
|
||||||
const [coverState, setCoverState] = React.useState<Record<string, 'ok' | 'error'>>({})
|
const [coverState, setCoverState] = useState<Record<string, 'ok' | 'error'>>({})
|
||||||
const [hasCoverByTag, setHasCoverByTag] = React.useState<Record<string, boolean>>({})
|
const [hasCoverByTag, setHasCoverByTag] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
const [renewing, setRenewing] = React.useState(false)
|
const [renewing, setRenewing] = useState(false)
|
||||||
const [renewProgress, setRenewProgress] = React.useState<{ done: number; total: number } | null>(null)
|
const [renewProgress, setRenewProgress] = useState<{ done: number; total: number } | null>(null)
|
||||||
|
|
||||||
// TagLower -> Modelname fürs Cover (UI-Overlay)
|
// TagLower -> Modelname fürs Cover (UI-Overlay)
|
||||||
const [coverModelByTag, setCoverModelByTag] = React.useState<Record<string, string>>({})
|
const [coverModelByTag, setCoverModelByTag] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
const refreshAbortRef = React.useRef<AbortController | null>(null)
|
const refreshAbortRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
// TagLower -> outputs[]
|
// TagLower -> outputs[]
|
||||||
const candidatesRef = React.useRef<Record<string, string[]>>({})
|
const candidatesRef = useRef<Record<string, string[]>>({})
|
||||||
|
|
||||||
const buildCandidates = React.useCallback((models: StoredModel[], doneJobs: RecordJob[]) => {
|
const buildCandidates = useCallback((models: StoredModel[], doneJobs: RecordJob[]) => {
|
||||||
// 1) modelKeyLower -> tagsLower[]
|
// 1) modelKeyLower -> tagsLower[]
|
||||||
const tagsByModelKey: Record<string, string[]> = {}
|
const tagsByModelKey: Record<string, string[]> = {}
|
||||||
for (const m of Array.isArray(models) ? models : []) {
|
for (const m of Array.isArray(models) ? models : []) {
|
||||||
@ -148,7 +148,7 @@ export default function CategoriesTab() {
|
|||||||
candidatesRef.current = out
|
candidatesRef.current = out
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const goToFinishedDownloadsWithTag = React.useCallback((tag: string) => {
|
const goToFinishedDownloadsWithTag = useCallback((tag: string) => {
|
||||||
const clean = String(tag || '').trim()
|
const clean = String(tag || '').trim()
|
||||||
if (!clean) return
|
if (!clean) return
|
||||||
|
|
||||||
@ -172,7 +172,7 @@ export default function CategoriesTab() {
|
|||||||
)
|
)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const goToModelsWithTag = React.useCallback((tag: string) => {
|
const goToModelsWithTag = useCallback((tag: string) => {
|
||||||
const clean = String(tag || '').trim()
|
const clean = String(tag || '').trim()
|
||||||
if (!clean) return
|
if (!clean) return
|
||||||
|
|
||||||
@ -196,7 +196,7 @@ export default function CategoriesTab() {
|
|||||||
)
|
)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const refresh = React.useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
// laufenden refresh abbrechen
|
// laufenden refresh abbrechen
|
||||||
refreshAbortRef.current?.abort()
|
refreshAbortRef.current?.abort()
|
||||||
|
|
||||||
@ -326,14 +326,14 @@ export default function CategoriesTab() {
|
|||||||
}
|
}
|
||||||
}, [buildCandidates])
|
}, [buildCandidates])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh()
|
void refresh()
|
||||||
return () => {
|
return () => {
|
||||||
refreshAbortRef.current?.abort()
|
refreshAbortRef.current?.abort()
|
||||||
}
|
}
|
||||||
}, [refresh])
|
}, [refresh])
|
||||||
|
|
||||||
const renewCovers = React.useCallback(async () => {
|
const renewCovers = useCallback(async () => {
|
||||||
if (renewing) return
|
if (renewing) return
|
||||||
setRenewing(true)
|
setRenewing(true)
|
||||||
setErr(null)
|
setErr(null)
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
checked: boolean
|
checked: boolean
|
||||||
@ -25,9 +25,9 @@ export default function Checkbox({
|
|||||||
name,
|
name,
|
||||||
className = '',
|
className = '',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const ref = React.useRef<HTMLInputElement | null>(null)
|
const ref = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (ref.current) {
|
if (ref.current) {
|
||||||
ref.current.indeterminate = indeterminate && !checked
|
ref.current.indeterminate = indeterminate && !checked
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import { useEffect, useRef, useState, useLayoutEffect, type ReactNode } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
export type ContextMenuItem = {
|
export type ContextMenuItem = {
|
||||||
label: React.ReactNode
|
label: ReactNode
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
danger?: boolean
|
danger?: boolean
|
||||||
@ -23,11 +23,11 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ContextMenu({ open, x, y, items, onClose, className }: Props) {
|
export default function ContextMenu({ open, x, y, items, onClose, className }: Props) {
|
||||||
const menuRef = React.useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const [pos, setPos] = React.useState<{ left: number; top: number }>({ left: x, top: y })
|
const [pos, setPos] = useState<{ left: number; top: number }>({ left: x, top: y })
|
||||||
|
|
||||||
// Position clamped in viewport
|
// Position clamped in viewport
|
||||||
React.useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const el = menuRef.current
|
const el = menuRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
@ -43,7 +43,7 @@ export default function ContextMenu({ open, x, y, items, onClose, className }: P
|
|||||||
}, [open, x, y, items.length])
|
}, [open, x, y, items.length])
|
||||||
|
|
||||||
// close on ESC / scroll / resize
|
// close on ESC / scroll / resize
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
||||||
const onKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && onClose()
|
const onKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && onClose()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -2,12 +2,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type MutableRefObject,
|
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import Card from './Card'
|
import Card from './Card'
|
||||||
@ -41,11 +41,16 @@ import {
|
|||||||
import ModelGenderIcon from './ModelGenderIcon'
|
import ModelGenderIcon from './ModelGenderIcon'
|
||||||
import { parseFinishedTags, resolveFinishedModelFlags } from './finishedModelFlags'
|
import { parseFinishedTags, resolveFinishedModelFlags } from './finishedModelFlags'
|
||||||
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
||||||
import React from 'react'
|
|
||||||
import {
|
import {
|
||||||
type FinishedSelectionStore,
|
type FinishedSelectionStore,
|
||||||
useSelectionChecked,
|
useSelectionChecked,
|
||||||
} from './finishedSelectionStore'
|
} from './finishedSelectionStore'
|
||||||
|
import SpritePreloader from './SpritePreloader'
|
||||||
|
|
||||||
|
|
||||||
|
type SwipeRefs = {
|
||||||
|
current: Map<string, SwipeCardHandle>
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
rows: RecordJob[]
|
rows: RecordJob[]
|
||||||
@ -65,7 +70,7 @@ type Props = {
|
|||||||
keepingKeys: Set<string>
|
keepingKeys: Set<string>
|
||||||
removingKeys: Set<string>
|
removingKeys: Set<string>
|
||||||
|
|
||||||
swipeRefs: MutableRefObject<Map<string, SwipeCardHandle>>
|
swipeRefs: SwipeRefs
|
||||||
|
|
||||||
assetNonce?: number
|
assetNonce?: number
|
||||||
|
|
||||||
@ -92,6 +97,8 @@ type Props = {
|
|||||||
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
|
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
|
||||||
|
|
||||||
handleDuration: (job: RecordJob, seconds: number) => void
|
handleDuration: (job: RecordJob, seconds: number) => void
|
||||||
|
handleResolution: (job: RecordJob, w: number, h: number) => void
|
||||||
|
resolutionLabelOf: (job: RecordJob) => string
|
||||||
|
|
||||||
deleteVideo: (job: RecordJob) => Promise<boolean>
|
deleteVideo: (job: RecordJob) => Promise<boolean>
|
||||||
keepVideo: (job: RecordJob) => Promise<boolean>
|
keepVideo: (job: RecordJob) => Promise<boolean>
|
||||||
@ -119,6 +126,7 @@ type Props = {
|
|||||||
forcePreviewMuted?: boolean
|
forcePreviewMuted?: boolean
|
||||||
mobileTeaserAudioUnlocked?: boolean
|
mobileTeaserAudioUnlocked?: boolean
|
||||||
onUnlockMobileTeaserAudio?: () => void
|
onUnlockMobileTeaserAudio?: () => void
|
||||||
|
renderIntegrityBadge?: (job: RecordJob) => ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardBlurWrapper({
|
function CardBlurWrapper({
|
||||||
@ -326,6 +334,7 @@ type FinishedDownloadsDesktopCardProps = {
|
|||||||
|
|
||||||
jobForDetails: (job: RecordJob) => RecordJob
|
jobForDetails: (job: RecordJob) => RecordJob
|
||||||
handleDuration: (job: RecordJob, seconds: number) => void
|
handleDuration: (job: RecordJob, seconds: number) => void
|
||||||
|
handleResolution: (job: RecordJob, w: number, h: number) => void
|
||||||
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
|
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
|
||||||
|
|
||||||
onToggleSelected: (job: RecordJob) => void
|
onToggleSelected: (job: RecordJob) => void
|
||||||
@ -349,6 +358,7 @@ type FinishedDownloadsDesktopCardProps = {
|
|||||||
keepVideo: (job: RecordJob) => Promise<boolean>
|
keepVideo: (job: RecordJob) => Promise<boolean>
|
||||||
|
|
||||||
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode
|
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode
|
||||||
|
renderIntegrityBadge?: (job: RecordJob) => ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
function samePostworkSummaryLite(
|
function samePostworkSummaryLite(
|
||||||
@ -415,7 +425,7 @@ function SelectionCheckboxOverlay({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const FinishedDownloadsDesktopCard = React.memo(
|
const FinishedDownloadsDesktopCard = memo(
|
||||||
function FinishedDownloadsDesktopCard({
|
function FinishedDownloadsDesktopCard({
|
||||||
job: j,
|
job: j,
|
||||||
selectionStore,
|
selectionStore,
|
||||||
@ -438,6 +448,7 @@ const FinishedDownloadsDesktopCard = React.memo(
|
|||||||
lower,
|
lower,
|
||||||
jobForDetails,
|
jobForDetails,
|
||||||
handleDuration,
|
handleDuration,
|
||||||
|
handleResolution,
|
||||||
onToggleSelected,
|
onToggleSelected,
|
||||||
onHoverPreviewKeyChange,
|
onHoverPreviewKeyChange,
|
||||||
onOpenPlayer,
|
onOpenPlayer,
|
||||||
@ -456,6 +467,7 @@ const FinishedDownloadsDesktopCard = React.memo(
|
|||||||
deleteVideo,
|
deleteVideo,
|
||||||
keepVideo,
|
keepVideo,
|
||||||
renderPostworkBadge,
|
renderPostworkBadge,
|
||||||
|
renderIntegrityBadge,
|
||||||
}: FinishedDownloadsDesktopCardProps) {
|
}: FinishedDownloadsDesktopCardProps) {
|
||||||
const k = keyFor(j)
|
const k = keyFor(j)
|
||||||
const busy = isDeleting || isKeeping || isRemoving
|
const busy = isDeleting || isKeeping || isRemoving
|
||||||
@ -469,7 +481,7 @@ const FinishedDownloadsDesktopCard = React.memo(
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSelectOrOpen = React.useCallback(() => {
|
const handleSelectOrOpen = useCallback(() => {
|
||||||
if (selectionStore.hasAnySelection()) {
|
if (selectionStore.hasAnySelection()) {
|
||||||
onToggleSelected(j)
|
onToggleSelected(j)
|
||||||
return true
|
return true
|
||||||
@ -585,6 +597,7 @@ const FinishedDownloadsDesktopCard = React.memo(
|
|||||||
variant="fill"
|
variant="fill"
|
||||||
durationSeconds={durationSeconds ?? (j as any)?.meta?.file?.durationSeconds}
|
durationSeconds={durationSeconds ?? (j as any)?.meta?.file?.durationSeconds}
|
||||||
onDuration={handleDuration}
|
onDuration={handleDuration}
|
||||||
|
onResolution={handleResolution}
|
||||||
showPopover={false}
|
showPopover={false}
|
||||||
blur={inlineActive ? false : Boolean(blurPreviews)}
|
blur={inlineActive ? false : Boolean(blurPreviews)}
|
||||||
animated={shouldAnimateTeaser({
|
animated={shouldAnimateTeaser({
|
||||||
@ -627,9 +640,10 @@ const FinishedDownloadsDesktopCard = React.memo(
|
|||||||
<CountryFlag country={modelCountry} size="sm" className="shrink-0" />
|
<CountryFlag country={modelCountry} size="sm" className="shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{renderPostworkBadge && postworkBadge ? (
|
{(renderIntegrityBadge || (renderPostworkBadge && postworkBadge)) ? (
|
||||||
<div className="relative z-20 shrink-0">
|
<div className="relative z-20 shrink-0 flex items-center gap-2">
|
||||||
{renderPostworkBadge(postworkBadge)}
|
{renderIntegrityBadge ? renderIntegrityBadge(j) : null}
|
||||||
|
{renderPostworkBadge && postworkBadge ? renderPostworkBadge(postworkBadge) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@ -709,6 +723,7 @@ const FinishedDownloadsDesktopCard = React.memo(
|
|||||||
prev.isRemoving === next.isRemoving &&
|
prev.isRemoving === next.isRemoving &&
|
||||||
samePostworkSummaryLite(prev.postworkBadge, next.postworkBadge) &&
|
samePostworkSummaryLite(prev.postworkBadge, next.postworkBadge) &&
|
||||||
prev.durationSeconds === next.durationSeconds &&
|
prev.durationSeconds === next.durationSeconds &&
|
||||||
|
prev.assetNonce === next.assetNonce &&
|
||||||
prev.forcePreviewMuted === next.forcePreviewMuted &&
|
prev.forcePreviewMuted === next.forcePreviewMuted &&
|
||||||
prev.blurPreviews === next.blurPreviews &&
|
prev.blurPreviews === next.blurPreviews &&
|
||||||
prev.teaserState.mode === next.teaserState.mode &&
|
prev.teaserState.mode === next.teaserState.mode &&
|
||||||
@ -759,6 +774,7 @@ function FinishedDownloadsCardsView({
|
|||||||
tryAutoplayInline,
|
tryAutoplayInline,
|
||||||
registerTeaserHost,
|
registerTeaserHost,
|
||||||
handleDuration,
|
handleDuration,
|
||||||
|
handleResolution,
|
||||||
|
|
||||||
deleteVideo,
|
deleteVideo,
|
||||||
keepVideo,
|
keepVideo,
|
||||||
@ -779,6 +795,7 @@ function FinishedDownloadsCardsView({
|
|||||||
forcePreviewMuted,
|
forcePreviewMuted,
|
||||||
mobileTeaserAudioUnlocked,
|
mobileTeaserAudioUnlocked,
|
||||||
onUnlockMobileTeaserAudio,
|
onUnlockMobileTeaserAudio,
|
||||||
|
renderIntegrityBadge,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
|
||||||
const footerTapRef = useRef<Record<string, boolean>>({})
|
const footerTapRef = useRef<Record<string, boolean>>({})
|
||||||
@ -943,6 +960,17 @@ function FinishedDownloadsCardsView({
|
|||||||
const scrubberCount = hasScrubberUi ? sprite.count : 0
|
const scrubberCount = hasScrubberUi ? sprite.count : 0
|
||||||
const scrubberStepSeconds = hasScrubberUi ? sprite.stepSeconds : 0
|
const scrubberStepSeconds = hasScrubberUi ? sprite.stepSeconds : 0
|
||||||
|
|
||||||
|
const teaserPreloadEnabled =
|
||||||
|
opts?.mobileStackTopOnlyVideo
|
||||||
|
? false
|
||||||
|
: (opts?.preloadTeaserWhenStill ? true : !isSmall)
|
||||||
|
|
||||||
|
const shouldPreloadSprite =
|
||||||
|
Boolean(teaserPreloadEnabled && hasSpriteScrubber && spriteUrl)
|
||||||
|
|
||||||
|
const spriteScrubbingActive =
|
||||||
|
hasSpriteScrubber && scrubHovering && typeof scrubActiveIndex === 'number'
|
||||||
|
|
||||||
const scrubProgressRatio = scrubProgressRatioFromIndex(scrubActiveIndex, scrubberCount)
|
const scrubProgressRatio = scrubProgressRatioFromIndex(scrubActiveIndex, scrubberCount)
|
||||||
|
|
||||||
const spriteFrameStyle = buildSpriteFrameStyle({
|
const spriteFrameStyle = buildSpriteFrameStyle({
|
||||||
@ -1116,9 +1144,10 @@ function FinishedDownloadsCardsView({
|
|||||||
variant="fill"
|
variant="fill"
|
||||||
durationSeconds={durations[k] ?? (j as any)?.meta?.file?.durationSeconds}
|
durationSeconds={durations[k] ?? (j as any)?.meta?.file?.durationSeconds}
|
||||||
onDuration={handleDuration}
|
onDuration={handleDuration}
|
||||||
|
onResolution={handleResolution}
|
||||||
showPopover={false}
|
showPopover={false}
|
||||||
blur={inlineActive ? false : Boolean(blurPreviews)}
|
blur={inlineActive ? false : Boolean(blurPreviews)}
|
||||||
animated={allowTeaserAnimation}
|
animated={allowTeaserAnimation && !spriteScrubbingActive}
|
||||||
animatedMode="teaser"
|
animatedMode="teaser"
|
||||||
animatedTrigger="always"
|
animatedTrigger="always"
|
||||||
inlineVideo={!opts?.disableInline && inlineActive ? 'always' : false}
|
inlineVideo={!opts?.disableInline && inlineActive ? 'always' : false}
|
||||||
@ -1129,18 +1158,16 @@ function FinishedDownloadsCardsView({
|
|||||||
popoverMuted={previewMuted}
|
popoverMuted={previewMuted}
|
||||||
assetNonce={assetNonce ?? 0}
|
assetNonce={assetNonce ?? 0}
|
||||||
alwaysLoadStill={forceLoadStill}
|
alwaysLoadStill={forceLoadStill}
|
||||||
teaserPreloadEnabled={
|
teaserPreloadEnabled={teaserPreloadEnabled}
|
||||||
opts?.mobileStackTopOnlyVideo
|
|
||||||
? false
|
|
||||||
: (opts?.preloadTeaserWhenStill ? true : !isSmall)
|
|
||||||
}
|
|
||||||
teaserPreloadRootMargin={
|
teaserPreloadRootMargin={
|
||||||
opts?.preloadTeaserWhenStill
|
opts?.preloadTeaserWhenStill
|
||||||
? '1200px 0px'
|
? '1200px 0px'
|
||||||
: (isSmall ? '900px 0px' : '700px 0px')
|
: (isSmall ? '900px 0px' : '700px 0px')
|
||||||
}
|
}
|
||||||
scrubProgressRatio={scrubProgressRatio}
|
scrubProgressRatio={hasSpriteScrubber ? undefined : scrubProgressRatio}
|
||||||
preferScrubProgress={scrubHovering && typeof scrubActiveIndex === 'number'}
|
preferScrubProgress={
|
||||||
|
!hasSpriteScrubber && scrubHovering && typeof scrubActiveIndex === 'number'
|
||||||
|
}
|
||||||
forceActive={Boolean(isSmall && opts?.mobileStackTopOnlyVideo && !opts?.forceStill)}
|
forceActive={Boolean(isSmall && opts?.mobileStackTopOnlyVideo && !opts?.forceStill)}
|
||||||
activationNonce={
|
activationNonce={
|
||||||
previewRenderRole === 'top'
|
previewRenderRole === 'top'
|
||||||
@ -1180,18 +1207,11 @@ function FinishedDownloadsCardsView({
|
|||||||
<span>Ton aktivieren</span>
|
<span>Ton aktivieren</span>
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* ✅ Sprite einmal vorladen, damit der erste Scrub-Move sofort sichtbar ist */}
|
<SpritePreloader
|
||||||
{hasSpriteScrubber && spriteUrl ? (
|
src={spriteUrl}
|
||||||
<img
|
enabled={shouldPreloadSprite}
|
||||||
src={spriteUrl}
|
/>
|
||||||
alt=""
|
|
||||||
className="hidden"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* ✅ Scrub-Frame Overlay via Sprite (kein Request pro Move) */}
|
{/* ✅ Scrub-Frame Overlay via Sprite (kein Request pro Move) */}
|
||||||
{hasSpriteScrubber && spriteFrameStyle && !inlineActive ? (
|
{hasSpriteScrubber && spriteFrameStyle && !inlineActive ? (
|
||||||
@ -1263,6 +1283,8 @@ function FinishedDownloadsCardsView({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
|
if (isSmall) return
|
||||||
|
|
||||||
if (selectionStore.hasAnySelection()) {
|
if (selectionStore.hasAnySelection()) {
|
||||||
onToggleSelected(j)
|
onToggleSelected(j)
|
||||||
return
|
return
|
||||||
@ -1291,9 +1313,10 @@ function FinishedDownloadsCardsView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{renderPostworkBadge && livePostwork ? (
|
{(renderIntegrityBadge || (renderPostworkBadge && livePostwork)) ? (
|
||||||
<div className="relative z-20 shrink-0">
|
<div className="relative z-20 shrink-0 flex items-center gap-2">
|
||||||
{renderPostworkBadge(livePostwork)}
|
{renderIntegrityBadge ? renderIntegrityBadge(j) : null}
|
||||||
|
{renderPostworkBadge && livePostwork ? renderPostworkBadge(livePostwork) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@ -1549,6 +1572,7 @@ function FinishedDownloadsCardsView({
|
|||||||
lower={lower}
|
lower={lower}
|
||||||
jobForDetails={jobForDetails}
|
jobForDetails={jobForDetails}
|
||||||
handleDuration={handleDuration}
|
handleDuration={handleDuration}
|
||||||
|
handleResolution={handleResolution}
|
||||||
handleScrubberClickIndex={handleScrubberClickIndex}
|
handleScrubberClickIndex={handleScrubberClickIndex}
|
||||||
onToggleSelected={onToggleSelected}
|
onToggleSelected={onToggleSelected}
|
||||||
onSelectOnly={onSelectOnly}
|
onSelectOnly={onSelectOnly}
|
||||||
@ -1569,6 +1593,7 @@ function FinishedDownloadsCardsView({
|
|||||||
deleteVideo={deleteVideo}
|
deleteVideo={deleteVideo}
|
||||||
keepVideo={keepVideo}
|
keepVideo={keepVideo}
|
||||||
renderPostworkBadge={renderPostworkBadge}
|
renderPostworkBadge={renderPostworkBadge}
|
||||||
|
renderIntegrityBadge={renderIntegrityBadge}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@ -1783,5 +1808,5 @@ function FinishedDownloadsCardsView({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const MemoFinishedDownloadsCardsView = React.memo(FinishedDownloadsCardsView)
|
const MemoFinishedDownloadsCardsView = memo(FinishedDownloadsCardsView)
|
||||||
export default MemoFinishedDownloadsCardsView
|
export default MemoFinishedDownloadsCardsView
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import { useState, useMemo, useCallback, memo, type ReactNode, type SyntheticEvent } from 'react'
|
||||||
import type {
|
import type {
|
||||||
RecordJob,
|
RecordJob,
|
||||||
GalleryModelFlags,
|
GalleryModelFlags,
|
||||||
@ -12,7 +12,6 @@ import FinishedVideoPreview from './FinishedVideoPreview'
|
|||||||
import RecordJobActions from './RecordJobActions'
|
import RecordJobActions from './RecordJobActions'
|
||||||
import TagOverflowRow from './TagOverflowRow'
|
import TagOverflowRow from './TagOverflowRow'
|
||||||
import { isHotName, stripHotPrefix } from './hotName'
|
import { isHotName, stripHotPrefix } from './hotName'
|
||||||
import { formatResolution } from './formatters'
|
|
||||||
import PreviewScrubber from './PreviewScrubber'
|
import PreviewScrubber from './PreviewScrubber'
|
||||||
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
|
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
|
||||||
import {
|
import {
|
||||||
@ -29,6 +28,7 @@ import {
|
|||||||
import ModelGenderIcon from './ModelGenderIcon'
|
import ModelGenderIcon from './ModelGenderIcon'
|
||||||
import { resolveFinishedModelFlags } from './finishedModelFlags'
|
import { resolveFinishedModelFlags } from './finishedModelFlags'
|
||||||
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
||||||
|
import SpritePreloader from './SpritePreloader'
|
||||||
|
|
||||||
function normalizeDurationSeconds(value: unknown): number | undefined {
|
function normalizeDurationSeconds(value: unknown): number | undefined {
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
||||||
@ -49,7 +49,7 @@ type Props = {
|
|||||||
output: string | undefined,
|
output: string | undefined,
|
||||||
summaries: Record<string, FinishedPostworkSummary>
|
summaries: Record<string, FinishedPostworkSummary>
|
||||||
) => FinishedPostworkSummary | undefined
|
) => FinishedPostworkSummary | undefined
|
||||||
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode
|
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode
|
||||||
|
|
||||||
durationSeconds?: number
|
durationSeconds?: number
|
||||||
teaserState: FinishedDownloadsTeaserState
|
teaserState: FinishedDownloadsTeaserState
|
||||||
@ -76,6 +76,8 @@ type Props = {
|
|||||||
onToggleTagFilter: (tag: string) => void
|
onToggleTagFilter: (tag: string) => void
|
||||||
onOpenPlayer: (job: RecordJob) => void
|
onOpenPlayer: (job: RecordJob) => void
|
||||||
handleDuration: (job: RecordJob, seconds: number) => void
|
handleDuration: (job: RecordJob, seconds: number) => void
|
||||||
|
handleResolution: (job: RecordJob, w: number, h: number) => void
|
||||||
|
resolutionLabelOf: (job: RecordJob) => string
|
||||||
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
|
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
|
||||||
deleteVideo: (job: RecordJob) => Promise<boolean>
|
deleteVideo: (job: RecordJob) => Promise<boolean>
|
||||||
keepVideo: (job: RecordJob) => Promise<boolean>
|
keepVideo: (job: RecordJob) => Promise<boolean>
|
||||||
@ -86,11 +88,13 @@ type Props = {
|
|||||||
onSplit?: (job: RecordJob) => void | Promise<void>
|
onSplit?: (job: RecordJob) => void | Promise<void>
|
||||||
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
||||||
jobForDetails: (job: RecordJob) => RecordJob
|
jobForDetails: (job: RecordJob) => RecordJob
|
||||||
|
renderIntegrityBadge?: (job: RecordJob) => ReactNode
|
||||||
|
|
||||||
contentPaddingClass: string
|
contentPaddingClass: string
|
||||||
footerMinHeightClass: string
|
footerMinHeightClass: string
|
||||||
titleClass: string
|
titleClass: string
|
||||||
sublineClass: string
|
sublineClass: string
|
||||||
|
assetNonce?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function FinishedDownloadsGalleryCardInner({
|
function FinishedDownloadsGalleryCardInner({
|
||||||
@ -119,11 +123,12 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
removingKeys,
|
removingKeys,
|
||||||
registerTeaserHost,
|
registerTeaserHost,
|
||||||
onToggleSelected,
|
onToggleSelected,
|
||||||
onSelectOnly,
|
|
||||||
onHoverPreviewKeyChange,
|
onHoverPreviewKeyChange,
|
||||||
onToggleTagFilter,
|
onToggleTagFilter,
|
||||||
onOpenPlayer,
|
onOpenPlayer,
|
||||||
handleDuration,
|
handleDuration,
|
||||||
|
handleResolution,
|
||||||
|
resolutionLabelOf,
|
||||||
handleScrubberClickIndex,
|
handleScrubberClickIndex,
|
||||||
deleteVideo,
|
deleteVideo,
|
||||||
keepVideo,
|
keepVideo,
|
||||||
@ -134,19 +139,21 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
onSplit,
|
onSplit,
|
||||||
onAddToDownloads,
|
onAddToDownloads,
|
||||||
jobForDetails,
|
jobForDetails,
|
||||||
|
renderIntegrityBadge,
|
||||||
contentPaddingClass,
|
contentPaddingClass,
|
||||||
footerMinHeightClass,
|
footerMinHeightClass,
|
||||||
titleClass,
|
titleClass,
|
||||||
sublineClass,
|
sublineClass,
|
||||||
|
assetNonce
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const k = rowKey
|
const k = rowKey
|
||||||
const checked = selectedKeys.has(rowKey)
|
const checked = selectedKeys.has(rowKey)
|
||||||
|
|
||||||
const [scrubIndexByKey, setScrubIndexByKey] = React.useState<Record<string, number | undefined>>({})
|
const [scrubIndexByKey, setScrubIndexByKey] = useState<Record<string, number | undefined>>({})
|
||||||
const [hoveredThumbKey, setHoveredThumbKey] = React.useState<string | null>(null)
|
const [hoveredThumbKey, setHoveredThumbKey] = useState<string | null>(null)
|
||||||
|
|
||||||
const handlePrimaryClick = React.useCallback(
|
const handlePrimaryClick = useCallback(
|
||||||
(e?: React.SyntheticEvent) => {
|
(e?: SyntheticEvent) => {
|
||||||
e?.preventDefault()
|
e?.preventDefault()
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
|
|
||||||
@ -157,10 +164,10 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
|
|
||||||
onOpenPlayer(j)
|
onOpenPlayer(j)
|
||||||
},
|
},
|
||||||
[selectionModeActive, onSelectOnly, onOpenPlayer, j]
|
[selectionModeActive, onToggleSelected, onOpenPlayer, j]
|
||||||
)
|
)
|
||||||
|
|
||||||
const setScrubIndexForKey = React.useCallback((key: string, index: number | undefined) => {
|
const setScrubIndexForKey = useCallback((key: string, index: number | undefined) => {
|
||||||
setScrubIndexByKey((prev) => {
|
setScrubIndexByKey((prev) => {
|
||||||
if (index === undefined) {
|
if (index === undefined) {
|
||||||
if (!(key in prev)) return prev
|
if (!(key in prev)) return prev
|
||||||
@ -173,7 +180,7 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const clearScrubIndex = React.useCallback((key: string) => {
|
const clearScrubIndex = useCallback((key: string) => {
|
||||||
setScrubIndexForKey(key, undefined)
|
setScrubIndexForKey(key, undefined)
|
||||||
}, [setScrubIndexForKey])
|
}, [setScrubIndexForKey])
|
||||||
|
|
||||||
@ -192,7 +199,7 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
lower
|
lower
|
||||||
)
|
)
|
||||||
|
|
||||||
const tags = React.useMemo(() => {
|
const tags = useMemo(() => {
|
||||||
const s = String(flags?.tags ?? '').trim()
|
const s = String(flags?.tags ?? '').trim()
|
||||||
if (!s) return [] as string[]
|
if (!s) return [] as string[]
|
||||||
const parts = s.split(/[\n,;|]+/g).map((p) => p.trim()).filter(Boolean)
|
const parts = s.split(/[\n,;|]+/g).map((p) => p.trim()).filter(Boolean)
|
||||||
@ -223,9 +230,9 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
const dur = runtimeOf(j)
|
const dur = runtimeOf(j)
|
||||||
const size = formatBytes(sizeBytesOf(j))
|
const size = formatBytes(sizeBytesOf(j))
|
||||||
|
|
||||||
const meta = React.useMemo(() => parseJobMeta((j as any)?.meta), [(j as any)?.meta])
|
const meta = useMemo(() => parseJobMeta((j as any)?.meta), [(j as any)?.meta])
|
||||||
|
|
||||||
const resObj = React.useMemo(() => {
|
const resObj = useMemo(() => {
|
||||||
const w = meta?.media?.video?.width ?? meta?.file?.video?.width
|
const w = meta?.media?.video?.width ?? meta?.file?.video?.width
|
||||||
const h = meta?.media?.video?.height ?? meta?.file?.video?.height
|
const h = meta?.media?.video?.height ?? meta?.file?.video?.height
|
||||||
if (
|
if (
|
||||||
@ -237,20 +244,22 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
return null
|
return null
|
||||||
}, [meta])
|
}, [meta])
|
||||||
|
|
||||||
const resLabel = formatResolution(resObj)
|
const resLabelRaw = resolutionLabelOf(j)
|
||||||
|
const resLabel = resLabelRaw && resLabelRaw !== '—' ? resLabelRaw : ''
|
||||||
|
|
||||||
const isRemoving = removingKeys.has(k)
|
const isRemoving = removingKeys.has(k)
|
||||||
const isDeleting = deletingKeys.has(k)
|
const isDeleting = deletingKeys.has(k)
|
||||||
const isKeeping = keepingKeys.has(k)
|
const isKeeping = keepingKeys.has(k)
|
||||||
const busy = isDeleting || isKeeping || isRemoving
|
const busy = isDeleting || isKeeping || isRemoving
|
||||||
|
|
||||||
const sprite = React.useMemo(
|
const sprite = useMemo(
|
||||||
() =>
|
() =>
|
||||||
readPreviewSpriteInfo((j as any)?.meta, {
|
readPreviewSpriteInfo((j as any)?.meta, {
|
||||||
fallbackPath: flags?.previewScrubberPath,
|
fallbackPath: flags?.previewScrubberPath,
|
||||||
fallbackCount: flags?.previewScrubberCount,
|
fallbackCount: flags?.previewScrubberCount,
|
||||||
|
versionFallback: assetNonce ?? 0,
|
||||||
}),
|
}),
|
||||||
[(j as any)?.meta, flags?.previewScrubberPath, flags?.previewScrubberCount]
|
[(j as any)?.meta, flags?.previewScrubberPath, flags?.previewScrubberCount, assetNonce]
|
||||||
)
|
)
|
||||||
|
|
||||||
const spriteUrl = sprite.url
|
const spriteUrl = sprite.url
|
||||||
@ -259,11 +268,12 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
const scrubberCount = sprite.count
|
const scrubberCount = sprite.count
|
||||||
const scrubberStepSeconds = sprite.stepSeconds
|
const scrubberStepSeconds = sprite.stepSeconds
|
||||||
const hasScrubber = hasScrubberUi
|
const hasScrubber = hasScrubberUi
|
||||||
|
const shouldPreloadSprite = Boolean(hasSpriteScrubber && spriteUrl)
|
||||||
|
|
||||||
const activeScrubIndex = scrubIndexByKey[k]
|
const activeScrubIndex = scrubIndexByKey[k]
|
||||||
const scrubProgressRatio = scrubProgressRatioFromIndex(activeScrubIndex, scrubberCount)
|
const scrubProgressRatio = scrubProgressRatioFromIndex(activeScrubIndex, scrubberCount)
|
||||||
|
|
||||||
const spriteFrameStyle = React.useMemo(
|
const spriteFrameStyle = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildSpriteFrameStyle({
|
buildSpriteFrameStyle({
|
||||||
spriteUrl,
|
spriteUrl,
|
||||||
@ -276,13 +286,15 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const showScrubberSpriteInThumb = Boolean(spriteFrameStyle)
|
const showScrubberSpriteInThumb = Boolean(spriteFrameStyle)
|
||||||
const hideTeaserUnderOverlay = showScrubberSpriteInThumb
|
|
||||||
|
|
||||||
const previewDurationSeconds =
|
const previewDurationSeconds =
|
||||||
durationSeconds ??
|
durationSeconds ??
|
||||||
normalizeDurationSeconds(meta?.media?.durationSeconds) ??
|
normalizeDurationSeconds(meta?.media?.durationSeconds) ??
|
||||||
normalizeDurationSeconds(meta?.file?.durationSeconds)
|
normalizeDurationSeconds(meta?.file?.durationSeconds)
|
||||||
|
|
||||||
|
const spriteScrubbingActive =
|
||||||
|
hasSpriteScrubber && typeof activeScrubIndex === 'number'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
@ -348,35 +360,31 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
getFileName={(p) => stripHotPrefix(baseName(p))}
|
getFileName={(p) => stripHotPrefix(baseName(p))}
|
||||||
durationSeconds={previewDurationSeconds}
|
durationSeconds={previewDurationSeconds}
|
||||||
onDuration={handleDuration}
|
onDuration={handleDuration}
|
||||||
|
onResolution={handleResolution}
|
||||||
variant="fill"
|
variant="fill"
|
||||||
showPopover={false}
|
showPopover={false}
|
||||||
blur={blurPreviews}
|
blur={blurPreviews}
|
||||||
animated={shouldAnimateTeaser({
|
animated={shouldAnimateTeaser({
|
||||||
state: teaserState,
|
state: teaserState,
|
||||||
itemKey: k,
|
itemKey: k,
|
||||||
disabled: hideTeaserUnderOverlay,
|
disabled: spriteScrubbingActive,
|
||||||
})}
|
})}
|
||||||
animatedMode="teaser"
|
animatedMode="teaser"
|
||||||
animatedTrigger="always"
|
animatedTrigger="always"
|
||||||
muted={previewMuted}
|
muted={previewMuted}
|
||||||
popoverMuted={previewMuted}
|
popoverMuted={previewMuted}
|
||||||
scrubProgressRatio={scrubProgressRatio}
|
assetNonce={assetNonce ?? 0}
|
||||||
preferScrubProgress={typeof activeScrubIndex === 'number'}
|
scrubProgressRatio={hasSpriteScrubber ? undefined : scrubProgressRatio}
|
||||||
|
preferScrubProgress={!hasSpriteScrubber && typeof activeScrubIndex === 'number'}
|
||||||
forceActive={isPreviewActive}
|
forceActive={isPreviewActive}
|
||||||
teaserPreloadEnabled
|
teaserPreloadEnabled
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasSpriteScrubber && spriteUrl ? (
|
<SpritePreloader
|
||||||
<img
|
src={spriteUrl}
|
||||||
src={spriteUrl}
|
enabled={shouldPreloadSprite}
|
||||||
alt=""
|
/>
|
||||||
className="hidden"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{showScrubberSpriteInThumb && spriteFrameStyle ? (
|
{showScrubberSpriteInThumb && spriteFrameStyle ? (
|
||||||
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
|
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
|
||||||
@ -448,9 +456,10 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showPostworkBadge && renderPostworkBadge ? (
|
{(renderIntegrityBadge || (showPostworkBadge && renderPostworkBadge)) ? (
|
||||||
<div className="relative z-20 shrink-0">
|
<div className="relative z-20 shrink-0 flex items-center gap-2">
|
||||||
{renderPostworkBadge(postworkBadge)}
|
{renderIntegrityBadge ? renderIntegrityBadge(j) : null}
|
||||||
|
{showPostworkBadge && renderPostworkBadge ? renderPostworkBadge(postworkBadge) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@ -519,5 +528,5 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const FinishedDownloadsGalleryCard = React.memo(FinishedDownloadsGalleryCardInner)
|
const FinishedDownloadsGalleryCard = memo(FinishedDownloadsGalleryCardInner)
|
||||||
export default FinishedDownloadsGalleryCard
|
export default FinishedDownloadsGalleryCard
|
||||||
@ -1,7 +1,7 @@
|
|||||||
// frontend\src\components\ui\FinishedDownloadsGalleryView.tsx
|
// frontend\src\components\ui\FinishedDownloadsGalleryView.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import { useEffect, useRef, useMemo, useCallback, memo, type ReactNode } from 'react'
|
||||||
import type {
|
import type {
|
||||||
RecordJob,
|
RecordJob,
|
||||||
GalleryModelFlags,
|
GalleryModelFlags,
|
||||||
@ -28,10 +28,12 @@ type Props = {
|
|||||||
summaries: Record<string, FinishedPostworkSummary>
|
summaries: Record<string, FinishedPostworkSummary>
|
||||||
) => FinishedPostworkSummary | undefined
|
) => FinishedPostworkSummary | undefined
|
||||||
teaserState: FinishedDownloadsTeaserState
|
teaserState: FinishedDownloadsTeaserState
|
||||||
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode
|
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode
|
||||||
cardScale?: number
|
cardScale?: number
|
||||||
|
|
||||||
handleDuration: (job: RecordJob, seconds: number) => void
|
handleDuration: (job: RecordJob, seconds: number) => void
|
||||||
|
handleResolution: (job: RecordJob, w: number, h: number) => void
|
||||||
|
resolutionLabelOf: (job: RecordJob) => string
|
||||||
|
|
||||||
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
|
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
|
||||||
jobForDetails: (job: RecordJob) => RecordJob
|
jobForDetails: (job: RecordJob) => RecordJob
|
||||||
@ -68,6 +70,8 @@ type Props = {
|
|||||||
onRecommendedPageSizeChange?: (size: number) => void
|
onRecommendedPageSizeChange?: (size: number) => void
|
||||||
|
|
||||||
forcePreviewMuted?: boolean
|
forcePreviewMuted?: boolean
|
||||||
|
assetNonce?: number
|
||||||
|
renderIntegrityBadge?: (job: RecordJob) => ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapTo(value: number, step: number, base = 0) {
|
function snapTo(value: number, step: number, base = 0) {
|
||||||
@ -96,6 +100,8 @@ function FinishedDownloadsGalleryView({
|
|||||||
onToggleSelected,
|
onToggleSelected,
|
||||||
onSelectOnly,
|
onSelectOnly,
|
||||||
handleDuration,
|
handleDuration,
|
||||||
|
handleResolution,
|
||||||
|
resolutionLabelOf,
|
||||||
handleScrubberClickIndex,
|
handleScrubberClickIndex,
|
||||||
jobForDetails,
|
jobForDetails,
|
||||||
keyFor,
|
keyFor,
|
||||||
@ -124,10 +130,12 @@ function FinishedDownloadsGalleryView({
|
|||||||
onAddToDownloads,
|
onAddToDownloads,
|
||||||
onRecommendedPageSizeChange,
|
onRecommendedPageSizeChange,
|
||||||
forcePreviewMuted,
|
forcePreviewMuted,
|
||||||
|
assetNonce,
|
||||||
|
renderIntegrityBadge,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const observeTeasers = shouldObserveTeasers(teaserState.mode)
|
const observeTeasers = shouldObserveTeasers(teaserState.mode)
|
||||||
|
|
||||||
const registerTeaserHostIfNeeded = React.useCallback(
|
const registerTeaserHostIfNeeded = useCallback(
|
||||||
(key: string) => (el: HTMLDivElement | null) => {
|
(key: string) => (el: HTMLDivElement | null) => {
|
||||||
if (!observeTeasers) {
|
if (!observeTeasers) {
|
||||||
registerTeaserHost(key)(null)
|
registerTeaserHost(key)(null)
|
||||||
@ -145,26 +153,26 @@ function FinishedDownloadsGalleryView({
|
|||||||
|
|
||||||
const effectiveScale = clamp(effectiveScaleRaw, 0.8, 1.35)
|
const effectiveScale = clamp(effectiveScaleRaw, 0.8, 1.35)
|
||||||
|
|
||||||
const layoutScale = React.useMemo(
|
const layoutScale = useMemo(
|
||||||
() => snapTo(effectiveScale, 0.05, 0.8),
|
() => snapTo(effectiveScale, 0.05, 0.8),
|
||||||
[effectiveScale]
|
[effectiveScale]
|
||||||
)
|
)
|
||||||
|
|
||||||
const sizePreset = React.useMemo<'sm' | 'md' | 'lg' | 'xl'>(() => {
|
const sizePreset = useMemo<'sm' | 'md' | 'lg' | 'xl'>(() => {
|
||||||
if (layoutScale >= 1.14) return 'xl'
|
if (layoutScale >= 1.14) return 'xl'
|
||||||
if (layoutScale >= 0.99) return 'lg'
|
if (layoutScale >= 0.99) return 'lg'
|
||||||
if (layoutScale >= 0.88) return 'md'
|
if (layoutScale >= 0.88) return 'md'
|
||||||
return 'sm'
|
return 'sm'
|
||||||
}, [layoutScale])
|
}, [layoutScale])
|
||||||
|
|
||||||
const minCardWidth = React.useMemo(() => {
|
const minCardWidth = useMemo(() => {
|
||||||
return Math.round(270 * layoutScale)
|
return Math.round(270 * layoutScale)
|
||||||
}, [layoutScale])
|
}, [layoutScale])
|
||||||
|
|
||||||
const rootRef = React.useRef<HTMLDivElement | null>(null)
|
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||||
const lastRecommendedPageSizeRef = React.useRef<number | null>(null)
|
const lastRecommendedPageSizeRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const recommendPageSize = React.useCallback(() => {
|
const recommendPageSize = useCallback(() => {
|
||||||
if (!onRecommendedPageSizeChange) return
|
if (!onRecommendedPageSizeChange) return
|
||||||
|
|
||||||
const el = rootRef.current
|
const el = rootRef.current
|
||||||
@ -190,7 +198,7 @@ function FinishedDownloadsGalleryView({
|
|||||||
onRecommendedPageSizeChange(nextPageSize)
|
onRecommendedPageSizeChange(nextPageSize)
|
||||||
}, [minCardWidth, onRecommendedPageSizeChange])
|
}, [minCardWidth, onRecommendedPageSizeChange])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
recommendPageSize()
|
recommendPageSize()
|
||||||
|
|
||||||
const el = rootRef.current
|
const el = rootRef.current
|
||||||
@ -291,6 +299,8 @@ function FinishedDownloadsGalleryView({
|
|||||||
onToggleTagFilter={onToggleTagFilter}
|
onToggleTagFilter={onToggleTagFilter}
|
||||||
onOpenPlayer={onOpenPlayer}
|
onOpenPlayer={onOpenPlayer}
|
||||||
handleDuration={handleDuration}
|
handleDuration={handleDuration}
|
||||||
|
handleResolution={handleResolution}
|
||||||
|
resolutionLabelOf={resolutionLabelOf}
|
||||||
handleScrubberClickIndex={handleScrubberClickIndex}
|
handleScrubberClickIndex={handleScrubberClickIndex}
|
||||||
deleteVideo={deleteVideo}
|
deleteVideo={deleteVideo}
|
||||||
keepVideo={keepVideo}
|
keepVideo={keepVideo}
|
||||||
@ -305,6 +315,8 @@ function FinishedDownloadsGalleryView({
|
|||||||
footerMinHeightClass={footerMinHeightClass}
|
footerMinHeightClass={footerMinHeightClass}
|
||||||
titleClass={titleClass}
|
titleClass={titleClass}
|
||||||
sublineClass={sublineClass}
|
sublineClass={sublineClass}
|
||||||
|
assetNonce={assetNonce ?? 0}
|
||||||
|
renderIntegrityBadge={renderIntegrityBadge}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -323,5 +335,5 @@ function FinishedDownloadsGalleryView({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const MemoFinishedDownloadsGalleryView = React.memo(FinishedDownloadsGalleryView)
|
const MemoFinishedDownloadsGalleryView = memo(FinishedDownloadsGalleryView)
|
||||||
export default MemoFinishedDownloadsGalleryView
|
export default MemoFinishedDownloadsGalleryView
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import { useState, useMemo, useCallback, type Dispatch, type ReactNode, type SetStateAction } from 'react'
|
||||||
import Table, { type Column, type SortState } from './Table'
|
import Table, { type Column, type SortState } from './Table'
|
||||||
import type {
|
import type {
|
||||||
RecordJob,
|
RecordJob,
|
||||||
@ -50,12 +50,12 @@ type Props = {
|
|||||||
summaries: Record<string, FinishedPostworkSummary>
|
summaries: Record<string, FinishedPostworkSummary>
|
||||||
) => FinishedPostworkSummary | undefined
|
) => FinishedPostworkSummary | undefined
|
||||||
jobForDetails: (job: RecordJob) => RecordJob
|
jobForDetails: (job: RecordJob) => RecordJob
|
||||||
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode
|
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode
|
||||||
|
|
||||||
// teaser/preview
|
// teaser/preview
|
||||||
canHover: boolean
|
canHover: boolean
|
||||||
teaserState: FinishedDownloadsTeaserState
|
teaserState: FinishedDownloadsTeaserState
|
||||||
setHoverTeaserKey: React.Dispatch<React.SetStateAction<string | null>>
|
setHoverTeaserKey: Dispatch<SetStateAction<string | null>>
|
||||||
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
|
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
|
||||||
handleDuration: (job: RecordJob, seconds: number) => void
|
handleDuration: (job: RecordJob, seconds: number) => void
|
||||||
handleResolution: (job: RecordJob, w: number, h: number) => void
|
handleResolution: (job: RecordJob, w: number, h: number) => void
|
||||||
@ -88,6 +88,7 @@ type Props = {
|
|||||||
deleteVideo: (job: RecordJob) => Promise<boolean>
|
deleteVideo: (job: RecordJob) => Promise<boolean>
|
||||||
keepVideo: (job: RecordJob) => Promise<boolean>
|
keepVideo: (job: RecordJob) => Promise<boolean>
|
||||||
forcePreviewMuted?: boolean
|
forcePreviewMuted?: boolean
|
||||||
|
renderIntegrityBadge?: (job: RecordJob) => ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FinishedDownloadsTableView({
|
export default function FinishedDownloadsTableView({
|
||||||
@ -143,10 +144,11 @@ export default function FinishedDownloadsTableView({
|
|||||||
deleteVideo,
|
deleteVideo,
|
||||||
keepVideo,
|
keepVideo,
|
||||||
forcePreviewMuted,
|
forcePreviewMuted,
|
||||||
|
renderIntegrityBadge,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [sort, setSort] = React.useState<SortState>(null)
|
const [sort, setSort] = useState<SortState>(null)
|
||||||
|
|
||||||
const runtimeSecondsForSort = React.useCallback((job: RecordJob) => {
|
const runtimeSecondsForSort = useCallback((job: RecordJob) => {
|
||||||
// 1) Prefer real video duration (ffprobe / backend)
|
// 1) Prefer real video duration (ffprobe / backend)
|
||||||
const sec = (job as any)?.meta?.file?.durationSeconds
|
const sec = (job as any)?.meta?.file?.durationSeconds
|
||||||
if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) return sec
|
if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) return sec
|
||||||
@ -162,7 +164,7 @@ export default function FinishedDownloadsTableView({
|
|||||||
return Number.POSITIVE_INFINITY
|
return Number.POSITIVE_INFINITY
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const parseTags = React.useCallback((raw?: string): string[] => {
|
const parseTags = useCallback((raw?: string): string[] => {
|
||||||
const s = String(raw ?? '').trim()
|
const s = String(raw ?? '').trim()
|
||||||
if (!s) return []
|
if (!s) return []
|
||||||
const parts = s
|
const parts = s
|
||||||
@ -182,11 +184,11 @@ export default function FinishedDownloadsTableView({
|
|||||||
return out
|
return out
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const parseMeta = React.useCallback((j: RecordJob): any | null => {
|
const parseMeta = useCallback((j: RecordJob): any | null => {
|
||||||
return parseJobMeta((j as any)?.meta)
|
return parseJobMeta((j as any)?.meta)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const previewSpriteInfoOf = React.useCallback(
|
const previewSpriteInfoOf = useCallback(
|
||||||
(j: RecordJob): { count: number; stepSeconds: number } | null => {
|
(j: RecordJob): { count: number; stepSeconds: number } | null => {
|
||||||
const meta = parseMeta(j)
|
const meta = parseMeta(j)
|
||||||
let ps: any = meta?.previewSprite ?? meta?.preview?.sprite ?? null
|
let ps: any = meta?.previewSprite ?? meta?.preview?.sprite ?? null
|
||||||
@ -212,9 +214,9 @@ export default function FinishedDownloadsTableView({
|
|||||||
[parseMeta]
|
[parseMeta]
|
||||||
)
|
)
|
||||||
|
|
||||||
const [scrubActiveByKey, setScrubActiveByKey] = React.useState<Record<string, number | undefined>>({})
|
const [scrubActiveByKey, setScrubActiveByKey] = useState<Record<string, number | undefined>>({})
|
||||||
|
|
||||||
const setScrubActiveIndex = React.useCallback((key: string, index: number | undefined) => {
|
const setScrubActiveIndex = useCallback((key: string, index: number | undefined) => {
|
||||||
setScrubActiveByKey((prev) => {
|
setScrubActiveByKey((prev) => {
|
||||||
if (prev[key] === index) return prev
|
if (prev[key] === index) return prev
|
||||||
return { ...prev, [key]: index }
|
return { ...prev, [key]: index }
|
||||||
@ -227,7 +229,7 @@ export default function FinishedDownloadsTableView({
|
|||||||
const someSelectedOnPage =
|
const someSelectedOnPage =
|
||||||
rows.some((j) => selectedKeys.has(keyFor(j))) && !allSelectedOnPage
|
rows.some((j) => selectedKeys.has(keyFor(j))) && !allSelectedOnPage
|
||||||
|
|
||||||
const handlePrimaryAction = React.useCallback(
|
const handlePrimaryAction = useCallback(
|
||||||
(job: RecordJob) => {
|
(job: RecordJob) => {
|
||||||
if (selectionModeActive) {
|
if (selectionModeActive) {
|
||||||
onToggleSelected(job)
|
onToggleSelected(job)
|
||||||
@ -239,7 +241,7 @@ export default function FinishedDownloadsTableView({
|
|||||||
[selectionModeActive, onToggleSelected, onOpenPlayer]
|
[selectionModeActive, onToggleSelected, onOpenPlayer]
|
||||||
)
|
)
|
||||||
|
|
||||||
const columns = React.useMemo<Column<RecordJob>[]>(() => {
|
const columns = useMemo<Column<RecordJob>[]>(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: 'select',
|
key: 'select',
|
||||||
@ -411,9 +413,10 @@ export default function FinishedDownloadsTableView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showPostworkBadge && renderPostworkBadge ? (
|
{(renderIntegrityBadge || (showPostworkBadge && renderPostworkBadge)) ? (
|
||||||
<div className="relative z-20 shrink-0">
|
<div className="relative z-20 shrink-0 flex items-center gap-2">
|
||||||
{renderPostworkBadge(postworkBadge)}
|
{renderIntegrityBadge ? renderIntegrityBadge(j) : null}
|
||||||
|
{showPostworkBadge && renderPostworkBadge ? renderPostworkBadge(postworkBadge) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@ -604,9 +607,10 @@ export default function FinishedDownloadsTableView({
|
|||||||
onToggleSelectAllPage,
|
onToggleSelectAllPage,
|
||||||
allSelectedOnPage,
|
allSelectedOnPage,
|
||||||
someSelectedOnPage,
|
someSelectedOnPage,
|
||||||
|
renderIntegrityBadge
|
||||||
])
|
])
|
||||||
|
|
||||||
const sortStateToMode = React.useCallback((s: SortState): DoneSortMode => {
|
const sortStateToMode = useCallback((s: SortState): DoneSortMode => {
|
||||||
if (!s) return 'completed_desc'
|
if (!s) return 'completed_desc'
|
||||||
const anyS = s as any
|
const anyS = s as any
|
||||||
const key = String(anyS.key ?? anyS.columnKey ?? anyS.id ?? '')
|
const key = String(anyS.key ?? anyS.columnKey ?? anyS.id ?? '')
|
||||||
@ -621,7 +625,7 @@ export default function FinishedDownloadsTableView({
|
|||||||
return asc ? 'completed_asc' : 'completed_desc'
|
return asc ? 'completed_asc' : 'completed_desc'
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleSortChange = React.useCallback(
|
const handleSortChange = useCallback(
|
||||||
(s: SortState) => {
|
(s: SortState) => {
|
||||||
setSort(s) // Table Pfeil
|
setSort(s) // Table Pfeil
|
||||||
onSortModeChange(sortStateToMode(s))
|
onSortModeChange(sortStateToMode(s))
|
||||||
@ -630,7 +634,7 @@ export default function FinishedDownloadsTableView({
|
|||||||
[onSortModeChange, sortStateToMode, page, onPageChange]
|
[onSortModeChange, sortStateToMode, page, onPageChange]
|
||||||
)
|
)
|
||||||
|
|
||||||
const rowClassName = React.useCallback(
|
const rowClassName = useCallback(
|
||||||
(j: RecordJob) => {
|
(j: RecordJob) => {
|
||||||
const k = keyFor(j)
|
const k = keyFor(j)
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -150,8 +150,7 @@ export default function FinishedVideoPreview({
|
|||||||
return m
|
return m
|
||||||
}, [job])
|
}, [job])
|
||||||
|
|
||||||
// ✅ merge statt "meta ?? fetchedMeta"
|
// meta aus job normalisiert; dient als Basis für Preview-/Teaser-Daten
|
||||||
// job.meta bleibt Basis, fetchedMeta ergänzt fehlende Felder (z.B. previewClips)
|
|
||||||
const metaForPreview = meta
|
const metaForPreview = meta
|
||||||
|
|
||||||
const [progressMountTick, setProgressMountTick] = useState(0)
|
const [progressMountTick, setProgressMountTick] = useState(0)
|
||||||
@ -685,8 +684,8 @@ export default function FinishedVideoPreview({
|
|||||||
|
|
||||||
const teaserSrc = useMemo(() => {
|
const teaserSrc = useMemo(() => {
|
||||||
if (!previewId) return ''
|
if (!previewId) return ''
|
||||||
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1`
|
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1&v=${v}`
|
||||||
}, [previewId])
|
}, [previewId, v])
|
||||||
|
|
||||||
// --- Inline Video sichtbar?
|
// --- Inline Video sichtbar?
|
||||||
const showingInlineVideo =
|
const showingInlineVideo =
|
||||||
@ -1403,7 +1402,7 @@ export default function FinishedVideoPreview({
|
|||||||
{/* ✅ Teaser prewarm (lädt Datei/Metadata vor, bleibt unsichtbar) */}
|
{/* ✅ Teaser prewarm (lädt Datei/Metadata vor, bleibt unsichtbar) */}
|
||||||
{!showingInlineVideo && teaserCanPrewarm && !(teaserActive && animatedMode === 'teaser') ? (
|
{!showingInlineVideo && teaserCanPrewarm && !(teaserActive && animatedMode === 'teaser') ? (
|
||||||
<video
|
<video
|
||||||
key={`teaser-prewarm-${previewId}`}
|
key={`teaser-prewarm-${previewId}-${v}`}
|
||||||
src={teaserSrc}
|
src={teaserSrc}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
muted
|
muted
|
||||||
@ -1427,6 +1426,7 @@ export default function FinishedVideoPreview({
|
|||||||
{/* ✅ Teaser MP4 */}
|
{/* ✅ Teaser MP4 */}
|
||||||
{!showingInlineVideo && teaserActive && animatedMode === 'teaser' ? (
|
{!showingInlineVideo && teaserActive && animatedMode === 'teaser' ? (
|
||||||
<video
|
<video
|
||||||
|
key={`teaser-${previewId}-${v}`}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
teaserMp4Ref.current = el
|
teaserMp4Ref.current = el
|
||||||
bumpMountTickIfNew('teaser', el)
|
bumpMountTickIfNew('teaser', el)
|
||||||
|
|||||||
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
|
// components/ui/LabeledSwitch.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import { useId, type ReactNode } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import Switch, { type SwitchProps } from './Switch'
|
import Switch, { type SwitchProps } from './Switch'
|
||||||
|
|
||||||
type Props = Omit<SwitchProps, 'ariaLabelledby' | 'ariaDescribedby' | 'ariaLabel'> & {
|
type Props = Omit<SwitchProps, 'ariaLabelledby' | 'ariaDescribedby' | 'ariaLabel'> & {
|
||||||
label: React.ReactNode
|
label: ReactNode
|
||||||
description?: React.ReactNode
|
description?: ReactNode
|
||||||
|
|
||||||
/** "left" = Label links / Switch rechts (wie Beispiel) */
|
|
||||||
labelPosition?: 'left' | 'right'
|
labelPosition?: 'left' | 'right'
|
||||||
/** Layout wrapper classes */
|
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -21,30 +18,50 @@ export default function LabeledSwitch({
|
|||||||
labelPosition = 'left',
|
labelPosition = 'left',
|
||||||
id,
|
id,
|
||||||
className,
|
className,
|
||||||
|
compact = false,
|
||||||
...switchProps
|
...switchProps
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const reactId = React.useId()
|
const reactId = useId()
|
||||||
const switchId = id ?? `sw-${reactId}`
|
const switchId = id ?? `sw-${reactId}`
|
||||||
const labelId = `${switchId}-label`
|
const labelId = `${switchId}-label`
|
||||||
const descId = `${switchId}-desc`
|
const descId = `${switchId}-desc`
|
||||||
|
|
||||||
if (labelPosition === 'right') {
|
if (labelPosition === 'right') {
|
||||||
// With right label Beispiel
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx('flex items-center justify-between gap-3', className)}>
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center justify-between',
|
||||||
|
compact ? 'gap-2' : 'gap-3',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Switch
|
<Switch
|
||||||
{...switchProps}
|
{...switchProps}
|
||||||
|
compact={compact}
|
||||||
id={switchId}
|
id={switchId}
|
||||||
ariaLabelledby={labelId}
|
ariaLabelledby={labelId}
|
||||||
ariaDescribedby={description ? descId : undefined}
|
ariaDescribedby={description ? descId : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="text-sm">
|
<div className={compact ? 'text-xs' : 'text-sm'}>
|
||||||
<label id={labelId} htmlFor={switchId} className="font-medium text-gray-900 dark:text-white">
|
<label
|
||||||
|
id={labelId}
|
||||||
|
htmlFor={switchId}
|
||||||
|
className={clsx(
|
||||||
|
'font-medium text-gray-900 dark:text-white',
|
||||||
|
compact ? 'leading-5' : ''
|
||||||
|
)}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</label>{' '}
|
</label>{' '}
|
||||||
{description ? (
|
{description ? (
|
||||||
<span id={descId} className="text-gray-500 dark:text-gray-400">
|
<span
|
||||||
|
id={descId}
|
||||||
|
className={clsx(
|
||||||
|
'text-gray-500 dark:text-gray-400',
|
||||||
|
compact ? 'text-[11px]' : ''
|
||||||
|
)}
|
||||||
|
>
|
||||||
{description}
|
{description}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@ -53,15 +70,34 @@ export default function LabeledSwitch({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// With left label and description Beispiel
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx('flex items-center justify-between', className)}>
|
<div
|
||||||
<span className="flex grow flex-col pr-2">
|
className={clsx(
|
||||||
<label id={labelId} htmlFor={switchId} className="text-sm/6 font-medium text-gray-900 dark:text-white">
|
'flex items-center justify-between',
|
||||||
|
compact ? 'gap-2' : 'gap-3',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={clsx('flex grow flex-col min-w-0', compact ? 'pr-1' : 'pr-2')}>
|
||||||
|
<label
|
||||||
|
id={labelId}
|
||||||
|
htmlFor={switchId}
|
||||||
|
className={clsx(
|
||||||
|
'font-medium text-gray-900 dark:text-white truncate',
|
||||||
|
compact ? 'text-xs leading-5' : 'text-sm/6'
|
||||||
|
)}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{description ? (
|
{description ? (
|
||||||
<span id={descId} className="text-sm text-gray-500 dark:text-gray-400">
|
<span
|
||||||
|
id={descId}
|
||||||
|
className={clsx(
|
||||||
|
'text-gray-500 dark:text-gray-400',
|
||||||
|
compact ? 'text-[11px]' : 'text-sm'
|
||||||
|
)}
|
||||||
|
>
|
||||||
{description}
|
{description}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@ -69,10 +105,11 @@ export default function LabeledSwitch({
|
|||||||
|
|
||||||
<Switch
|
<Switch
|
||||||
{...switchProps}
|
{...switchProps}
|
||||||
|
compact={compact}
|
||||||
id={switchId}
|
id={switchId}
|
||||||
ariaLabelledby={labelId}
|
ariaLabelledby={labelId}
|
||||||
ariaDescribedby={description ? descId : undefined}
|
ariaDescribedby={description ? descId : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -1,15 +1,15 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.ReactNode
|
children: ReactNode
|
||||||
/** Wenn true: sofort mounten (ohne IntersectionObserver) */
|
/** Wenn true: sofort mounten (ohne IntersectionObserver) */
|
||||||
force?: boolean
|
force?: boolean
|
||||||
/** Vorladen bevor es wirklich sichtbar ist */
|
/** Vorladen bevor es wirklich sichtbar ist */
|
||||||
rootMargin?: string
|
rootMargin?: string
|
||||||
/** Optional: Platzhalter bis mounted */
|
/** Optional: Platzhalter bis mounted */
|
||||||
placeholder?: React.ReactNode
|
placeholder?: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -20,15 +20,15 @@ export default function LazyMount({
|
|||||||
placeholder = null,
|
placeholder = null,
|
||||||
className,
|
className,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const ref = React.useRef<HTMLDivElement | null>(null)
|
const ref = useRef<HTMLDivElement | null>(null)
|
||||||
const [mounted, setMounted] = React.useState<boolean>(force)
|
const [mounted, setMounted] = useState<boolean>(force)
|
||||||
|
|
||||||
// Wenn force später true wird (z.B. inlinePlay startet) -> sofort mounten
|
// Wenn force später true wird (z.B. inlinePlay startet) -> sofort mounten
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (force && !mounted) setMounted(true)
|
if (force && !mounted) setMounted(true)
|
||||||
}, [force, mounted])
|
}, [force, mounted])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (mounted || force) return
|
if (mounted || force) return
|
||||||
const el = ref.current
|
const el = ref.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
|||||||
@ -73,6 +73,9 @@ export default function LiveVideo({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let watchdogTimer: number | null = null
|
let watchdogTimer: number | null = null
|
||||||
let readySent = false
|
let readySent = false
|
||||||
|
let stalledRetries = 0
|
||||||
|
let attemptStartedAt = Date.now()
|
||||||
|
let lastProgressAt = Date.now()
|
||||||
|
|
||||||
const video = ref.current
|
const video = ref.current
|
||||||
if (!video) return
|
if (!video) return
|
||||||
@ -92,6 +95,31 @@ export default function LiveVideo({
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const markBroken = () => {
|
||||||
|
const reason = normalizeBrokenReason(roomStatus)
|
||||||
|
setBroken(true)
|
||||||
|
setBrokenReason(reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
const beginLoad = () => {
|
||||||
|
readySent = false
|
||||||
|
attemptStartedAt = Date.now()
|
||||||
|
lastProgressAt = Date.now()
|
||||||
|
|
||||||
|
onLoadingStartRef.current?.()
|
||||||
|
|
||||||
|
hardReset()
|
||||||
|
video.src = src
|
||||||
|
video.load()
|
||||||
|
|
||||||
|
const playPromise = video.play()
|
||||||
|
if (playPromise && typeof playPromise.catch === 'function') {
|
||||||
|
playPromise.catch(() => {
|
||||||
|
// Nicht sofort abbrechen – Watchdog entscheidet nach Timeout.
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
hardReset()
|
hardReset()
|
||||||
|
|
||||||
if (!src) {
|
if (!src) {
|
||||||
@ -100,90 +128,83 @@ export default function LiveVideo({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoadingStartRef.current?.()
|
|
||||||
|
|
||||||
const markReady = () => {
|
const markReady = () => {
|
||||||
|
lastProgressAt = Date.now()
|
||||||
|
|
||||||
if (readySent) return
|
if (readySent) return
|
||||||
readySent = true
|
readySent = true
|
||||||
onReadyRef.current?.()
|
onReadyRef.current?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
video.src = src
|
|
||||||
video.load()
|
|
||||||
video.play().catch(() => {})
|
|
||||||
|
|
||||||
let lastT = Date.now()
|
|
||||||
const onTime = () => {
|
const onTime = () => {
|
||||||
lastT = Date.now()
|
lastProgressAt = Date.now()
|
||||||
markReady()
|
markReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onLoadedData = () => {
|
const onLoadedData = () => {
|
||||||
|
lastProgressAt = Date.now()
|
||||||
markReady()
|
markReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onCanPlay = () => {
|
const onCanPlay = () => {
|
||||||
|
lastProgressAt = Date.now()
|
||||||
markReady()
|
markReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPlaying = () => {
|
const onPlaying = () => {
|
||||||
|
lastProgressAt = Date.now()
|
||||||
markReady()
|
markReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onError = () => {
|
||||||
|
markBroken()
|
||||||
|
}
|
||||||
|
|
||||||
video.addEventListener('timeupdate', onTime)
|
video.addEventListener('timeupdate', onTime)
|
||||||
video.addEventListener('loadeddata', onLoadedData)
|
video.addEventListener('loadeddata', onLoadedData)
|
||||||
video.addEventListener('canplay', onCanPlay)
|
video.addEventListener('canplay', onCanPlay)
|
||||||
video.addEventListener('playing', onPlaying)
|
video.addEventListener('playing', onPlaying)
|
||||||
|
video.addEventListener('error', onError)
|
||||||
|
|
||||||
let stalledRetries = 0
|
beginLoad()
|
||||||
|
|
||||||
watchdogTimer = window.setInterval(() => {
|
watchdogTimer = window.setInterval(() => {
|
||||||
if (!video.paused && Date.now() - lastT > 12_000) {
|
const now = Date.now()
|
||||||
|
|
||||||
|
// Stream wurde nie ready -> nicht ewig Spinner zeigen
|
||||||
|
if (!readySent && now - attemptStartedAt > 12_000) {
|
||||||
if (stalledRetries < 1) {
|
if (stalledRetries < 1) {
|
||||||
stalledRetries += 1
|
stalledRetries += 1
|
||||||
readySent = false
|
beginLoad()
|
||||||
onLoadingStartRef.current?.()
|
|
||||||
|
|
||||||
hardReset()
|
|
||||||
video.src = src
|
|
||||||
video.load()
|
|
||||||
video.play().catch(() => {})
|
|
||||||
lastT = Date.now()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const reason = normalizeBrokenReason(roomStatus)
|
markBroken()
|
||||||
|
|
||||||
if (reason === 'unknown') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setBroken(true)
|
|
||||||
setBrokenReason(reason)
|
|
||||||
}
|
|
||||||
}, 4_000)
|
|
||||||
|
|
||||||
const onError = () => {
|
|
||||||
const reason = normalizeBrokenReason(roomStatus)
|
|
||||||
|
|
||||||
if (reason === 'unknown') {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setBroken(true)
|
// Stream war ready, hängt aber später
|
||||||
setBrokenReason(reason)
|
if (readySent && !video.paused && now - lastProgressAt > 12_000) {
|
||||||
}
|
if (stalledRetries < 1) {
|
||||||
|
stalledRetries += 1
|
||||||
|
beginLoad()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
video.addEventListener('error', onError)
|
markBroken()
|
||||||
|
}
|
||||||
|
}, 1_000)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (watchdogTimer) window.clearInterval(watchdogTimer)
|
if (watchdogTimer) window.clearInterval(watchdogTimer)
|
||||||
watchdogTimer = null
|
watchdogTimer = null
|
||||||
|
|
||||||
video.removeEventListener('timeupdate', onTime)
|
video.removeEventListener('timeupdate', onTime)
|
||||||
video.removeEventListener('loadeddata', onLoadedData)
|
video.removeEventListener('loadeddata', onLoadedData)
|
||||||
video.removeEventListener('canplay', onCanPlay)
|
video.removeEventListener('canplay', onCanPlay)
|
||||||
video.removeEventListener('playing', onPlaying)
|
video.removeEventListener('playing', onPlaying)
|
||||||
video.removeEventListener('error', onError)
|
video.removeEventListener('error', onError)
|
||||||
|
|
||||||
hardReset()
|
hardReset()
|
||||||
}
|
}
|
||||||
}, [src, roomStatus])
|
}, [src, roomStatus])
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import { type ReactNode } from 'react'
|
||||||
|
|
||||||
type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | number
|
type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | number
|
||||||
|
|
||||||
@ -12,7 +12,7 @@ export type LoadingSpinnerProps = {
|
|||||||
/** Farbe via Tailwind (z.B. "text-indigo-500") */
|
/** Farbe via Tailwind (z.B. "text-indigo-500") */
|
||||||
className?: string
|
className?: string
|
||||||
/** Optionaler Text neben dem Spinner (sichtbar) */
|
/** Optionaler Text neben dem Spinner (sichtbar) */
|
||||||
label?: React.ReactNode
|
label?: ReactNode
|
||||||
/** Screenreader-Text (wenn label nicht gesetzt ist) */
|
/** Screenreader-Text (wenn label nicht gesetzt ist) */
|
||||||
srLabel?: string
|
srLabel?: string
|
||||||
/** Zentriert Spinner + Label als Inline-Flex */
|
/** Zentriert Spinner + Label als Inline-Flex */
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// frontend/src/components/ui/LoginPage.tsx
|
// frontend/src/components/ui/LoginPage.tsx
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ClipboardEvent } from 'react'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -170,7 +170,7 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
|||||||
if (i < 5) window.setTimeout(() => focusDigit(i + 1), 0)
|
if (i < 5) window.setTimeout(() => focusDigit(i + 1), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDigitKeyDown(i: number, ev: React.KeyboardEvent<HTMLInputElement>) {
|
function handleDigitKeyDown(i: number, ev: KeyboardEvent<HTMLInputElement>) {
|
||||||
if (ev.key === 'Backspace') {
|
if (ev.key === 'Backspace') {
|
||||||
const cur = codeDigits[i]
|
const cur = codeDigits[i]
|
||||||
if (cur) {
|
if (cur) {
|
||||||
@ -201,7 +201,7 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDigitPaste(i: number, ev: React.ClipboardEvent<HTMLInputElement>) {
|
function handleDigitPaste(i: number, ev: ClipboardEvent<HTMLInputElement>) {
|
||||||
const text = ev.clipboardData.getData('text') || ''
|
const text = ev.clipboardData.getData('text') || ''
|
||||||
const only = text.replace(/\D/g, '').slice(0, 6)
|
const only = text.replace(/\D/g, '').slice(0, 6)
|
||||||
if (!only) return
|
if (!only) return
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import { useEffect, useRef, useState, useMemo, useCallback, type ReactNode } from 'react'
|
||||||
|
|
||||||
import type { RecordJob } from '../../types'
|
import type { RecordJob } from '../../types'
|
||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
@ -518,61 +518,61 @@ export default function ModelDetails({
|
|||||||
|
|
||||||
//const isDesktop = useMediaQuery('(min-width: 640px)')
|
//const isDesktop = useMediaQuery('(min-width: 640px)')
|
||||||
|
|
||||||
const [models, setModels] = React.useState<StoredModel[]>([])
|
const [models, setModels] = useState<StoredModel[]>([])
|
||||||
|
|
||||||
const [room, setRoom] = React.useState<ChaturbateRoom | null>(null)
|
const [room, setRoom] = useState<ChaturbateRoom | null>(null)
|
||||||
const [roomMeta, setRoomMeta] = React.useState<Pick<OnlineResp, 'enabled' | 'fetchedAt' | 'lastError'> | null>(null)
|
const [roomMeta, setRoomMeta] = useState<Pick<OnlineResp, 'enabled' | 'fetchedAt' | 'lastError'> | null>(null)
|
||||||
|
|
||||||
const [bio, setBio] = React.useState<BioContext | null>(null)
|
const [bio, setBio] = useState<BioContext | null>(null)
|
||||||
const [bioMeta, setBioMeta] = React.useState<Pick<BioResp, 'enabled' | 'fetchedAt' | 'lastError'> | null>(null)
|
const [bioMeta, setBioMeta] = useState<Pick<BioResp, 'enabled' | 'fetchedAt' | 'lastError'> | null>(null)
|
||||||
const [bioLoading, setBioLoading] = React.useState(false)
|
const [bioLoading, setBioLoading] = useState(false)
|
||||||
|
|
||||||
const [done, setDone] = React.useState<RecordJob[]>([])
|
const [done, setDone] = useState<RecordJob[]>([])
|
||||||
const [doneLoading, setDoneLoading] = React.useState(false)
|
const [doneLoading, setDoneLoading] = useState(false)
|
||||||
|
|
||||||
const [running, setRunning] = React.useState<RecordJob[]>([])
|
const [running, setRunning] = useState<RecordJob[]>([])
|
||||||
const [runningLoading, setRunningLoading] = React.useState(false)
|
const [runningLoading, setRunningLoading] = useState(false)
|
||||||
|
|
||||||
const runningReqSeqRef = React.useRef(0)
|
const runningReqSeqRef = useRef(0)
|
||||||
|
|
||||||
const [, setBioRefreshSeq] = React.useState(0)
|
const [, setBioRefreshSeq] = useState(0)
|
||||||
|
|
||||||
const [imgViewer, setImgViewer] = React.useState<{ src: string; alt?: string } | null>(null)
|
const [imgViewer, setImgViewer] = useState<{ src: string; alt?: string } | null>(null)
|
||||||
|
|
||||||
const [, setRunningHover] = React.useState(false)
|
const [, setRunningHover] = useState(false)
|
||||||
|
|
||||||
const [stopPending, setStopPending] = React.useState(false)
|
const [stopPending, setStopPending] = useState(false)
|
||||||
|
|
||||||
const [doneTotalCount, setDoneTotalCount] = React.useState(0)
|
const [doneTotalCount, setDoneTotalCount] = useState(0)
|
||||||
|
|
||||||
const [donePage, setDonePage] = React.useState(1)
|
const [donePage, setDonePage] = useState(1)
|
||||||
const DONE_PAGE_SIZE = 4
|
const DONE_PAGE_SIZE = 4
|
||||||
|
|
||||||
const key = normalizeModelKey(modelKey)
|
const key = normalizeModelKey(modelKey)
|
||||||
|
|
||||||
type TabKey = 'info' | 'downloads' | 'running'
|
type TabKey = 'info' | 'downloads' | 'running'
|
||||||
const [tab, setTab] = React.useState<TabKey>('info')
|
const [tab, setTab] = useState<TabKey>('info')
|
||||||
|
|
||||||
const bioReqRef = React.useRef<AbortController | null>(null)
|
const bioReqRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
// ===== Gallery UI State (wie FinishedDownloadsGalleryView) =====
|
// ===== Gallery UI State (wie FinishedDownloadsGalleryView) =====
|
||||||
const [durations, setDurations] = React.useState<Record<string, number>>({})
|
const [durations, setDurations] = useState<Record<string, number>>({})
|
||||||
const [hoverTeaserKey, setHoverTeaserKey] = React.useState<string | null>(null)
|
const [hoverTeaserKey, setHoverTeaserKey] = useState<string | null>(null)
|
||||||
const [teaserKey, setTeaserKey] = React.useState<string | null>(null)
|
const [teaserKey, setTeaserKey] = useState<string | null>(null)
|
||||||
|
|
||||||
const [hoveredModelPreviewKey, setHoveredModelPreviewKey] = React.useState<string | null>(null)
|
const [hoveredModelPreviewKey, setHoveredModelPreviewKey] = useState<string | null>(null)
|
||||||
const [scrubIndexByKey, setScrubIndexByKey] = React.useState<Record<string, number | undefined>>({})
|
const [scrubIndexByKey, setScrubIndexByKey] = useState<Record<string, number | undefined>>({})
|
||||||
|
|
||||||
const [hoveredThumbKey, setHoveredThumbKey] = React.useState<string | null>(null)
|
const [hoveredThumbKey, setHoveredThumbKey] = useState<string | null>(null)
|
||||||
|
|
||||||
const lower = React.useCallback((s: string) => String(s ?? '').toLowerCase(), [])
|
const lower = useCallback((s: string) => String(s ?? '').toLowerCase(), [])
|
||||||
|
|
||||||
const deletingKeys = React.useMemo(() => new Set<string>(), [])
|
const deletingKeys = useMemo(() => new Set<string>(), [])
|
||||||
const keepingKeys = React.useMemo(() => new Set<string>(), [])
|
const keepingKeys = useMemo(() => new Set<string>(), [])
|
||||||
const removingKeys = React.useMemo(() => new Set<string>(), [])
|
const removingKeys = useMemo(() => new Set<string>(), [])
|
||||||
const deletedKeys = React.useMemo(() => new Set<string>(), [])
|
const deletedKeys = useMemo(() => new Set<string>(), [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
bioReqRef.current?.abort()
|
bioReqRef.current?.abort()
|
||||||
bioReqRef.current = null
|
bioReqRef.current = null
|
||||||
@ -603,7 +603,7 @@ export default function ModelDetails({
|
|||||||
}, [open, key])
|
}, [open, key])
|
||||||
|
|
||||||
// ✅ 1) Beim Öffnen sofort aus Cache rendern
|
// ✅ 1) Beim Öffnen sofort aus Cache rendern
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !key) return
|
if (!open || !key) return
|
||||||
|
|
||||||
// Online
|
// Online
|
||||||
@ -634,12 +634,12 @@ export default function ModelDetails({
|
|||||||
}
|
}
|
||||||
}, [open, key])
|
}, [open, key])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
setStopPending(false)
|
setStopPending(false)
|
||||||
}, [open, key])
|
}, [open, key])
|
||||||
|
|
||||||
const refreshBio = React.useCallback(async () => {
|
const refreshBio = useCallback(async () => {
|
||||||
if (!key) return
|
if (!key) return
|
||||||
|
|
||||||
// vorherigen abbrechen
|
// vorherigen abbrechen
|
||||||
@ -681,13 +681,13 @@ export default function ModelDetails({
|
|||||||
}
|
}
|
||||||
}, [key, cookies])
|
}, [key, cookies])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) return
|
if (open) return
|
||||||
bioReqRef.current?.abort()
|
bioReqRef.current?.abort()
|
||||||
bioReqRef.current = null
|
bioReqRef.current = null
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const refetchModels = React.useCallback(async () => {
|
const refetchModels = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/models', { cache: 'no-store' })
|
const r = await fetch('/api/models', { cache: 'no-store' })
|
||||||
const data = (await r.json().catch(() => null)) as any
|
const data = (await r.json().catch(() => null)) as any
|
||||||
@ -697,7 +697,7 @@ export default function ModelDetails({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const refetchDone = React.useCallback(async () => {
|
const refetchDone = useCallback(async () => {
|
||||||
if (!key) return
|
if (!key) return
|
||||||
setDoneLoading(true)
|
setDoneLoading(true)
|
||||||
try {
|
try {
|
||||||
@ -721,13 +721,13 @@ export default function ModelDetails({
|
|||||||
}
|
}
|
||||||
}, [key, donePage])
|
}, [key, donePage])
|
||||||
|
|
||||||
const refetchDoneRef = React.useRef(refetchDone)
|
const refetchDoneRef = useRef(refetchDone)
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
refetchDoneRef.current = refetchDone
|
refetchDoneRef.current = refetchDone
|
||||||
}, [refetchDone])
|
}, [refetchDone])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
||||||
const es = new EventSource('/api/stream')
|
const es = new EventSource('/api/stream')
|
||||||
@ -760,39 +760,39 @@ export default function ModelDetails({
|
|||||||
} as any
|
} as any
|
||||||
}
|
}
|
||||||
|
|
||||||
const openImage = React.useCallback((src?: string | null, alt?: string) => {
|
const openImage = useCallback((src?: string | null, alt?: string) => {
|
||||||
const s = String(src ?? '').trim()
|
const s = String(src ?? '').trim()
|
||||||
if (!s) return
|
if (!s) return
|
||||||
setImgViewer({ src: s, alt })
|
setImgViewer({ src: s, alt })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// wenn Modal zu geht, wieder "normal" (kein force refresh)
|
// wenn Modal zu geht, wieder "normal" (kein force refresh)
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) setBioRefreshSeq(0)
|
if (!open) setBioRefreshSeq(0)
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const runningList = React.useMemo(() => {
|
const runningList = useMemo(() => {
|
||||||
return Array.isArray(runningJobs) ? runningJobs : running
|
return Array.isArray(runningJobs) ? runningJobs : running
|
||||||
}, [runningJobs, running])
|
}, [runningJobs, running])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
setDonePage(1)
|
setDonePage(1)
|
||||||
}, [open, modelKey])
|
}, [open, modelKey])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
void refetchModels()
|
void refetchModels()
|
||||||
}, [open, refetchModels])
|
}, [open, refetchModels])
|
||||||
|
|
||||||
// Done downloads (inkl. keep/<model>/) -> serverseitig paginiert laden
|
// Done downloads (inkl. keep/<model>/) -> serverseitig paginiert laden
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !key) return
|
if (!open || !key) return
|
||||||
void refetchDone()
|
void refetchDone()
|
||||||
}, [open, key, refetchDone])
|
}, [open, key, refetchDone])
|
||||||
|
|
||||||
// Running jobs
|
// Running jobs
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
if (Array.isArray(runningJobs)) return
|
if (Array.isArray(runningJobs)) return
|
||||||
|
|
||||||
@ -824,12 +824,12 @@ export default function ModelDetails({
|
|||||||
}
|
}
|
||||||
}, [open, runningJobs])
|
}, [open, runningJobs])
|
||||||
|
|
||||||
const model = React.useMemo(() => {
|
const model = useMemo(() => {
|
||||||
if (!key) return null
|
if (!key) return null
|
||||||
return models.find((m) => (m.modelKey || '').toLowerCase() === key) ?? null
|
return models.find((m) => (m.modelKey || '').toLowerCase() === key) ?? null
|
||||||
}, [models, key])
|
}, [models, key])
|
||||||
|
|
||||||
const storedRoomFromSnap = React.useMemo<ChaturbateRoom | null>(() => {
|
const storedRoomFromSnap = useMemo<ChaturbateRoom | null>(() => {
|
||||||
const raw = (model as any)?.cbOnlineJson
|
const raw = (model as any)?.cbOnlineJson
|
||||||
if (!raw || typeof raw !== 'string') return null
|
if (!raw || typeof raw !== 'string') return null
|
||||||
try {
|
try {
|
||||||
@ -839,7 +839,7 @@ export default function ModelDetails({
|
|||||||
}
|
}
|
||||||
}, [model])
|
}, [model])
|
||||||
|
|
||||||
const storedRoomMeta = React.useMemo(() => {
|
const storedRoomMeta = useMemo(() => {
|
||||||
const fetchedAt = (model as any)?.cbOnlineFetchedAt
|
const fetchedAt = (model as any)?.cbOnlineFetchedAt
|
||||||
const lastError = (model as any)?.cbOnlineLastError
|
const lastError = (model as any)?.cbOnlineLastError
|
||||||
if (!fetchedAt && !lastError) return null
|
if (!fetchedAt && !lastError) return null
|
||||||
@ -851,7 +851,7 @@ export default function ModelDetails({
|
|||||||
|
|
||||||
const doneMatches = done
|
const doneMatches = done
|
||||||
|
|
||||||
const runningMatches = React.useMemo(() => {
|
const runningMatches = useMemo(() => {
|
||||||
if (!key) return []
|
if (!key) return []
|
||||||
return runningList.filter((j) => {
|
return runningList.filter((j) => {
|
||||||
const m = modelNameFromOutput(j.output)
|
const m = modelNameFromOutput(j.output)
|
||||||
@ -861,7 +861,7 @@ export default function ModelDetails({
|
|||||||
|
|
||||||
const titleName = effectiveRoom?.display_name || model?.modelKey || key || 'Model'
|
const titleName = effectiveRoom?.display_name || model?.modelKey || key || 'Model'
|
||||||
|
|
||||||
const titleUsername = React.useMemo(() => {
|
const titleUsername = useMemo(() => {
|
||||||
const raw = String(
|
const raw = String(
|
||||||
effectiveRoom?.username ||
|
effectiveRoom?.username ||
|
||||||
model?.modelKey ||
|
model?.modelKey ||
|
||||||
@ -877,7 +877,7 @@ export default function ModelDetails({
|
|||||||
const heroImgFull = effectiveRoom?.image_url || heroImg
|
const heroImgFull = effectiveRoom?.image_url || heroImg
|
||||||
const roomUrl = effectiveRoom?.chat_room_url_revshare || effectiveRoom?.chat_room_url || ''
|
const roomUrl = effectiveRoom?.chat_room_url_revshare || effectiveRoom?.chat_room_url || ''
|
||||||
|
|
||||||
const profileUsername = React.useMemo(() => {
|
const profileUsername = useMemo(() => {
|
||||||
const raw = String(
|
const raw = String(
|
||||||
effectiveRoom?.username ||
|
effectiveRoom?.username ||
|
||||||
model?.modelKey ||
|
model?.modelKey ||
|
||||||
@ -888,7 +888,7 @@ export default function ModelDetails({
|
|||||||
return raw.replace(/^@/, '')
|
return raw.replace(/^@/, '')
|
||||||
}, [effectiveRoom?.username, model?.modelKey, key])
|
}, [effectiveRoom?.username, model?.modelKey, key])
|
||||||
|
|
||||||
const profileHref = React.useMemo(() => {
|
const profileHref = useMemo(() => {
|
||||||
if (roomUrl) return roomUrl
|
if (roomUrl) return roomUrl
|
||||||
if (!profileUsername) return ''
|
if (!profileUsername) return ''
|
||||||
return absCbUrl(`/${profileUsername}/`)
|
return absCbUrl(`/${profileUsername}/`)
|
||||||
@ -914,7 +914,7 @@ export default function ModelDetails({
|
|||||||
const about = stripHtmlToText(bio?.about_me)
|
const about = stripHtmlToText(bio?.about_me)
|
||||||
const wish = stripHtmlToText(bio?.wish_list)
|
const wish = stripHtmlToText(bio?.wish_list)
|
||||||
|
|
||||||
const effectiveLocationParts = React.useMemo(() => {
|
const effectiveLocationParts = useMemo(() => {
|
||||||
const rawLocation = String(effectiveRoom?.location ?? bioLocation ?? '').trim()
|
const rawLocation = String(effectiveRoom?.location ?? bioLocation ?? '').trim()
|
||||||
const rawCountry = normalizeCountryName(effectiveRoom?.country)
|
const rawCountry = normalizeCountryName(effectiveRoom?.country)
|
||||||
|
|
||||||
@ -998,11 +998,11 @@ export default function ModelDetails({
|
|||||||
const effectiveLocationText = effectiveLocationParts.locationText
|
const effectiveLocationText = effectiveLocationParts.locationText
|
||||||
const effectiveCountryText = effectiveLocationParts.countryText
|
const effectiveCountryText = effectiveLocationParts.countryText
|
||||||
|
|
||||||
const effectiveLocationSubText = React.useMemo(() => {
|
const effectiveLocationSubText = useMemo(() => {
|
||||||
return effectiveCountryText || ''
|
return effectiveCountryText || ''
|
||||||
}, [effectiveCountryText])
|
}, [effectiveCountryText])
|
||||||
|
|
||||||
const effectiveLocationTitle = React.useMemo(() => {
|
const effectiveLocationTitle = useMemo(() => {
|
||||||
if (effectiveLocationText && effectiveLocationSubText) {
|
if (effectiveLocationText && effectiveLocationSubText) {
|
||||||
return `${effectiveLocationText} · ${effectiveLocationSubText}`
|
return `${effectiveLocationText} · ${effectiveLocationSubText}`
|
||||||
}
|
}
|
||||||
@ -1018,7 +1018,7 @@ export default function ModelDetails({
|
|||||||
? effectiveRoom.age
|
? effectiveRoom.age
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const effectiveBirthday = React.useMemo(() => {
|
const effectiveBirthday = useMemo(() => {
|
||||||
const bioBirthday = String(bio?.display_birthday ?? '').trim()
|
const bioBirthday = String(bio?.display_birthday ?? '').trim()
|
||||||
if (bioBirthday) return bioBirthday
|
if (bioBirthday) return bioBirthday
|
||||||
|
|
||||||
@ -1028,15 +1028,15 @@ export default function ModelDetails({
|
|||||||
return ''
|
return ''
|
||||||
}, [bio?.display_birthday, effectiveRoom?.birthday])
|
}, [bio?.display_birthday, effectiveRoom?.birthday])
|
||||||
|
|
||||||
const effectiveAgeText = React.useMemo(() => {
|
const effectiveAgeText = useMemo(() => {
|
||||||
return effectiveAge != null ? String(effectiveAge) : ''
|
return effectiveAge != null ? String(effectiveAge) : ''
|
||||||
}, [effectiveAge])
|
}, [effectiveAge])
|
||||||
|
|
||||||
const effectiveBirthdayText = React.useMemo(() => {
|
const effectiveBirthdayText = useMemo(() => {
|
||||||
return fmtBirthDate(effectiveBirthday)
|
return fmtBirthDate(effectiveBirthday)
|
||||||
}, [effectiveBirthday])
|
}, [effectiveBirthday])
|
||||||
|
|
||||||
const effectiveAgeTitle = React.useMemo(() => {
|
const effectiveAgeTitle = useMemo(() => {
|
||||||
if (effectiveAgeText && effectiveBirthdayText) {
|
if (effectiveAgeText && effectiveBirthdayText) {
|
||||||
return `${effectiveAgeText} · ${effectiveBirthdayText}`
|
return `${effectiveAgeText} · ${effectiveBirthdayText}`
|
||||||
}
|
}
|
||||||
@ -1049,7 +1049,7 @@ export default function ModelDetails({
|
|||||||
const photos = Array.isArray(bio?.photo_sets) ? bio!.photo_sets! : []
|
const photos = Array.isArray(bio?.photo_sets) ? bio!.photo_sets! : []
|
||||||
const interested = Array.isArray(bio?.interested_in) ? bio!.interested_in! : []
|
const interested = Array.isArray(bio?.interested_in) ? bio!.interested_in! : []
|
||||||
|
|
||||||
const allTags = React.useMemo(() => {
|
const allTags = useMemo(() => {
|
||||||
const a = splitTags(model?.tags)
|
const a = splitTags(model?.tags)
|
||||||
const b = Array.isArray(effectiveRoom?.tags) ? (effectiveRoom!.tags as string[]) : []
|
const b = Array.isArray(effectiveRoom?.tags) ? (effectiveRoom!.tags as string[]) : []
|
||||||
const map = new Map<string, string>()
|
const map = new Map<string, string>()
|
||||||
@ -1066,9 +1066,9 @@ export default function ModelDetails({
|
|||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode
|
icon: ReactNode
|
||||||
label: string
|
label: string
|
||||||
value: React.ReactNode
|
value: ReactNode
|
||||||
}) => (
|
}) => (
|
||||||
<div className="rounded-lg border border-gray-200/70 bg-white/60 px-2.5 py-2 sm:px-3 dark:border-white/10 dark:bg-white/5">
|
<div className="rounded-lg border border-gray-200/70 bg-white/60 px-2.5 py-2 sm:px-3 dark:border-white/10 dark:bg-white/5">
|
||||||
<div className="flex items-center gap-1.5 text-[11px] sm:text-xs text-gray-600 dark:text-gray-300">
|
<div className="flex items-center gap-1.5 text-[11px] sm:text-xs text-gray-600 dark:text-gray-300">
|
||||||
@ -1079,14 +1079,14 @@ export default function ModelDetails({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const keyFor = React.useCallback((j: RecordJob) => {
|
const keyFor = useCallback((j: RecordJob) => {
|
||||||
// stabil, auch wenn id fehlt
|
// stabil, auch wenn id fehlt
|
||||||
const id = String((j as any)?.id ?? '')
|
const id = String((j as any)?.id ?? '')
|
||||||
const out = String(j.output ?? '')
|
const out = String(j.output ?? '')
|
||||||
return id ? `${id}::${out}` : out
|
return id ? `${id}::${out}` : out
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleToggleHot = React.useCallback(
|
const handleToggleHot = useCallback(
|
||||||
async (job: RecordJob) => {
|
async (job: RecordJob) => {
|
||||||
const out = job.output || ''
|
const out = job.output || ''
|
||||||
const oldFile = baseName(out)
|
const oldFile = baseName(out)
|
||||||
@ -1118,7 +1118,7 @@ export default function ModelDetails({
|
|||||||
[onToggleHot, refetchDone]
|
[onToggleHot, refetchDone]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleScrubberClickIndex = React.useCallback(
|
const handleScrubberClickIndex = useCallback(
|
||||||
(job: RecordJob, segmentIndex: number, _segmentCount: number) => {
|
(job: RecordJob, segmentIndex: number, _segmentCount: number) => {
|
||||||
const sprite = readPreviewSpriteInfo((job as any)?.meta)
|
const sprite = readPreviewSpriteInfo((job as any)?.meta)
|
||||||
const step = sprite.stepSeconds > 0 ? sprite.stepSeconds : 5
|
const step = sprite.stepSeconds > 0 ? sprite.stepSeconds : 5
|
||||||
@ -1128,7 +1128,7 @@ export default function ModelDetails({
|
|||||||
[onOpenPlayer]
|
[onOpenPlayer]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleToggleFavoriteModel = React.useCallback(async () => {
|
const handleToggleFavoriteModel = useCallback(async () => {
|
||||||
if (!key) return
|
if (!key) return
|
||||||
|
|
||||||
// ✅ UI sofort (optimistisch)
|
// ✅ UI sofort (optimistisch)
|
||||||
@ -1147,7 +1147,7 @@ export default function ModelDetails({
|
|||||||
refetchModels()
|
refetchModels()
|
||||||
}, [key, onToggleFavorite, refetchModels])
|
}, [key, onToggleFavorite, refetchModels])
|
||||||
|
|
||||||
const handleToggleLikeModel = React.useCallback(async () => {
|
const handleToggleLikeModel = useCallback(async () => {
|
||||||
if (!key) return
|
if (!key) return
|
||||||
|
|
||||||
// liked ist bei dir boolean | null -> wir togglen "true <-> false"
|
// liked ist bei dir boolean | null -> wir togglen "true <-> false"
|
||||||
@ -1163,7 +1163,7 @@ export default function ModelDetails({
|
|||||||
refetchModels()
|
refetchModels()
|
||||||
}, [key, onToggleLike, refetchModels])
|
}, [key, onToggleLike, refetchModels])
|
||||||
|
|
||||||
const handleToggleWatchModel = React.useCallback(async () => {
|
const handleToggleWatchModel = useCallback(async () => {
|
||||||
if (!key) return
|
if (!key) return
|
||||||
|
|
||||||
// ✅ UI sofort (optimistisch)
|
// ✅ UI sofort (optimistisch)
|
||||||
@ -1182,7 +1182,7 @@ export default function ModelDetails({
|
|||||||
refetchModels()
|
refetchModels()
|
||||||
}, [key, onToggleWatch, refetchModels])
|
}, [key, onToggleWatch, refetchModels])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setHoverTeaserKey(null)
|
setHoverTeaserKey(null)
|
||||||
setTeaserKey(null)
|
setTeaserKey(null)
|
||||||
@ -1193,7 +1193,7 @@ export default function ModelDetails({
|
|||||||
}
|
}
|
||||||
}, [open, key])
|
}, [open, key])
|
||||||
|
|
||||||
const handleDuration = React.useCallback(
|
const handleDuration = useCallback(
|
||||||
(job: RecordJob, seconds: number) => {
|
(job: RecordJob, seconds: number) => {
|
||||||
const k = keyFor(job)
|
const k = keyFor(job)
|
||||||
setDurations((prev) => (prev[k] === seconds ? prev : { ...prev, [k]: seconds }))
|
setDurations((prev) => (prev[k] === seconds ? prev : { ...prev, [k]: seconds }))
|
||||||
@ -1201,7 +1201,7 @@ export default function ModelDetails({
|
|||||||
[keyFor]
|
[keyFor]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleHoverPreviewKeyChange = React.useCallback(
|
const handleHoverPreviewKeyChange = useCallback(
|
||||||
(k: string | null) => {
|
(k: string | null) => {
|
||||||
setHoverTeaserKey(k)
|
setHoverTeaserKey(k)
|
||||||
if (teaserPlayback === 'hover') setTeaserKey(k)
|
if (teaserPlayback === 'hover') setTeaserKey(k)
|
||||||
@ -1209,7 +1209,7 @@ export default function ModelDetails({
|
|||||||
[teaserPlayback]
|
[teaserPlayback]
|
||||||
)
|
)
|
||||||
|
|
||||||
const runtimeOf = React.useCallback(
|
const runtimeOf = useCallback(
|
||||||
(job: RecordJob) => {
|
(job: RecordJob) => {
|
||||||
const k = keyFor(job)
|
const k = keyFor(job)
|
||||||
const raw =
|
const raw =
|
||||||
@ -1229,7 +1229,7 @@ export default function ModelDetails({
|
|||||||
[durations, keyFor]
|
[durations, keyFor]
|
||||||
)
|
)
|
||||||
|
|
||||||
const sizeBytesOf = React.useCallback((job: RecordJob) => {
|
const sizeBytesOf = useCallback((job: RecordJob) => {
|
||||||
const v =
|
const v =
|
||||||
(job as any)?.sizeBytes ??
|
(job as any)?.sizeBytes ??
|
||||||
(job as any)?.size ??
|
(job as any)?.size ??
|
||||||
@ -1238,9 +1238,9 @@ export default function ModelDetails({
|
|||||||
return typeof v === 'number' && Number.isFinite(v) ? v : null
|
return typeof v === 'number' && Number.isFinite(v) ? v : null
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const formatBytes = React.useCallback((bytes?: number | null) => fmtBytes(bytes ?? null), [])
|
const formatBytes = useCallback((bytes?: number | null) => fmtBytes(bytes ?? null), [])
|
||||||
|
|
||||||
const setScrubIndexForKey = React.useCallback((key: string, index: number | undefined) => {
|
const setScrubIndexForKey = useCallback((key: string, index: number | undefined) => {
|
||||||
setScrubIndexByKey((prev) => {
|
setScrubIndexByKey((prev) => {
|
||||||
if (index === undefined) {
|
if (index === undefined) {
|
||||||
if (!(key in prev)) return prev
|
if (!(key in prev)) return prev
|
||||||
@ -1253,12 +1253,12 @@ export default function ModelDetails({
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const clearScrubIndex = React.useCallback(
|
const clearScrubIndex = useCallback(
|
||||||
(key: string) => setScrubIndexForKey(key, undefined),
|
(key: string) => setScrubIndexForKey(key, undefined),
|
||||||
[setScrubIndexForKey]
|
[setScrubIndexForKey]
|
||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
setTab('info')
|
setTab('info')
|
||||||
}, [open, key])
|
}, [open, key])
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import { type ComponentType, type ReactNode } from 'react'
|
||||||
|
|
||||||
function cn(...parts: Array<string | false | null | undefined>) {
|
function cn(...parts: Array<string | false | null | undefined>) {
|
||||||
return parts.filter(Boolean).join(' ')
|
return parts.filter(Boolean).join(' ')
|
||||||
@ -59,7 +59,7 @@ function BaseIcon({
|
|||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@ -140,7 +140,7 @@ export default function ModelGenderIcon({
|
|||||||
|
|
||||||
if (!kind) return null
|
if (!kind) return null
|
||||||
|
|
||||||
const map: Record<Exclude<GenderKind, null>, { label: string; Icon: React.ComponentType<IconProps> }> = {
|
const map: Record<Exclude<GenderKind, null>, { label: string; Icon: ComponentType<IconProps> }> = {
|
||||||
female: { label: 'Female', Icon: FemaleIcon },
|
female: { label: 'Female', Icon: FemaleIcon },
|
||||||
male: { label: 'Male', Icon: MaleIcon },
|
male: { label: 'Male', Icon: MaleIcon },
|
||||||
trans: { label: 'Trans', Icon: TransIcon },
|
trans: { label: 'Trans', Icon: TransIcon },
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
SpeakerWaveIcon,
|
SpeakerWaveIcon,
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
import LoadingSpinner from './LoadingSpinner'
|
import LoadingSpinner from './LoadingSpinner'
|
||||||
|
import { apiUrl, apiFetch } from '../../lib/api'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
jobId: string
|
jobId: string
|
||||||
@ -53,7 +54,12 @@ export default function ModelPreview({
|
|||||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||||
const [inView, setInView] = useState(false)
|
const [inView, setInView] = useState(false)
|
||||||
const [imgError, setImgError] = useState(false)
|
const [imgError, setImgError] = useState(false)
|
||||||
const [imgSrc, setImgSrc] = useState('')
|
const fadeTimerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const [displaySrc, setDisplaySrc] = useState('')
|
||||||
|
const [pendingDisplaySrc, setPendingDisplaySrc] = useState('')
|
||||||
|
const [incomingSrc, setIncomingSrc] = useState('')
|
||||||
|
const [showIncoming, setShowIncoming] = useState(false)
|
||||||
const [liveSrc, setLiveSrc] = useState('')
|
const [liveSrc, setLiveSrc] = useState('')
|
||||||
const [hoverOpen, setHoverOpen] = useState(false)
|
const [hoverOpen, setHoverOpen] = useState(false)
|
||||||
|
|
||||||
@ -121,6 +127,12 @@ export default function ModelPreview({
|
|||||||
: `${base}?v=${encodeURIComponent(version)}`
|
: `${base}?v=${encodeURIComponent(version)}`
|
||||||
}, [fallbackSrc, jobId, refreshKey, localRefreshNonce])
|
}, [fallbackSrc, jobId, refreshKey, localRefreshNonce])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (fadeTimerRef.current) window.clearTimeout(fadeTimerRef.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!inView) return
|
if (!inView) return
|
||||||
|
|
||||||
@ -148,26 +160,31 @@ export default function ModelPreview({
|
|||||||
const cookieHeader = buildChaturbateCookieHeader()
|
const cookieHeader = buildChaturbateCookieHeader()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const prepareRes = await fetch(
|
const prepareUrl = apiUrl(
|
||||||
`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&prepare=1`,
|
`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&prepare=1`
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
cache: 'no-store',
|
|
||||||
signal: ac.signal,
|
|
||||||
headers: cookieHeader
|
|
||||||
? { 'X-Chaturbate-Cookie': cookieHeader }
|
|
||||||
: undefined,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const prepareRes = await apiFetch(prepareUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: ac.signal,
|
||||||
|
headers: cookieHeader
|
||||||
|
? { 'X-Chaturbate-Cookie': cookieHeader }
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
if (ac.signal.aborted) return
|
if (ac.signal.aborted) return
|
||||||
await prepareRes.json().catch(() => null)
|
await prepareRes.json().catch(() => null)
|
||||||
if (ac.signal.aborted) return
|
if (ac.signal.aborted) return
|
||||||
|
|
||||||
setLiveSrc(`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&ts=${Date.now()}`)
|
setLiveSrc(
|
||||||
|
apiUrl(`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&ts=${Date.now()}`)
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
if (ac.signal.aborted) return
|
if (ac.signal.aborted) return
|
||||||
setLiveSrc(`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&ts=${Date.now()}`)
|
setLiveSrc(
|
||||||
|
apiUrl(`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&ts=${Date.now()}`)
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
if (!ac.signal.aborted) setLivePreparing(false)
|
if (!ac.signal.aborted) setLivePreparing(false)
|
||||||
}
|
}
|
||||||
@ -197,21 +214,113 @@ export default function ModelPreview({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (fadeTimerRef.current) window.clearTimeout(fadeTimerRef.current)
|
||||||
|
|
||||||
setImgError(false)
|
setImgError(false)
|
||||||
setUsingFallback(false)
|
setUsingFallback(false)
|
||||||
setImgSrc(previewSrc)
|
setPendingDisplaySrc('')
|
||||||
|
setDisplaySrc('')
|
||||||
|
setIncomingSrc('')
|
||||||
|
setShowIncoming(false)
|
||||||
|
|
||||||
setLiveSrc('')
|
setLiveSrc('')
|
||||||
setLivePreparing(false)
|
setLivePreparing(false)
|
||||||
setLiveReady(false)
|
setLiveReady(false)
|
||||||
}, [jobId, previewSrc])
|
}, [jobId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!inView) return
|
||||||
|
if (!previewSrc) return
|
||||||
|
|
||||||
|
if (!displaySrc) {
|
||||||
|
setDisplaySrc(previewSrc)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previewSrc === displaySrc || previewSrc === incomingSrc) return
|
||||||
|
|
||||||
|
setIncomingSrc(previewSrc)
|
||||||
|
setShowIncoming(false)
|
||||||
|
}, [inView, previewSrc, displaySrc, incomingSrc])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inView) return
|
||||||
if (!usingFallback) return
|
if (!usingFallback) return
|
||||||
if (!fallbackImgSrc) return
|
if (!fallbackImgSrc) return
|
||||||
|
if (!displaySrc) return
|
||||||
|
|
||||||
|
if (fallbackImgSrc === displaySrc || fallbackImgSrc === incomingSrc) return
|
||||||
|
|
||||||
|
setIncomingSrc(fallbackImgSrc)
|
||||||
|
setShowIncoming(false)
|
||||||
|
}, [inView, usingFallback, fallbackImgSrc, displaySrc, incomingSrc])
|
||||||
|
|
||||||
|
const handleDisplayLoad = useCallback(() => {
|
||||||
setImgError(false)
|
setImgError(false)
|
||||||
setImgSrc(fallbackImgSrc)
|
|
||||||
}, [usingFallback, fallbackImgSrc])
|
const current = String(displaySrc || '').trim()
|
||||||
|
const fallback = String(fallbackImgSrc || '').trim()
|
||||||
|
|
||||||
|
setUsingFallback(Boolean(fallback && current === fallback))
|
||||||
|
|
||||||
|
if (pendingDisplaySrc && current === pendingDisplaySrc) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setIncomingSrc('')
|
||||||
|
setShowIncoming(false)
|
||||||
|
setPendingDisplaySrc('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [displaySrc, fallbackImgSrc, pendingDisplaySrc])
|
||||||
|
|
||||||
|
const handleDisplayError = useCallback(() => {
|
||||||
|
const current = String(displaySrc || '').trim()
|
||||||
|
const fallback = String(fallbackImgSrc || '').trim()
|
||||||
|
|
||||||
|
if (fallback && current !== fallback) {
|
||||||
|
setUsingFallback(true)
|
||||||
|
setImgError(false)
|
||||||
|
setDisplaySrc(fallback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setImgError(true)
|
||||||
|
}, [displaySrc, fallbackImgSrc])
|
||||||
|
|
||||||
|
const handleIncomingLoad = useCallback(() => {
|
||||||
|
const next = String(incomingSrc || '').trim()
|
||||||
|
if (!next) return
|
||||||
|
|
||||||
|
if (fadeTimerRef.current) window.clearTimeout(fadeTimerRef.current)
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setShowIncoming(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
fadeTimerRef.current = window.setTimeout(() => {
|
||||||
|
setPendingDisplaySrc(next)
|
||||||
|
setDisplaySrc(next)
|
||||||
|
}, 320)
|
||||||
|
}, [incomingSrc])
|
||||||
|
|
||||||
|
const handleIncomingError = useCallback(() => {
|
||||||
|
const next = String(incomingSrc || '').trim()
|
||||||
|
const fallback = String(fallbackImgSrc || '').trim()
|
||||||
|
|
||||||
|
if (fallback && next !== fallback) {
|
||||||
|
setIncomingSrc(fallback)
|
||||||
|
setShowIncoming(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIncomingSrc('')
|
||||||
|
setShowIncoming(false)
|
||||||
|
|
||||||
|
if (!displaySrc) {
|
||||||
|
setImgError(true)
|
||||||
|
}
|
||||||
|
}, [incomingSrc, fallbackImgSrc, displaySrc])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HoverPopover
|
<HoverPopover
|
||||||
@ -319,34 +428,48 @@ export default function ModelPreview({
|
|||||||
>
|
>
|
||||||
{inView ? (
|
{inView ? (
|
||||||
!imgError ? (
|
!imgError ? (
|
||||||
<img
|
<div className="relative w-full h-full">
|
||||||
src={imgSrc || previewSrc}
|
{displaySrc ? (
|
||||||
loading="lazy"
|
<img
|
||||||
decoding="async"
|
src={displaySrc}
|
||||||
alt=""
|
loading="lazy"
|
||||||
referrerPolicy="no-referrer"
|
decoding="async"
|
||||||
className={['block w-full h-full', objectFitCls, 'object-center', blurCls].filter(Boolean).join(' ')}
|
alt=""
|
||||||
onLoad={() => {
|
referrerPolicy="no-referrer"
|
||||||
setImgError(false)
|
className={[
|
||||||
|
'block w-full h-full',
|
||||||
|
objectFitCls,
|
||||||
|
'object-center',
|
||||||
|
blurCls,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
onLoad={handleDisplayLoad}
|
||||||
|
onError={handleDisplayError}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
const current = String(imgSrc || '').trim()
|
{incomingSrc ? (
|
||||||
const fallback = String(fallbackImgSrc || '').trim()
|
<img
|
||||||
setUsingFallback(Boolean(fallback && current === fallback))
|
src={incomingSrc}
|
||||||
}}
|
loading="lazy"
|
||||||
onError={() => {
|
decoding="async"
|
||||||
const current = String(imgSrc || '').trim()
|
alt=""
|
||||||
const fallback = String(fallbackImgSrc || '').trim()
|
referrerPolicy="no-referrer"
|
||||||
|
className={[
|
||||||
if (fallback && current !== fallback) {
|
'absolute inset-0 block w-full h-full transition-opacity duration-300 ease-in-out',
|
||||||
setUsingFallback(true)
|
objectFitCls,
|
||||||
setImgError(false)
|
'object-center',
|
||||||
setImgSrc(fallback)
|
blurCls,
|
||||||
return
|
showIncoming ? 'opacity-100' : 'opacity-0',
|
||||||
}
|
]
|
||||||
|
.filter(Boolean)
|
||||||
setImgError(true)
|
.join(' ')}
|
||||||
}}
|
onLoad={handleIncomingLoad}
|
||||||
/>
|
onError={handleIncomingError}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={['block w-full h-full bg-gray-100 dark:bg-white/5', blurCls].filter(Boolean).join(' ')} />
|
<div className={['block w-full h-full bg-gray-100 dark:bg-white/5', blurCls].filter(Boolean).join(' ')} />
|
||||||
)
|
)
|
||||||
|
|||||||
@ -2,7 +2,16 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import {
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useMemo,
|
||||||
|
useCallback,
|
||||||
|
useDeferredValue,
|
||||||
|
type ReactNode,
|
||||||
|
type MouseEvent as ReactMouseEvent,
|
||||||
|
} from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import Card from './Card'
|
import Card from './Card'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
@ -197,8 +206,8 @@ function IconToggle({
|
|||||||
title: string
|
title: string
|
||||||
active?: boolean
|
active?: boolean
|
||||||
hiddenUntilHover?: boolean
|
hiddenUntilHover?: boolean
|
||||||
onClick: (e: React.MouseEvent) => void
|
onClick: (e: ReactMouseEvent<HTMLButtonElement>) => void
|
||||||
icon: React.ReactNode
|
icon: ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@ -286,32 +295,32 @@ type ModelsTabProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
||||||
const [models, setModels] = React.useState<StoredModel[]>([])
|
const [models, setModels] = useState<StoredModel[]>([])
|
||||||
const flagsInFlightRef = React.useRef<Record<string, true>>({})
|
const flagsInFlightRef = useRef<Record<string, true>>({})
|
||||||
|
|
||||||
const [loading, setLoading] = React.useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [err, setErr] = React.useState<string | null>(null)
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
|
||||||
const [q, setQ] = React.useState('')
|
const [q, setQ] = useState('')
|
||||||
const [page, setPage] = React.useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const pageSize = 10
|
const pageSize = 10
|
||||||
|
|
||||||
const [viewMode, setViewMode] = React.useState<ModelsViewMode>('gallery')
|
const [viewMode, setViewMode] = useState<ModelsViewMode>('gallery')
|
||||||
|
|
||||||
const [tagFilter, setTagFilter] = React.useState<string[]>([])
|
const [tagFilter, setTagFilter] = useState<string[]>([])
|
||||||
|
|
||||||
type WatchFilterMode = 'all' | 'watched' | 'unwatched'
|
type WatchFilterMode = 'all' | 'watched' | 'unwatched'
|
||||||
type StatusFilterKey = 'favorite' | 'liked'
|
type StatusFilterKey = 'favorite' | 'liked'
|
||||||
|
|
||||||
const [watchFilter, setWatchFilter] = React.useState<WatchFilterMode>('all')
|
const [watchFilter, setWatchFilter] = useState<WatchFilterMode>('all')
|
||||||
const [statusFilter, setStatusFilter] = React.useState<StatusFilterKey[]>([])
|
const [statusFilter, setStatusFilter] = useState<StatusFilterKey[]>([])
|
||||||
|
|
||||||
const activeStatusSet = React.useMemo(
|
const activeStatusSet = useMemo(
|
||||||
() => new Set(statusFilter),
|
() => new Set(statusFilter),
|
||||||
[statusFilter]
|
[statusFilter]
|
||||||
)
|
)
|
||||||
|
|
||||||
const toggleStatusFilter = React.useCallback((key: StatusFilterKey) => {
|
const toggleStatusFilter = useCallback((key: StatusFilterKey) => {
|
||||||
setStatusFilter((prev) =>
|
setStatusFilter((prev) =>
|
||||||
prev.includes(key)
|
prev.includes(key)
|
||||||
? prev.filter((x) => x !== key)
|
? prev.filter((x) => x !== key)
|
||||||
@ -319,20 +328,20 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
|||||||
)
|
)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const clearStatusFilter = React.useCallback(() => {
|
const clearStatusFilter = useCallback(() => {
|
||||||
setWatchFilter('all')
|
setWatchFilter('all')
|
||||||
setStatusFilter([])
|
setStatusFilter([])
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const [videoCounts, setVideoCounts] = React.useState<Record<string, number>>({})
|
const [videoCounts, setVideoCounts] = useState<Record<string, number>>({})
|
||||||
const [videoCountsLoading, setVideoCountsLoading] = React.useState(false)
|
const [videoCountsLoading, setVideoCountsLoading] = useState(false)
|
||||||
|
|
||||||
const [sort, setSort] = React.useState<{ key: string; direction: 'asc' | 'desc' } | null>({
|
const [sort, setSort] = useState<{ key: string; direction: 'asc' | 'desc' } | null>({
|
||||||
key: 'createdAt',
|
key: 'createdAt',
|
||||||
direction: 'desc',
|
direction: 'desc',
|
||||||
})
|
})
|
||||||
|
|
||||||
const refreshVideoCounts = React.useCallback(async () => {
|
const refreshVideoCounts = useCallback(async () => {
|
||||||
setVideoCountsLoading(true)
|
setVideoCountsLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/record/done?all=1&includeKeep=1', { cache: 'no-store' as any })
|
const res = await fetch('/api/record/done?all=1&includeKeep=1', { cache: 'no-store' as any })
|
||||||
@ -361,9 +370,9 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const activeTagSet = React.useMemo(() => new Set(tagFilter.map(lowerTag)), [tagFilter])
|
const activeTagSet = useMemo(() => new Set(tagFilter.map(lowerTag)), [tagFilter])
|
||||||
|
|
||||||
const allKnownTags = React.useMemo(() => {
|
const allKnownTags = useMemo(() => {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const out: string[] = []
|
const out: string[] = []
|
||||||
|
|
||||||
@ -380,7 +389,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
|||||||
return out
|
return out
|
||||||
}, [models])
|
}, [models])
|
||||||
|
|
||||||
const toggleTagFilter = React.useCallback((tag: string) => {
|
const toggleTagFilter = useCallback((tag: string) => {
|
||||||
const k = tag.toLowerCase()
|
const k = tag.toLowerCase()
|
||||||
setTagFilter((prev) => {
|
setTagFilter((prev) => {
|
||||||
const has = prev.some((t) => t.toLowerCase() === k)
|
const has = prev.some((t) => t.toLowerCase() === k)
|
||||||
@ -388,9 +397,9 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const clearTagFilter = React.useCallback(() => setTagFilter([]), [])
|
const clearTagFilter = useCallback(() => setTagFilter([]), [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
const onSet = (ev: Event) => {
|
const onSet = (ev: Event) => {
|
||||||
const e = ev as CustomEvent<{ tags?: string[] }>
|
const e = ev as CustomEvent<{ tags?: string[] }>
|
||||||
const tags = Array.isArray(e.detail?.tags) ? e.detail.tags : []
|
const tags = Array.isArray(e.detail?.tags) ? e.detail.tags : []
|
||||||
@ -401,7 +410,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
|||||||
return () => window.removeEventListener('models:set-tag-filter', onSet as any)
|
return () => window.removeEventListener('models:set-tag-filter', onSet as any)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem('models_pendingTags')
|
const raw = localStorage.getItem('models_pendingTags')
|
||||||
if (!raw) return
|
if (!raw) return
|
||||||
@ -413,7 +422,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('models_viewMode')
|
const saved = localStorage.getItem('models_viewMode')
|
||||||
if (saved === 'table' || saved === 'gallery') setViewMode(saved)
|
if (saved === 'table' || saved === 'gallery') setViewMode(saved)
|
||||||
@ -422,7 +431,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('models_viewMode', viewMode)
|
localStorage.setItem('models_viewMode', viewMode)
|
||||||
} catch {
|
} catch {
|
||||||
@ -430,18 +439,18 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
|||||||
}
|
}
|
||||||
}, [viewMode])
|
}, [viewMode])
|
||||||
|
|
||||||
const [input, setInput] = React.useState('')
|
const [input, setInput] = useState('')
|
||||||
const [parsed, setParsed] = React.useState<ParsedModel | null>(null)
|
const [parsed, setParsed] = useState<ParsedModel | null>(null)
|
||||||
const [parseError, setParseError] = React.useState<string | null>(null)
|
const [parseError, setParseError] = useState<string | null>(null)
|
||||||
const [adding, setAdding] = React.useState(false)
|
const [adding, setAdding] = useState(false)
|
||||||
|
|
||||||
const [importOpen, setImportOpen] = React.useState(false)
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
const [importFile, setImportFile] = React.useState<File | null>(null)
|
const [importFile, setImportFile] = useState<File | null>(null)
|
||||||
const [importMsg, setImportMsg] = React.useState<string | null>(null)
|
const [importMsg, setImportMsg] = useState<string | null>(null)
|
||||||
|
|
||||||
const [importKind, setImportKind] = React.useState<ImportKind>('favorite')
|
const [importKind, setImportKind] = useState<ImportKind>('favorite')
|
||||||
const [importing, setImporting] = React.useState(false)
|
const [importing, setImporting] = useState(false)
|
||||||
const [importErr, setImportErr] = React.useState<string | null>(null)
|
const [importErr, setImportErr] = useState<string | null>(null)
|
||||||
|
|
||||||
async function doImport() {
|
async function doImport() {
|
||||||
if (!importFile) return
|
if (!importFile) return
|
||||||
@ -484,7 +493,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
|||||||
setImportOpen(true)
|
setImportOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const refresh = React.useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setErr(null)
|
setErr(null)
|
||||||
try {
|
try {
|
||||||
@ -509,15 +518,15 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
|||||||
}
|
}
|
||||||
}, [refreshVideoCounts])
|
}, [refreshVideoCounts])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshVideoCounts()
|
void refreshVideoCounts()
|
||||||
}, [refreshVideoCounts])
|
}, [refreshVideoCounts])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh()
|
void refresh()
|
||||||
}, [refresh])
|
}, [refresh])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
const onChanged = (ev: Event) => {
|
const onChanged = (ev: Event) => {
|
||||||
const e = ev as CustomEvent<any>
|
const e = ev as CustomEvent<any>
|
||||||
const detail = e?.detail ?? {}
|
const detail = e?.detail ?? {}
|
||||||
@ -547,7 +556,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
|||||||
return () => window.removeEventListener('models-changed', onChanged as any)
|
return () => window.removeEventListener('models-changed', onChanged as any)
|
||||||
}, [refresh])
|
}, [refresh])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
const onDbChanged = () => {
|
const onDbChanged = () => {
|
||||||
setPage(1)
|
setPage(1)
|
||||||
setModels([])
|
setModels([])
|
||||||
@ -558,7 +567,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
|||||||
return () => window.removeEventListener('models-db-changed', onDbChanged as any)
|
return () => window.removeEventListener('models-db-changed', onDbChanged as any)
|
||||||
}, [refresh])
|
}, [refresh])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
const raw = input.trim()
|
const raw = input.trim()
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
setParsed(null)
|
setParsed(null)
|
||||||
@ -598,14 +607,14 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
|||||||
return () => window.clearTimeout(t)
|
return () => window.clearTimeout(t)
|
||||||
}, [input])
|
}, [input])
|
||||||
|
|
||||||
const modelsWithHay = React.useMemo(() => {
|
const modelsWithHay = useMemo(() => {
|
||||||
return models.map((m) => ({
|
return models.map((m) => ({
|
||||||
m,
|
m,
|
||||||
hay: `${m.modelKey} ${m.host ?? ''} ${m.input ?? ''} ${m.tags ?? ''}`.toLowerCase(),
|
hay: `${m.modelKey} ${m.host ?? ''} ${m.input ?? ''} ${m.tags ?? ''}`.toLowerCase(),
|
||||||
}))
|
}))
|
||||||
}, [models])
|
}, [models])
|
||||||
|
|
||||||
const deferredQ = React.useDeferredValue(q)
|
const deferredQ = useDeferredValue(q)
|
||||||
|
|
||||||
async function patch(id: string, body: any) {
|
async function patch(id: string, body: any) {
|
||||||
setErr(null)
|
setErr(null)
|
||||||
@ -674,7 +683,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = React.useMemo<Column<StoredModel>[]>(() => {
|
const columns = useMemo<Column<StoredModel>[]>(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: 'statusAll',
|
key: 'statusAll',
|
||||||
@ -936,7 +945,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
|||||||
]
|
]
|
||||||
}, [activeTagSet, toggleTagFilter, videoCounts, videoCountsLoading])
|
}, [activeTagSet, toggleTagFilter, videoCounts, videoCountsLoading])
|
||||||
|
|
||||||
const filtered = React.useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const needle = deferredQ.trim().toLowerCase()
|
const needle = deferredQ.trim().toLowerCase()
|
||||||
|
|
||||||
let base = !needle
|
let base = !needle
|
||||||
@ -976,7 +985,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
|||||||
})
|
})
|
||||||
}, [models, modelsWithHay, deferredQ, watchFilter, activeStatusSet, activeTagSet])
|
}, [models, modelsWithHay, deferredQ, watchFilter, activeStatusSet, activeTagSet])
|
||||||
|
|
||||||
const sortedAll = React.useMemo(() => {
|
const sortedAll = useMemo(() => {
|
||||||
if (!sort) return filtered
|
if (!sort) return filtered
|
||||||
|
|
||||||
const col = columns.find((c) => c.key === sort.key)
|
const col = columns.find((c) => c.key === sort.key)
|
||||||
@ -1010,18 +1019,18 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
|||||||
return decorated.map((d) => d.r)
|
return decorated.map((d) => d.r)
|
||||||
}, [filtered, sort, columns])
|
}, [filtered, sort, columns])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}, [q, tagFilter, watchFilter, statusFilter, viewMode, sort])
|
}, [q, tagFilter, watchFilter, statusFilter, viewMode, sort])
|
||||||
|
|
||||||
const totalItems = sortedAll.length
|
const totalItems = sortedAll.length
|
||||||
const totalPages = React.useMemo(() => Math.max(1, Math.ceil(totalItems / pageSize)), [totalItems, pageSize])
|
const totalPages = useMemo(() => Math.max(1, Math.ceil(totalItems / pageSize)), [totalItems, pageSize])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (page > totalPages) setPage(totalPages)
|
if (page > totalPages) setPage(totalPages)
|
||||||
}, [page, totalPages])
|
}, [page, totalPages])
|
||||||
|
|
||||||
const pageRows = React.useMemo(() => {
|
const pageRows = useMemo(() => {
|
||||||
const start = (page - 1) * pageSize
|
const start = (page - 1) * pageSize
|
||||||
return sortedAll.slice(start, start + pageSize)
|
return sortedAll.slice(start, start + pageSize)
|
||||||
}, [sortedAll, page, pageSize])
|
}, [sortedAll, page, pageSize])
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import { type ReactNode } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import {
|
import {
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
@ -42,7 +42,7 @@ function PageButton({
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
rounded?: 'l' | 'r' | 'none'
|
rounded?: 'l' | 'r' | 'none'
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
children: React.ReactNode
|
children: ReactNode
|
||||||
title?: string
|
title?: string
|
||||||
}) {
|
}) {
|
||||||
const roundedCls =
|
const roundedCls =
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
// frontend\src\components\ui\PerformanceMonitor.tsx
|
// frontend\src\components\ui\PerformanceMonitor.tsx
|
||||||
|
|
||||||
import React from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { subscribeSSE } from '../../lib/sseSingleton'
|
import { subscribeSSE } from '../../lib/sseSingleton'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -64,9 +64,9 @@ function formatBytesCompact(bytes: number | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useFps(sampleMs = 1000) {
|
function useFps(sampleMs = 1000) {
|
||||||
const [fps, setFps] = React.useState<number | null>(null)
|
const [fps, setFps] = useState<number | null>(null)
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
let raf = 0
|
let raf = 0
|
||||||
let last = performance.now()
|
let last = performance.now()
|
||||||
let frames = 0
|
let frames = 0
|
||||||
@ -129,18 +129,18 @@ export default function PerformanceMonitor({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const fps = useFps(1000)
|
const fps = useFps(1000)
|
||||||
|
|
||||||
const [ping, setPing] = React.useState<number | null>(null)
|
const [ping, setPing] = useState<number | null>(null)
|
||||||
const [cpu, setCpu] = React.useState<number | null>(null)
|
const [cpu, setCpu] = useState<number | null>(null)
|
||||||
const [diskFreeBytes, setDiskFreeBytes] = React.useState<number | null>(null)
|
const [diskFreeBytes, setDiskFreeBytes] = useState<number | null>(null)
|
||||||
const [diskTotalBytes, setDiskTotalBytes] = React.useState<number | null>(null)
|
const [diskTotalBytes, setDiskTotalBytes] = useState<number | null>(null)
|
||||||
const [diskUsedPercent, setDiskUsedPercent] = React.useState<number | null>(null)
|
const [diskUsedPercent, setDiskUsedPercent] = useState<number | null>(null)
|
||||||
|
|
||||||
const LOW_FREE_BYTES = 5 * 1024 * 1024 * 1024 // 5 GB
|
const LOW_FREE_BYTES = 5 * 1024 * 1024 * 1024 // 5 GB
|
||||||
const RESET_BYTES = 8 * 1024 * 1024 * 1024 // Hysterese (8 GB)
|
const RESET_BYTES = 8 * 1024 * 1024 * 1024 // Hysterese (8 GB)
|
||||||
const emergencyRef = React.useRef(false)
|
const emergencyRef = useRef(false)
|
||||||
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
const url = `/api/perf/stream?ms=${encodeURIComponent(String(pollMs))}`
|
const url = `/api/perf/stream?ms=${encodeURIComponent(String(pollMs))}`
|
||||||
|
|
||||||
const unsub = subscribeSSE<any>(url, 'perf', (data) => {
|
const unsub = subscribeSSE<any>(url, 'perf', (data) => {
|
||||||
|
|||||||
@ -1,7 +1,15 @@
|
|||||||
// frontend\src\components\ui\Player.tsx
|
// frontend\src\components\ui\Player.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import {
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useMemo,
|
||||||
|
useCallback,
|
||||||
|
useLayoutEffect,
|
||||||
|
type PointerEvent as ReactPointerEvent,
|
||||||
|
} from 'react'
|
||||||
import type { RecordJob } from '../../types'
|
import type { RecordJob } from '../../types'
|
||||||
import Card from './Card'
|
import Card from './Card'
|
||||||
import videojs from 'video.js'
|
import videojs from 'video.js'
|
||||||
@ -167,9 +175,9 @@ function cn(...parts: Array<string | false | null | undefined>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useMediaQuery(query: string) {
|
function useMediaQuery(query: string) {
|
||||||
const [matches, setMatches] = React.useState(false)
|
const [matches, setMatches] = useState(false)
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
const mql = window.matchMedia(query)
|
const mql = window.matchMedia(query)
|
||||||
const onChange = () => setMatches(mql.matches)
|
const onChange = () => setMatches(mql.matches)
|
||||||
@ -298,17 +306,17 @@ export default function Player({
|
|||||||
startMuted = DEFAULT_PLAYER_START_MUTED,
|
startMuted = DEFAULT_PLAYER_START_MUTED,
|
||||||
startAtSec = 0
|
startAtSec = 0
|
||||||
}: PlayerProps) {
|
}: PlayerProps) {
|
||||||
const title = React.useMemo(
|
const title = useMemo(
|
||||||
() => baseName(job.output?.trim() || '') || job.id,
|
() => baseName(job.output?.trim() || '') || job.id,
|
||||||
[job.output, job.id]
|
[job.output, job.id]
|
||||||
)
|
)
|
||||||
|
|
||||||
const fileRaw = React.useMemo(() => baseName(job.output?.trim() || ''), [job.output])
|
const fileRaw = useMemo(() => baseName(job.output?.trim() || ''), [job.output])
|
||||||
const playName = React.useMemo(() => baseName(job.output?.trim() || ''), [job.output])
|
const playName = useMemo(() => baseName(job.output?.trim() || ''), [job.output])
|
||||||
const sizeLabel = React.useMemo(() => formatBytes(sizeBytesOf(job)), [job])
|
const sizeLabel = useMemo(() => formatBytes(sizeBytesOf(job)), [job])
|
||||||
const anyJob = job as any
|
const anyJob = job as any
|
||||||
|
|
||||||
const [fullDurationSec, setFullDurationSec] = React.useState<number>(() => {
|
const [fullDurationSec, setFullDurationSec] = useState<number>(() => {
|
||||||
return (
|
return (
|
||||||
Number((job as any)?.meta?.durationSeconds) ||
|
Number((job as any)?.meta?.durationSeconds) ||
|
||||||
Number((job as any)?.durationSeconds) ||
|
Number((job as any)?.durationSeconds) ||
|
||||||
@ -316,12 +324,12 @@ export default function Player({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const [metaReady, setMetaReady] = React.useState<boolean>(() => {
|
const [metaReady, setMetaReady] = useState<boolean>(() => {
|
||||||
// live ist egal, finished: erst mal false (wir holen gleich)
|
// live ist egal, finished: erst mal false (wir holen gleich)
|
||||||
return job.status === 'running'
|
return job.status === 'running'
|
||||||
})
|
})
|
||||||
|
|
||||||
const [metaDims, setMetaDims] = React.useState<{ h: number; fps: number | null }>(() => ({
|
const [metaDims, setMetaDims] = useState<{ h: number; fps: number | null }>(() => ({
|
||||||
h: 0,
|
h: 0,
|
||||||
fps: null,
|
fps: null,
|
||||||
}))
|
}))
|
||||||
@ -329,23 +337,23 @@ export default function Player({
|
|||||||
// ✅ Live nur, wenn es wirklich Preview/HLS-Assets gibt (nicht nur status==="running")
|
// ✅ Live nur, wenn es wirklich Preview/HLS-Assets gibt (nicht nur status==="running")
|
||||||
const isLive = job.status === 'running'
|
const isLive = job.status === 'running'
|
||||||
|
|
||||||
const [liveMuted, setLiveMuted] = React.useState(startMuted)
|
const [liveMuted, setLiveMuted] = useState(startMuted)
|
||||||
const [liveVolume, setLiveVolume] = React.useState(startMuted ? 0 : 1)
|
const [liveVolume, setLiveVolume] = useState(startMuted ? 0 : 1)
|
||||||
const [livePreparing, setLivePreparing] = React.useState(false)
|
const [livePreparing, setLivePreparing] = useState(false)
|
||||||
const [livePreparedSrc, setLivePreparedSrc] = React.useState('')
|
const [livePreparedSrc, setLivePreparedSrc] = useState('')
|
||||||
const [liveReady, setLiveReady] = React.useState(false)
|
const [liveReady, setLiveReady] = useState(false)
|
||||||
|
|
||||||
// ✅ Backend erwartet "id=" (nicht "name=")
|
// ✅ Backend erwartet "id=" (nicht "name=")
|
||||||
// running: echte job.id (jobs-map lookup)
|
// running: echte job.id (jobs-map lookup)
|
||||||
// finished: Dateiname ohne Extension als Stem (wenn dein Backend finished so mapped)
|
// finished: Dateiname ohne Extension als Stem (wenn dein Backend finished so mapped)
|
||||||
const finishedStem = React.useMemo(() => (playName || '').replace(/\.[^.]+$/, ''), [playName])
|
const finishedStem = useMemo(() => (playName || '').replace(/\.[^.]+$/, ''), [playName])
|
||||||
|
|
||||||
const previewId = React.useMemo(
|
const previewId = useMemo(
|
||||||
() => (isLive ? job.id : finishedStem || job.id),
|
() => (isLive ? job.id : finishedStem || job.id),
|
||||||
[isLive, job.id, finishedStem]
|
[isLive, job.id, finishedStem]
|
||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLive) return
|
if (isLive) return
|
||||||
if (fullDurationSec > 0) return
|
if (fullDurationSec > 0) return
|
||||||
|
|
||||||
@ -388,19 +396,19 @@ export default function Player({
|
|||||||
}, [isLive, fullDurationSec, job.output])
|
}, [isLive, fullDurationSec, job.output])
|
||||||
|
|
||||||
const isHotFile = fileRaw.startsWith('HOT ')
|
const isHotFile = fileRaw.startsWith('HOT ')
|
||||||
const model = React.useMemo(() => {
|
const model = useMemo(() => {
|
||||||
const k = (modelKey || '').trim()
|
const k = (modelKey || '').trim()
|
||||||
return k ? k : modelNameFromOutput(job.output)
|
return k ? k : modelNameFromOutput(job.output)
|
||||||
}, [modelKey, job.output])
|
}, [modelKey, job.output])
|
||||||
const file = React.useMemo(() => stripHotPrefix(fileRaw), [fileRaw])
|
const file = useMemo(() => stripHotPrefix(fileRaw), [fileRaw])
|
||||||
|
|
||||||
const runtimeLabel = React.useMemo(() => {
|
const runtimeLabel = useMemo(() => {
|
||||||
const sec = Number(fullDurationSec || 0) || 0
|
const sec = Number(fullDurationSec || 0) || 0
|
||||||
return sec > 0 ? formatDuration(sec * 1000) : '—'
|
return sec > 0 ? formatDuration(sec * 1000) : '—'
|
||||||
}, [fullDurationSec])
|
}, [fullDurationSec])
|
||||||
|
|
||||||
// Datum: bevorzugt aus Dateiname, sonst startedAt/endedAt/createdAt — ✅ inkl. Uhrzeit
|
// Datum: bevorzugt aus Dateiname, sonst startedAt/endedAt/createdAt — ✅ inkl. Uhrzeit
|
||||||
const dateLabel = React.useMemo(() => {
|
const dateLabel = useMemo(() => {
|
||||||
const fromName = parseDateFromOutput(job.output)
|
const fromName = parseDateFromOutput(job.output)
|
||||||
if (fromName) return formatDateTime(fromName)
|
if (fromName) return formatDateTime(fromName)
|
||||||
|
|
||||||
@ -417,28 +425,28 @@ export default function Player({
|
|||||||
}, [job.output, anyJob.startedAt, anyJob.endedAt, anyJob.createdAt, anyJob.fileCreatedAt, anyJob.ctime])
|
}, [job.output, anyJob.startedAt, anyJob.endedAt, anyJob.createdAt, anyJob.fileCreatedAt, anyJob.ctime])
|
||||||
|
|
||||||
// ✅ Tags wie in FinishedDownloads: aus ModelStore (modelsByKey)
|
// ✅ Tags wie in FinishedDownloads: aus ModelStore (modelsByKey)
|
||||||
const effectiveModelKey = React.useMemo(() => lower((modelKey || model || '').trim()), [modelKey, model])
|
const effectiveModelKey = useMemo(() => lower((modelKey || model || '').trim()), [modelKey, model])
|
||||||
|
|
||||||
const tags = React.useMemo(() => {
|
const tags = useMemo(() => {
|
||||||
const flags = modelsByKey?.[effectiveModelKey]
|
const flags = modelsByKey?.[effectiveModelKey]
|
||||||
return parseTags(flags?.tags)
|
return parseTags(flags?.tags)
|
||||||
}, [modelsByKey, effectiveModelKey])
|
}, [modelsByKey, effectiveModelKey])
|
||||||
|
|
||||||
// Vorschaubild oben
|
// Vorschaubild oben
|
||||||
const previewA = React.useMemo(
|
const previewA = useMemo(
|
||||||
() => apiUrl(`/api/preview?id=${encodeURIComponent(previewId)}&file=preview.jpg`),
|
() => apiUrl(`/api/preview?id=${encodeURIComponent(previewId)}&file=preview.jpg`),
|
||||||
[previewId]
|
[previewId]
|
||||||
)
|
)
|
||||||
|
|
||||||
// ✅ Live-Stream URL (Playback) -> play=1 hält Preview sicher am Leben
|
// ✅ Live-Stream URL (Playback) -> play=1 hält Preview sicher am Leben
|
||||||
const liveHlsSrc = React.useMemo(
|
const liveHlsSrc = useMemo(
|
||||||
() => apiUrl(`/api/preview/live?id=${encodeURIComponent(previewId)}&play=1`),
|
() => apiUrl(`/api/preview/live?id=${encodeURIComponent(previewId)}&play=1`),
|
||||||
[previewId]
|
[previewId]
|
||||||
)
|
)
|
||||||
|
|
||||||
const [previewSrc, setPreviewSrc] = React.useState(previewA)
|
const [previewSrc, setPreviewSrc] = useState(previewA)
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
setLivePreparing(false)
|
setLivePreparing(false)
|
||||||
setLivePreparedSrc('')
|
setLivePreparedSrc('')
|
||||||
@ -497,28 +505,28 @@ export default function Player({
|
|||||||
}
|
}
|
||||||
}, [isLive, previewId, liveHlsSrc])
|
}, [isLive, previewId, liveHlsSrc])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
setPreviewSrc(previewA)
|
setPreviewSrc(previewA)
|
||||||
}, [previewA])
|
}, [previewA])
|
||||||
|
|
||||||
const fps = React.useMemo(
|
const fps = useMemo(
|
||||||
() => pickNum(metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate),
|
() => pickNum(metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate),
|
||||||
[metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate]
|
[metaDims.fps, anyJob.fps, anyJob.frameRate, anyJob.meta?.fps, anyJob.meta?.frameRate]
|
||||||
)
|
)
|
||||||
|
|
||||||
const [intrSize, setIntrSize] = React.useState<{ w: number; h: number } | null>(null)
|
const [intrSize, setIntrSize] = useState<{ w: number; h: number } | null>(null)
|
||||||
|
|
||||||
const videoW = React.useMemo(
|
const videoW = useMemo(
|
||||||
() => pickNum(anyJob.videoWidth, anyJob.width, anyJob.meta?.width),
|
() => pickNum(anyJob.videoWidth, anyJob.width, anyJob.meta?.width),
|
||||||
[anyJob.videoWidth, anyJob.width, anyJob.meta?.width]
|
[anyJob.videoWidth, anyJob.width, anyJob.meta?.width]
|
||||||
)
|
)
|
||||||
|
|
||||||
const videoH = React.useMemo(
|
const videoH = useMemo(
|
||||||
() => pickNum(metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height),
|
() => pickNum(metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height),
|
||||||
[metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height]
|
[metaDims.h, anyJob.videoHeight, anyJob.height, anyJob.meta?.height]
|
||||||
)
|
)
|
||||||
|
|
||||||
const resolutionLabel = React.useMemo(
|
const resolutionLabel = useMemo(
|
||||||
() =>
|
() =>
|
||||||
formatResolution(
|
formatResolution(
|
||||||
intrSize ??
|
intrSize ??
|
||||||
@ -526,15 +534,15 @@ export default function Player({
|
|||||||
),
|
),
|
||||||
[intrSize, videoW, videoH]
|
[intrSize, videoW, videoH]
|
||||||
)
|
)
|
||||||
const fpsLabel = React.useMemo(() => formatFps(fps), [fps])
|
const fpsLabel = useMemo(() => formatFps(fps), [fps])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && onClose()
|
const onKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && onClose()
|
||||||
window.addEventListener('keydown', onKeyDown)
|
window.addEventListener('keydown', onKeyDown)
|
||||||
return () => window.removeEventListener('keydown', onKeyDown)
|
return () => window.removeEventListener('keydown', onKeyDown)
|
||||||
}, [onClose])
|
}, [onClose])
|
||||||
|
|
||||||
const buildVideoSrc = React.useCallback(
|
const buildVideoSrc = useCallback(
|
||||||
(params: { file?: string; id?: string }) => {
|
(params: { file?: string; id?: string }) => {
|
||||||
const qp = new URLSearchParams()
|
const qp = new URLSearchParams()
|
||||||
if (params.file) qp.set('file', params.file)
|
if (params.file) qp.set('file', params.file)
|
||||||
@ -544,7 +552,7 @@ export default function Player({
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
const media = React.useMemo(() => {
|
const media = useMemo(() => {
|
||||||
// ✅ Live wird NICHT mehr über Video.js gespielt
|
// ✅ Live wird NICHT mehr über Video.js gespielt
|
||||||
if (isLive) return { src: '', type: '' }
|
if (isLive) return { src: '', type: '' }
|
||||||
|
|
||||||
@ -563,13 +571,13 @@ export default function Player({
|
|||||||
return { src: buildVideoSrc({ id: job.id }), type: 'video/mp4' }
|
return { src: buildVideoSrc({ id: job.id }), type: 'video/mp4' }
|
||||||
}, [isLive, metaReady, job.output, job.id, buildVideoSrc])
|
}, [isLive, metaReady, job.output, job.id, buildVideoSrc])
|
||||||
|
|
||||||
const containerRef = React.useRef<HTMLDivElement | null>(null)
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||||
const playerRef = React.useRef<VideoJsPlayer | null>(null)
|
const playerRef = useRef<VideoJsPlayer | null>(null)
|
||||||
const videoNodeRef = React.useRef<HTMLVideoElement | null>(null)
|
const videoNodeRef = useRef<HTMLVideoElement | null>(null)
|
||||||
|
|
||||||
const [mounted, setMounted] = React.useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
const updateIntrinsicDims = React.useCallback(() => {
|
const updateIntrinsicDims = useCallback(() => {
|
||||||
const p: any = playerRef.current
|
const p: any = playerRef.current
|
||||||
if (!p || p.isDisposed?.()) return
|
if (!p || p.isDisposed?.()) return
|
||||||
|
|
||||||
@ -584,7 +592,7 @@ export default function Player({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const captureGhostFrame = React.useCallback(() => {
|
const captureGhostFrame = useCallback(() => {
|
||||||
try {
|
try {
|
||||||
// Bevorzugt das echte HTMLVideoElement von Video.js
|
// Bevorzugt das echte HTMLVideoElement von Video.js
|
||||||
const v =
|
const v =
|
||||||
@ -628,19 +636,19 @@ export default function Player({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const playbackKey = React.useMemo(() => {
|
const playbackKey = useMemo(() => {
|
||||||
return baseName(job.output?.trim() || '') || job.id
|
return baseName(job.output?.trim() || '') || job.id
|
||||||
}, [job.output, job.id])
|
}, [job.output, job.id])
|
||||||
|
|
||||||
const normalizedStartAtSec = React.useMemo(() => {
|
const normalizedStartAtSec = useMemo(() => {
|
||||||
const n = Number(startAtSec)
|
const n = Number(startAtSec)
|
||||||
return Number.isFinite(n) && n >= 0 ? n : 0
|
return Number.isFinite(n) && n >= 0 ? n : 0
|
||||||
}, [startAtSec])
|
}, [startAtSec])
|
||||||
|
|
||||||
// Merkt sich, für welchen "Open-Zustand" wir den initialen Seek schon angewendet haben
|
// Merkt sich, für welchen "Open-Zustand" wir den initialen Seek schon angewendet haben
|
||||||
const appliedStartSeekRef = React.useRef<string>('')
|
const appliedStartSeekRef = useRef<string>('')
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLive) {
|
if (isLive) {
|
||||||
setMetaReady(true)
|
setMetaReady(true)
|
||||||
return
|
return
|
||||||
@ -711,9 +719,9 @@ export default function Player({
|
|||||||
}, [isLive, playbackKey, job.output])
|
}, [isLive, playbackKey, job.output])
|
||||||
|
|
||||||
// ✅ iOS Safari: visualViewport changes (address bar / bottom bar / keyboard) need a rerender
|
// ✅ iOS Safari: visualViewport changes (address bar / bottom bar / keyboard) need a rerender
|
||||||
const [, setVvTick] = React.useState(0)
|
const [, setVvTick] = useState(0)
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
const vv = window.visualViewport
|
const vv = window.visualViewport
|
||||||
if (!vv) return
|
if (!vv) return
|
||||||
@ -734,8 +742,8 @@ export default function Player({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const [controlBarH, setControlBarH] = React.useState(30)
|
const [controlBarH, setControlBarH] = useState(30)
|
||||||
const [portalTarget, setPortalTarget] = React.useState<HTMLElement | null>(null)
|
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null)
|
||||||
|
|
||||||
const mini = !expanded
|
const mini = !expanded
|
||||||
|
|
||||||
@ -754,7 +762,7 @@ export default function Player({
|
|||||||
const MIN_W = 320
|
const MIN_W = 320
|
||||||
const MIN_H = 200
|
const MIN_H = 200
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted) return
|
if (!mounted) return
|
||||||
const p = playerRef.current
|
const p = playerRef.current
|
||||||
if (!p || (p as any).isDisposed?.()) return
|
if (!p || (p as any).isDisposed?.()) return
|
||||||
@ -785,9 +793,9 @@ export default function Player({
|
|||||||
}
|
}
|
||||||
}, [mounted, expanded])
|
}, [mounted, expanded])
|
||||||
|
|
||||||
React.useEffect(() => setMounted(true), [])
|
useEffect(() => setMounted(true), [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!usePortal) {
|
if (!usePortal) {
|
||||||
setPortalTarget(null)
|
setPortalTarget(null)
|
||||||
return
|
return
|
||||||
@ -816,7 +824,7 @@ export default function Player({
|
|||||||
setPortalTarget(el)
|
setPortalTarget(el)
|
||||||
}, [isDesktop, usePortal])
|
}, [isDesktop, usePortal])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
const p: any = playerRef.current
|
const p: any = playerRef.current
|
||||||
if (!p || p.isDisposed?.()) return
|
if (!p || p.isDisposed?.()) return
|
||||||
if (isLive) return // live nutzt Video.js nicht
|
if (isLive) return // live nutzt Video.js nicht
|
||||||
@ -852,7 +860,7 @@ export default function Player({
|
|||||||
}
|
}
|
||||||
}, [job.output, isLive])
|
}, [job.output, isLive])
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!mounted) return
|
if (!mounted) return
|
||||||
if (!containerRef.current) return
|
if (!containerRef.current) return
|
||||||
if (playerRef.current) return
|
if (playerRef.current) return
|
||||||
@ -927,7 +935,7 @@ export default function Player({
|
|||||||
}
|
}
|
||||||
}, [mounted, startMuted, isLive, metaReady, updateIntrinsicDims])
|
}, [mounted, startMuted, isLive, metaReady, updateIntrinsicDims])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
const p = playerRef.current
|
const p = playerRef.current
|
||||||
if (!p || (p as any).isDisposed?.()) return
|
if (!p || (p as any).isDisposed?.()) return
|
||||||
|
|
||||||
@ -937,7 +945,7 @@ export default function Player({
|
|||||||
el.classList.toggle('is-live-download', Boolean(isLive))
|
el.classList.toggle('is-live-download', Boolean(isLive))
|
||||||
}, [isLive])
|
}, [isLive])
|
||||||
|
|
||||||
const releaseMedia = React.useCallback(() => {
|
const releaseMedia = useCallback(() => {
|
||||||
const p = playerRef.current
|
const p = playerRef.current
|
||||||
if (!p || (p as any).isDisposed?.()) return
|
if (!p || (p as any).isDisposed?.()) return
|
||||||
|
|
||||||
@ -952,7 +960,7 @@ export default function Player({
|
|||||||
} catch {}
|
} catch {}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const seekPlayerToAbsolute = React.useCallback((absSec: number) => {
|
const seekPlayerToAbsolute = useCallback((absSec: number) => {
|
||||||
const p: any = playerRef.current
|
const p: any = playerRef.current
|
||||||
if (!p || p.isDisposed?.()) return
|
if (!p || p.isDisposed?.()) return
|
||||||
|
|
||||||
@ -971,7 +979,7 @@ export default function Player({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted) return
|
if (!mounted) return
|
||||||
if (!isLive && !metaReady) {
|
if (!isLive && !metaReady) {
|
||||||
releaseMedia()
|
releaseMedia()
|
||||||
@ -1056,7 +1064,7 @@ export default function Player({
|
|||||||
tryPlay()
|
tryPlay()
|
||||||
}, [mounted, isLive, metaReady, media.src, media.type, startMuted, updateIntrinsicDims, fullDurationSec, releaseMedia])
|
}, [mounted, isLive, metaReady, media.src, media.type, startMuted, updateIntrinsicDims, fullDurationSec, releaseMedia])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted) return
|
if (!mounted) return
|
||||||
if (isLive) return // Live spielt nicht über Video.js
|
if (isLive) return // Live spielt nicht über Video.js
|
||||||
if (!metaReady) return
|
if (!metaReady) return
|
||||||
@ -1137,13 +1145,13 @@ export default function Player({
|
|||||||
seekPlayerToAbsolute,
|
seekPlayerToAbsolute,
|
||||||
])
|
])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
const p = playerRef.current
|
const p = playerRef.current
|
||||||
if (!p || (p as any).isDisposed?.()) return
|
if (!p || (p as any).isDisposed?.()) return
|
||||||
queueMicrotask(() => p.trigger('resize'))
|
queueMicrotask(() => p.trigger('resize'))
|
||||||
}, [expanded])
|
}, [expanded])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
const onRelease = (ev: Event) => {
|
const onRelease = (ev: Event) => {
|
||||||
const detail = (ev as CustomEvent<{ file?: string }>).detail
|
const detail = (ev as CustomEvent<{ file?: string }>).detail
|
||||||
const file = (detail?.file ?? '').trim()
|
const file = (detail?.file ?? '').trim()
|
||||||
@ -1157,7 +1165,7 @@ export default function Player({
|
|||||||
return () => window.removeEventListener('player:release', onRelease as EventListener)
|
return () => window.removeEventListener('player:release', onRelease as EventListener)
|
||||||
}, [job.output, releaseMedia])
|
}, [job.output, releaseMedia])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
const onCloseIfFile = (ev: Event) => {
|
const onCloseIfFile = (ev: Event) => {
|
||||||
const detail = (ev as CustomEvent<{ file?: string }>).detail
|
const detail = (ev as CustomEvent<{ file?: string }>).detail
|
||||||
const file = (detail?.file ?? '').trim()
|
const file = (detail?.file ?? '').trim()
|
||||||
@ -1197,15 +1205,15 @@ export default function Player({
|
|||||||
return { w, h, ox: 0, oy: 0, bottomInset: 0 }
|
return { w, h, ox: 0, oy: 0, bottomInset: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevViewportRef = React.useRef<{ w: number; h: number } | null>(null)
|
const prevViewportRef = useRef<{ w: number; h: number } | null>(null)
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
const { w, h } = getViewport()
|
const { w, h } = getViewport()
|
||||||
prevViewportRef.current = { w, h }
|
prevViewportRef.current = { w, h }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const getVideoAspectRatio = React.useCallback(() => {
|
const getVideoAspectRatio = useCallback(() => {
|
||||||
// bevorzugt echtes Video (intrinsic), dann Meta, fallback 16:9
|
// bevorzugt echtes Video (intrinsic), dann Meta, fallback 16:9
|
||||||
const anyJob = job as any
|
const anyJob = job as any
|
||||||
|
|
||||||
@ -1233,7 +1241,7 @@ export default function Player({
|
|||||||
return 16 / 9
|
return 16 / 9
|
||||||
}, [job, intrSize])
|
}, [job, intrSize])
|
||||||
|
|
||||||
const clampMiniRect = React.useCallback(
|
const clampMiniRect = useCallback(
|
||||||
(r: { x: number; y: number; w: number; h: number }) => {
|
(r: { x: number; y: number; w: number; h: number }) => {
|
||||||
if (typeof window === 'undefined') return r
|
if (typeof window === 'undefined') return r
|
||||||
|
|
||||||
@ -1276,7 +1284,7 @@ export default function Player({
|
|||||||
[getVideoAspectRatio, isLive]
|
[getVideoAspectRatio, isLive]
|
||||||
)
|
)
|
||||||
|
|
||||||
const loadRect = React.useCallback(() => {
|
const loadRect = useCallback(() => {
|
||||||
if (typeof window === 'undefined') return { x: MARGIN, y: MARGIN, w: DEFAULT_W, h: DEFAULT_H }
|
if (typeof window === 'undefined') return { x: MARGIN, y: MARGIN, w: DEFAULT_W, h: DEFAULT_H }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -1298,27 +1306,27 @@ export default function Player({
|
|||||||
return clampMiniRect({ x, y, w, h })
|
return clampMiniRect({ x, y, w, h })
|
||||||
}, [clampMiniRect])
|
}, [clampMiniRect])
|
||||||
|
|
||||||
const [win, setWin] = React.useState<WinRect>(() => loadRect())
|
const [win, setWin] = useState<WinRect>(() => loadRect())
|
||||||
const isNarrowMini = miniDesktop && win.w < 380
|
const isNarrowMini = miniDesktop && win.w < 380
|
||||||
|
|
||||||
const saveRect = React.useCallback((r: WinRect) => {
|
const saveRect = useCallback((r: WinRect) => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(WIN_KEY, JSON.stringify(r))
|
window.localStorage.setItem(WIN_KEY, JSON.stringify(r))
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const winRef = React.useRef(win)
|
const winRef = useRef(win)
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
winRef.current = win
|
winRef.current = win
|
||||||
}, [win])
|
}, [win])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!miniDesktop) return
|
if (!miniDesktop) return
|
||||||
setWin(loadRect())
|
setWin(loadRect())
|
||||||
}, [miniDesktop, loadRect])
|
}, [miniDesktop, loadRect])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!miniDesktop) return
|
if (!miniDesktop) return
|
||||||
|
|
||||||
const onResize = () => {
|
const onResize = () => {
|
||||||
@ -1372,8 +1380,8 @@ export default function Player({
|
|||||||
}, [miniDesktop, clampMiniRect])
|
}, [miniDesktop, clampMiniRect])
|
||||||
|
|
||||||
// Video.js resize triggern, wenn sich Fenstergröße ändert
|
// Video.js resize triggern, wenn sich Fenstergröße ändert
|
||||||
const vjsResizeRafRef = React.useRef<number | null>(null)
|
const vjsResizeRafRef = useRef<number | null>(null)
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
const p = playerRef.current
|
const p = playerRef.current
|
||||||
if (!p || (p as any).isDisposed?.()) return
|
if (!p || (p as any).isDisposed?.()) return
|
||||||
|
|
||||||
@ -1393,18 +1401,18 @@ export default function Player({
|
|||||||
}
|
}
|
||||||
}, [miniDesktop, win.w, win.h])
|
}, [miniDesktop, win.w, win.h])
|
||||||
|
|
||||||
const [isResizing, setIsResizing] = React.useState(false)
|
const [isResizing, setIsResizing] = useState(false)
|
||||||
const [isDragging, setIsDragging] = React.useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
const [snapPreviewRect, setSnapPreviewRect] = React.useState<WinRect | null>(null)
|
const [snapPreviewRect, setSnapPreviewRect] = useState<WinRect | null>(null)
|
||||||
const [ghostFrameSrc, setGhostFrameSrc] = React.useState<string | null>(null)
|
const [ghostFrameSrc, setGhostFrameSrc] = useState<string | null>(null)
|
||||||
const ghostFrameCanvasRef = React.useRef<HTMLCanvasElement | null>(null)
|
const ghostFrameCanvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||||
|
|
||||||
// pointermove sehr häufig -> 1x pro Frame committen
|
// pointermove sehr häufig -> 1x pro Frame committen
|
||||||
const dragRafRef = React.useRef<number | null>(null)
|
const dragRafRef = useRef<number | null>(null)
|
||||||
const pendingPosRef = React.useRef<{ x: number; y: number } | null>(null)
|
const pendingPosRef = useRef<{ x: number; y: number } | null>(null)
|
||||||
const draggingRef = React.useRef<null | { sx: number; sy: number; start: WinRect }>(null)
|
const draggingRef = useRef<null | { sx: number; sy: number; start: WinRect }>(null)
|
||||||
|
|
||||||
const applySnap = React.useCallback((r: WinRect): WinRect => {
|
const applySnap = useCallback((r: WinRect): WinRect => {
|
||||||
const { w: vw, h: vh } = getViewport()
|
const { w: vw, h: vh } = getViewport()
|
||||||
const leftEdge = MARGIN
|
const leftEdge = MARGIN
|
||||||
const rightEdge = vw - r.w - MARGIN
|
const rightEdge = vw - r.w - MARGIN
|
||||||
@ -1414,8 +1422,8 @@ export default function Player({
|
|||||||
return { ...r, x: dockLeft ? leftEdge : rightEdge, y: bottomEdge }
|
return { ...r, x: dockLeft ? leftEdge : rightEdge, y: bottomEdge }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const onDragMove = React.useCallback(
|
const onDragMove = useCallback(
|
||||||
(ev: PointerEvent) => {
|
(ev: globalThis.PointerEvent) => {
|
||||||
const s = draggingRef.current
|
const s = draggingRef.current
|
||||||
if (!s) return
|
if (!s) return
|
||||||
|
|
||||||
@ -1442,7 +1450,7 @@ export default function Player({
|
|||||||
[clampMiniRect, applySnap]
|
[clampMiniRect, applySnap]
|
||||||
)
|
)
|
||||||
|
|
||||||
const endDrag = React.useCallback(() => {
|
const endDrag = useCallback(() => {
|
||||||
if (!draggingRef.current) return
|
if (!draggingRef.current) return
|
||||||
setIsDragging(false)
|
setIsDragging(false)
|
||||||
setSnapPreviewRect(null)
|
setSnapPreviewRect(null)
|
||||||
@ -1463,8 +1471,8 @@ export default function Player({
|
|||||||
})
|
})
|
||||||
}, [onDragMove, applySnap, clampMiniRect, saveRect])
|
}, [onDragMove, applySnap, clampMiniRect, saveRect])
|
||||||
|
|
||||||
const beginDrag = React.useCallback(
|
const beginDrag = useCallback(
|
||||||
(e: React.PointerEvent) => {
|
(e: ReactPointerEvent<HTMLElement>) => {
|
||||||
if (!miniDesktop) return
|
if (!miniDesktop) return
|
||||||
if (isResizing) return
|
if (isResizing) return
|
||||||
if (e.button !== 0) return
|
if (e.button !== 0) return
|
||||||
@ -1490,12 +1498,12 @@ export default function Player({
|
|||||||
)
|
)
|
||||||
|
|
||||||
// pointermove kommt extrem oft -> wir committen max. 1x pro Frame
|
// pointermove kommt extrem oft -> wir committen max. 1x pro Frame
|
||||||
const resizeRafRef = React.useRef<number | null>(null)
|
const resizeRafRef = useRef<number | null>(null)
|
||||||
const pendingRectRef = React.useRef<WinRect | null>(null)
|
const pendingRectRef = useRef<WinRect | null>(null)
|
||||||
const resizingRef = React.useRef<null | { dir: ResizeDir; sx: number; sy: number; start: WinRect; ratio: number }>(null)
|
const resizingRef = useRef<null | { dir: ResizeDir; sx: number; sy: number; start: WinRect; ratio: number }>(null)
|
||||||
|
|
||||||
const onResizeMove = React.useCallback(
|
const onResizeMove = useCallback(
|
||||||
(ev: PointerEvent) => {
|
(ev: globalThis.PointerEvent) => {
|
||||||
const s = resizingRef.current
|
const s = resizingRef.current
|
||||||
if (!s) return
|
if (!s) return
|
||||||
|
|
||||||
@ -1590,7 +1598,7 @@ export default function Player({
|
|||||||
[clampMiniRect]
|
[clampMiniRect]
|
||||||
)
|
)
|
||||||
|
|
||||||
const endResize = React.useCallback(() => {
|
const endResize = useCallback(() => {
|
||||||
if (!resizingRef.current) return
|
if (!resizingRef.current) return
|
||||||
setIsResizing(false)
|
setIsResizing(false)
|
||||||
setSnapPreviewRect(null)
|
setSnapPreviewRect(null)
|
||||||
@ -1607,8 +1615,8 @@ export default function Player({
|
|||||||
saveRect(winRef.current)
|
saveRect(winRef.current)
|
||||||
}, [onResizeMove, saveRect])
|
}, [onResizeMove, saveRect])
|
||||||
|
|
||||||
const beginResize = React.useCallback(
|
const beginResize = useCallback(
|
||||||
(dir: ResizeDir) => (e: React.PointerEvent) => {
|
(dir: ResizeDir) => (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
if (!miniDesktop) return
|
if (!miniDesktop) return
|
||||||
if (e.button !== 0) return
|
if (e.button !== 0) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@ -1624,10 +1632,10 @@ export default function Player({
|
|||||||
[miniDesktop, onResizeMove, endResize]
|
[miniDesktop, onResizeMove, endResize]
|
||||||
)
|
)
|
||||||
|
|
||||||
const [canHover, setCanHover] = React.useState(false)
|
const [canHover, setCanHover] = useState(false)
|
||||||
const [chromeHover, setChromeHover] = React.useState(false)
|
const [chromeHover, setChromeHover] = useState(false)
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
const mq = window.matchMedia?.('(hover: hover) and (pointer: fine)')
|
const mq = window.matchMedia?.('(hover: hover) and (pointer: fine)')
|
||||||
const update = () => setCanHover(Boolean(mq?.matches))
|
const update = () => setCanHover(Boolean(mq?.matches))
|
||||||
update()
|
update()
|
||||||
@ -1636,9 +1644,9 @@ export default function Player({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const dragUiActive = miniDesktop && (chromeHover || isDragging || isResizing)
|
const dragUiActive = miniDesktop && (chromeHover || isDragging || isResizing)
|
||||||
const [stopPending, setStopPending] = React.useState(false)
|
const [stopPending, setStopPending] = useState(false)
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (job.status !== 'running') setStopPending(false)
|
if (job.status !== 'running') setStopPending(false)
|
||||||
}, [job.id, job.status])
|
}, [job.id, job.status])
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import { useEffect, useRef, useCallback, type PointerEvent, type MouseEvent } from 'react'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
imageCount: number
|
imageCount: number
|
||||||
@ -32,17 +32,17 @@ export default function PreviewScrubber({
|
|||||||
className,
|
className,
|
||||||
stepSeconds = 0,
|
stepSeconds = 0,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const rootRef = React.useRef<HTMLDivElement | null>(null)
|
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
const rafRef = React.useRef<number | null>(null)
|
const rafRef = useRef<number | null>(null)
|
||||||
const pendingIndexRef = React.useRef<number | undefined>(undefined)
|
const pendingIndexRef = useRef<number | undefined>(undefined)
|
||||||
|
|
||||||
const flushPending = React.useCallback(() => {
|
const flushPending = useCallback(() => {
|
||||||
rafRef.current = null
|
rafRef.current = null
|
||||||
onActiveIndexChange(pendingIndexRef.current)
|
onActiveIndexChange(pendingIndexRef.current)
|
||||||
}, [onActiveIndexChange])
|
}, [onActiveIndexChange])
|
||||||
|
|
||||||
const setIndexThrottled = React.useCallback(
|
const setIndexThrottled = useCallback(
|
||||||
(index: number | undefined) => {
|
(index: number | undefined) => {
|
||||||
pendingIndexRef.current = index
|
pendingIndexRef.current = index
|
||||||
if (rafRef.current != null) return
|
if (rafRef.current != null) return
|
||||||
@ -51,7 +51,7 @@ export default function PreviewScrubber({
|
|||||||
[flushPending]
|
[flushPending]
|
||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (rafRef.current != null) {
|
if (rafRef.current != null) {
|
||||||
window.cancelAnimationFrame(rafRef.current)
|
window.cancelAnimationFrame(rafRef.current)
|
||||||
@ -60,7 +60,7 @@ export default function PreviewScrubber({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const indexFromClientX = React.useCallback(
|
const indexFromClientX = useCallback(
|
||||||
(clientX: number) => {
|
(clientX: number) => {
|
||||||
if (!rootRef.current || imageCount < 1) return undefined
|
if (!rootRef.current || imageCount < 1) return undefined
|
||||||
|
|
||||||
@ -75,28 +75,28 @@ export default function PreviewScrubber({
|
|||||||
[imageCount]
|
[imageCount]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handlePointerMove = React.useCallback(
|
const handlePointerMove = useCallback(
|
||||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
(e: PointerEvent<HTMLDivElement>) => {
|
||||||
const idx = indexFromClientX(e.clientX)
|
const idx = indexFromClientX(e.clientX)
|
||||||
setIndexThrottled(idx)
|
setIndexThrottled(idx)
|
||||||
},
|
},
|
||||||
[indexFromClientX, setIndexThrottled]
|
[indexFromClientX, setIndexThrottled]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handlePointerEnter = React.useCallback(
|
const handlePointerEnter = useCallback(
|
||||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
(e: PointerEvent<HTMLDivElement>) => {
|
||||||
const idx = indexFromClientX(e.clientX)
|
const idx = indexFromClientX(e.clientX)
|
||||||
setIndexThrottled(idx)
|
setIndexThrottled(idx)
|
||||||
},
|
},
|
||||||
[indexFromClientX, setIndexThrottled]
|
[indexFromClientX, setIndexThrottled]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handlePointerLeave = React.useCallback(() => {
|
const handlePointerLeave = useCallback(() => {
|
||||||
setIndexThrottled(undefined)
|
setIndexThrottled(undefined)
|
||||||
}, [setIndexThrottled])
|
}, [setIndexThrottled])
|
||||||
|
|
||||||
const handlePointerDown = React.useCallback(
|
const handlePointerDown = useCallback(
|
||||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
(e: PointerEvent<HTMLDivElement>) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
const idx = indexFromClientX(e.clientX)
|
const idx = indexFromClientX(e.clientX)
|
||||||
setIndexThrottled(idx)
|
setIndexThrottled(idx)
|
||||||
@ -107,8 +107,8 @@ export default function PreviewScrubber({
|
|||||||
[indexFromClientX, setIndexThrottled]
|
[indexFromClientX, setIndexThrottled]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleClick = React.useCallback(
|
const handleClick = useCallback(
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
(e: MouseEvent<HTMLDivElement>) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (!onIndexClick) return
|
if (!onIndexClick) return
|
||||||
const idx = indexFromClientX(e.clientX)
|
const idx = indexFromClientX(e.clientX)
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import { type ReactNode } from 'react'
|
||||||
|
|
||||||
type ProgressBarProps = {
|
type ProgressBarProps = {
|
||||||
label?: React.ReactNode
|
label?: ReactNode
|
||||||
value?: number | null // 0..100
|
value?: number | null // 0..100
|
||||||
indeterminate?: boolean // wenn true -> “läuft…” ohne Prozent
|
indeterminate?: boolean // wenn true -> “läuft…” ohne Prozent
|
||||||
showPercent?: boolean // zeigt “xx%” (nur determinate)
|
showPercent?: boolean // zeigt “xx%” (nur determinate)
|
||||||
rightLabel?: React.ReactNode // optionaler Text unter der Bar (z.B. 3/10)
|
rightLabel?: ReactNode // optionaler Text unter der Bar (z.B. 3/10)
|
||||||
steps?: string[] // optional: Step-Labels
|
steps?: string[] // optional: Step-Labels
|
||||||
currentStep?: number // 0-basiert
|
currentStep?: number // 0-basiert
|
||||||
size?: 'sm' | 'md'
|
size?: 'sm' | 'md'
|
||||||
|
|||||||
@ -2,7 +2,16 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import {
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useMemo,
|
||||||
|
useLayoutEffect,
|
||||||
|
Fragment,
|
||||||
|
type ReactNode,
|
||||||
|
type MouseEvent as ReactMouseEvent,
|
||||||
|
} from 'react'
|
||||||
import type { RecordJob } from '../../types'
|
import type { RecordJob } from '../../types'
|
||||||
import {
|
import {
|
||||||
FireIcon as FireOutlineIcon,
|
FireIcon as FireOutlineIcon,
|
||||||
@ -179,10 +188,10 @@ export default function RecordJobActions({
|
|||||||
// Add: nur über order steuern (disabled wenn keine URL/Handler)
|
// Add: nur über order steuern (disabled wenn keine URL/Handler)
|
||||||
const wantAdd = inOrder('add')
|
const wantAdd = inOrder('add')
|
||||||
|
|
||||||
const [addState, setAddState] = React.useState<'idle' | 'busy' | 'ok'>('idle')
|
const [addState, setAddState] = useState<'idle' | 'busy' | 'ok'>('idle')
|
||||||
const addTimerRef = React.useRef<number | null>(null)
|
const addTimerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (addTimerRef.current) window.clearTimeout(addTimerRef.current)
|
if (addTimerRef.current) window.clearTimeout(addTimerRef.current)
|
||||||
}
|
}
|
||||||
@ -226,10 +235,10 @@ export default function RecordJobActions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Auto-Fit: verfügbare Breite + tatsächlicher gap (Tailwind gap-1/gap-2/…)
|
// ✅ Auto-Fit: verfügbare Breite + tatsächlicher gap (Tailwind gap-1/gap-2/…)
|
||||||
const [rootW, setRootW] = React.useState(0)
|
const [rootW, setRootW] = useState(0)
|
||||||
const [gapPx, setGapPx] = React.useState(4)
|
const [gapPx, setGapPx] = useState(4)
|
||||||
|
|
||||||
const run = (fn?: ActionFn) => (e: React.MouseEvent) => {
|
const run = (fn?: ActionFn) => (e: ReactMouseEvent<HTMLButtonElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (busy || !fn) return
|
if (busy || !fn) return
|
||||||
@ -452,7 +461,7 @@ export default function RecordJobActions({
|
|||||||
</button>
|
</button>
|
||||||
) : null
|
) : null
|
||||||
|
|
||||||
const byKey: Record<ActionKey, React.ReactNode> = {
|
const byKey: Record<ActionKey, ReactNode> = {
|
||||||
details: DetailsBtn,
|
details: DetailsBtn,
|
||||||
add: AddBtn,
|
add: AddBtn,
|
||||||
split: SplitBtn,
|
split: SplitBtn,
|
||||||
@ -479,7 +488,7 @@ export default function RecordJobActions({
|
|||||||
|
|
||||||
const fallbackLimit = compact ? 2 : 3
|
const fallbackLimit = compact ? 2 : 3
|
||||||
|
|
||||||
const fitLimit = React.useMemo(() => {
|
const fitLimit = useMemo(() => {
|
||||||
if (!autoFit) return (inlineCount ?? fallbackLimit)
|
if (!autoFit) return (inlineCount ?? fallbackLimit)
|
||||||
|
|
||||||
// rootW kann initial 0 sein -> brauchbarer Fallback
|
// rootW kann initial 0 sein -> brauchbarer Fallback
|
||||||
@ -498,10 +507,10 @@ export default function RecordJobActions({
|
|||||||
const inlineKeys = collapse ? presentKeys.slice(0, fitLimit) : presentKeys
|
const inlineKeys = collapse ? presentKeys.slice(0, fitLimit) : presentKeys
|
||||||
const menuKeys = collapse ? presentKeys.slice(fitLimit) : []
|
const menuKeys = collapse ? presentKeys.slice(fitLimit) : []
|
||||||
|
|
||||||
const [menuOpen, setMenuOpen] = React.useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const menuRef = React.useRef<HTMLDivElement | null>(null)
|
const menuRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const el = menuRef.current
|
const el = menuRef.current
|
||||||
if (!el || typeof window === 'undefined') return
|
if (!el || typeof window === 'undefined') return
|
||||||
|
|
||||||
@ -532,16 +541,16 @@ export default function RecordJobActions({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// ✅ neu
|
// ✅ neu
|
||||||
const menuBtnRef = React.useRef<HTMLButtonElement | null>(null)
|
const menuBtnRef = useRef<HTMLButtonElement | null>(null)
|
||||||
const menuPopupRef = React.useRef<HTMLDivElement | null>(null)
|
const menuPopupRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
const MENU_W = 208 // entspricht Tailwind w-52 (13rem)
|
const MENU_W = 208 // entspricht Tailwind w-52 (13rem)
|
||||||
const GAP = 4
|
const GAP = 4
|
||||||
const MARGIN = 8
|
const MARGIN = 8
|
||||||
|
|
||||||
const [menuStyle, setMenuStyle] = React.useState<{ top: number; left: number; maxH: number } | null>(null)
|
const [menuStyle, setMenuStyle] = useState<{ top: number; left: number; maxH: number } | null>(null)
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuOpen) return
|
if (!menuOpen) return
|
||||||
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@ -566,7 +575,7 @@ export default function RecordJobActions({
|
|||||||
}
|
}
|
||||||
}, [menuOpen])
|
}, [menuOpen])
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!menuOpen) {
|
if (!menuOpen) {
|
||||||
setMenuStyle(null)
|
setMenuStyle(null)
|
||||||
return
|
return
|
||||||
@ -812,7 +821,7 @@ export default function RecordJobActions({
|
|||||||
<div ref={menuRef} className={cn('relative flex items-center flex-nowrap', className ?? 'gap-2')}>
|
<div ref={menuRef} className={cn('relative flex items-center flex-nowrap', className ?? 'gap-2')}>
|
||||||
{inlineKeys.map((k) => {
|
{inlineKeys.map((k) => {
|
||||||
const node = byKey[k]
|
const node = byKey[k]
|
||||||
return node ? <React.Fragment key={k}>{node}</React.Fragment> : null
|
return node ? <Fragment key={k}>{node}</Fragment> : null
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{collapse && menuKeys.length > 0 ? (
|
{collapse && menuKeys.length > 0 ? (
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// frontend\src\components\ui\RecorderSettings.tsx
|
// frontend\src\components\ui\RecorderSettings.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import Card from './Card'
|
import Card from './Card'
|
||||||
import LabeledSwitch from './LabeledSwitch'
|
import LabeledSwitch from './LabeledSwitch'
|
||||||
@ -29,6 +29,12 @@ type RecorderSettings = {
|
|||||||
teaserAudio?: boolean
|
teaserAudio?: boolean
|
||||||
lowDiskPauseBelowGB?: number
|
lowDiskPauseBelowGB?: number
|
||||||
enableNotifications?: boolean
|
enableNotifications?: boolean
|
||||||
|
|
||||||
|
generateAssetsMeta?: boolean
|
||||||
|
generateAssetsThumb?: boolean
|
||||||
|
generateAssetsTeaser?: boolean
|
||||||
|
generateAssetsSprites?: boolean
|
||||||
|
generateAssetsAnalyze?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type DiskStatus = {
|
type DiskStatus = {
|
||||||
@ -59,12 +65,40 @@ const DEFAULTS: RecorderSettings = {
|
|||||||
teaserAudio: false,
|
teaserAudio: false,
|
||||||
lowDiskPauseBelowGB: 5,
|
lowDiskPauseBelowGB: 5,
|
||||||
enableNotifications: true,
|
enableNotifications: true,
|
||||||
|
|
||||||
|
generateAssetsMeta: true,
|
||||||
|
generateAssetsThumb: true,
|
||||||
|
generateAssetsTeaser: true,
|
||||||
|
generateAssetsSprites: true,
|
||||||
|
generateAssetsAnalyze: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onAssetsGenerated?: () => void
|
onAssetsGenerated?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function regeneratePhaseOrder(queue?: string, phase?: string) {
|
||||||
|
const q = String(queue || '').trim().toLowerCase()
|
||||||
|
const p = String(phase || '').trim().toLowerCase()
|
||||||
|
|
||||||
|
const key = `${q}:${p}`
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case 'postwork:meta':
|
||||||
|
return 0
|
||||||
|
case 'postwork:thumb':
|
||||||
|
return 1
|
||||||
|
case 'postwork:teaser':
|
||||||
|
return 2
|
||||||
|
case 'postwork:sprites':
|
||||||
|
return 3
|
||||||
|
case 'enrich:analyze':
|
||||||
|
return 4
|
||||||
|
default:
|
||||||
|
return 999
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function shortTaskFilename(name?: string, max = 52) {
|
function shortTaskFilename(name?: string, max = 52) {
|
||||||
const s = String(name ?? '').trim()
|
const s = String(name ?? '').trim()
|
||||||
if (!s) return ''
|
if (!s) return ''
|
||||||
@ -94,7 +128,7 @@ function assetsPhaseLabel(label?: string, phase?: string) {
|
|||||||
case 'sprites':
|
case 'sprites':
|
||||||
return 'Sprites'
|
return 'Sprites'
|
||||||
case 'analyze':
|
case 'analyze':
|
||||||
return 'KI-Analyse'
|
return 'Analyse'
|
||||||
default:
|
default:
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@ -109,12 +143,21 @@ function assetsTaskText(file?: string, label?: string, phase?: string) {
|
|||||||
return phaseText
|
return phaseText
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseTaskTimeMs(v: unknown) {
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) return v
|
||||||
|
|
||||||
|
const s = String(v ?? '').trim()
|
||||||
|
if (!s) return 0
|
||||||
|
|
||||||
|
const ms = Date.parse(s)
|
||||||
|
return Number.isFinite(ms) ? ms : 0
|
||||||
|
}
|
||||||
|
|
||||||
export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||||
const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
|
const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState<number>(0)
|
const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState<number>(0)
|
||||||
const saveSuccessTimerRef = useRef<number | null>(null)
|
const saveSuccessTimerRef = useRef<number | null>(null)
|
||||||
const [cleaning, setCleaning] = useState(false)
|
|
||||||
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
|
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
|
||||||
const [msg, setMsg] = useState<string | null>(null)
|
const [msg, setMsg] = useState<string | null>(null)
|
||||||
const [err, setErr] = useState<string | null>(null)
|
const [err, setErr] = useState<string | null>(null)
|
||||||
@ -171,6 +214,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
text: '',
|
text: '',
|
||||||
cancellable: true,
|
cancellable: true,
|
||||||
fading: false,
|
fading: false,
|
||||||
|
queuedAtMs: 0,
|
||||||
startedAtMs: 0,
|
startedAtMs: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -181,6 +225,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
text: '',
|
text: '',
|
||||||
cancellable: true,
|
cancellable: true,
|
||||||
fading: false,
|
fading: false,
|
||||||
|
queuedAtMs: 0,
|
||||||
startedAtMs: 0,
|
startedAtMs: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -190,6 +235,20 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
const uiPauseGB = diskStatus?.pauseGB ?? pauseGB
|
const uiPauseGB = diskStatus?.pauseGB ?? pauseGB
|
||||||
const uiResumeGB = diskStatus?.resumeGB ?? (pauseGB + 3)
|
const uiResumeGB = diskStatus?.resumeGB ?? (pauseGB + 3)
|
||||||
|
|
||||||
|
const checkVideosAbortRef = useRef<AbortController | null>(null)
|
||||||
|
const checkVideosCancelRequestedRef = useRef(false)
|
||||||
|
|
||||||
|
const [checkVideosTask, setCheckVideosTask] = useState<TaskItem>({
|
||||||
|
id: 'check-videos',
|
||||||
|
status: 'idle',
|
||||||
|
title: 'Downloads prüfen',
|
||||||
|
text: '',
|
||||||
|
cancellable: true,
|
||||||
|
fading: false,
|
||||||
|
queuedAtMs: 0,
|
||||||
|
startedAtMs: 0,
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (saveSuccessTimerRef.current != null) {
|
if (saveSuccessTimerRef.current != null) {
|
||||||
@ -232,6 +291,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
teaserAudio: (data as any).teaserAudio ?? DEFAULTS.teaserAudio,
|
teaserAudio: (data as any).teaserAudio ?? DEFAULTS.teaserAudio,
|
||||||
lowDiskPauseBelowGB: (data as any).lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB,
|
lowDiskPauseBelowGB: (data as any).lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB,
|
||||||
enableNotifications: (data as any).enableNotifications ?? DEFAULTS.enableNotifications,
|
enableNotifications: (data as any).enableNotifications ?? DEFAULTS.enableNotifications,
|
||||||
|
generateAssetsMeta: (data as any).generateAssetsMeta ?? DEFAULTS.generateAssetsMeta,
|
||||||
|
generateAssetsThumb: (data as any).generateAssetsThumb ?? DEFAULTS.generateAssetsThumb,
|
||||||
|
generateAssetsTeaser: (data as any).generateAssetsTeaser ?? DEFAULTS.generateAssetsTeaser,
|
||||||
|
generateAssetsSprites: (data as any).generateAssetsSprites ?? DEFAULTS.generateAssetsSprites,
|
||||||
|
generateAssetsAnalyze: (data as any).generateAssetsAnalyze ?? DEFAULTS.generateAssetsAnalyze,
|
||||||
})
|
})
|
||||||
setLoadedDatabaseUrl(String((data as any).databaseUrl ?? '').trim())
|
setLoadedDatabaseUrl(String((data as any).databaseUrl ?? '').trim())
|
||||||
})
|
})
|
||||||
@ -258,11 +322,13 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
const assetsFile = String(assets?.currentFile || assets?.text || '').trim()
|
const assetsFile = String(assets?.currentFile || assets?.text || '').trim()
|
||||||
const assetsLabel = String(assets?.currentLabel || '').trim()
|
const assetsLabel = String(assets?.currentLabel || '').trim()
|
||||||
const assetsPhase = String(assets?.currentPhase || '').trim()
|
const assetsPhase = String(assets?.currentPhase || '').trim()
|
||||||
|
const assetsQueuedAtMs = parseTaskTimeMs(assets?.queuedAt)
|
||||||
|
const assetsStartedAtMs = parseTaskTimeMs(assets?.startedAt)
|
||||||
|
|
||||||
if (assets?.queued) {
|
if (assets?.queued) {
|
||||||
setAssetsTask((t) => ({
|
setAssetsTask((t) => ({
|
||||||
...t,
|
...t,
|
||||||
status: 'running',
|
status: 'queued',
|
||||||
title: 'Assets generieren',
|
title: 'Assets generieren',
|
||||||
text: String(assets.text || 'Wartet…'),
|
text: String(assets.text || 'Wartet…'),
|
||||||
done: Number(assets.done ?? 0),
|
done: Number(assets.done ?? 0),
|
||||||
@ -270,6 +336,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
err: undefined,
|
err: undefined,
|
||||||
cancellable: false,
|
cancellable: false,
|
||||||
fading: false,
|
fading: false,
|
||||||
|
queuedAtMs: t.queuedAtMs || assetsQueuedAtMs || Date.now(),
|
||||||
|
startedAtMs: assetsStartedAtMs || t.startedAtMs || 0,
|
||||||
}))
|
}))
|
||||||
} else if (assets?.running) {
|
} else if (assets?.running) {
|
||||||
setAssetsTask((t) => ({
|
setAssetsTask((t) => ({
|
||||||
@ -282,6 +350,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
err: undefined,
|
err: undefined,
|
||||||
cancellable: true,
|
cancellable: true,
|
||||||
fading: false,
|
fading: false,
|
||||||
|
queuedAtMs: t.queuedAtMs || assetsQueuedAtMs || Date.now(),
|
||||||
|
startedAtMs: assetsStartedAtMs || t.startedAtMs || Date.now(),
|
||||||
}))
|
}))
|
||||||
} else if (assets?.error) {
|
} else if (assets?.error) {
|
||||||
const assetsError = String(assets.error || '').trim()
|
const assetsError = String(assets.error || '').trim()
|
||||||
@ -310,6 +380,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
err: cancelled ? undefined : assetsError,
|
err: cancelled ? undefined : assetsError,
|
||||||
cancellable: false,
|
cancellable: false,
|
||||||
fading: t.fading ?? false,
|
fading: t.fading ?? false,
|
||||||
|
queuedAtMs: t.queuedAtMs || assetsQueuedAtMs || 0,
|
||||||
|
startedAtMs: assetsStartedAtMs || t.startedAtMs || 0,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else if (assets?.finishedAt) {
|
} else if (assets?.finishedAt) {
|
||||||
@ -323,6 +395,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
err: undefined,
|
err: undefined,
|
||||||
cancellable: false,
|
cancellable: false,
|
||||||
fading: false,
|
fading: false,
|
||||||
|
queuedAtMs: t.queuedAtMs || assetsQueuedAtMs || 0,
|
||||||
|
startedAtMs: assetsStartedAtMs || t.startedAtMs || 0,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -330,84 +404,113 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
? data.regenerateAssetsJobs
|
? data.regenerateAssetsJobs
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const nextTasks: TaskItem[] = regenJobs
|
|
||||||
.map((job: any): TaskItem | null => {
|
|
||||||
const file = String(job?.file || '').trim()
|
|
||||||
if (!file) return null
|
|
||||||
|
|
||||||
const state = String(job?.state || '').trim().toLowerCase()
|
|
||||||
const error = String(job?.error || '').trim()
|
|
||||||
|
|
||||||
const startedAtMs =
|
|
||||||
Number(job?.startedAt ?? 0) ||
|
|
||||||
Number(job?.enqueuedAt ?? 0) ||
|
|
||||||
Date.now()
|
|
||||||
|
|
||||||
const cancelled =
|
|
||||||
state === 'cancelled' ||
|
|
||||||
error.toLowerCase() === 'abgebrochen' ||
|
|
||||||
error.toLowerCase() === 'abbruch' ||
|
|
||||||
error.toLowerCase() === 'cancelled' ||
|
|
||||||
error.toLowerCase() === 'canceled'
|
|
||||||
|
|
||||||
let status: TaskItem['status'] = 'idle'
|
|
||||||
if (state === 'queued' || state === 'running') {
|
|
||||||
status = 'running'
|
|
||||||
} else if (state === 'done') {
|
|
||||||
status = 'done'
|
|
||||||
} else if (cancelled) {
|
|
||||||
status = 'cancelled'
|
|
||||||
} else if (state === 'error') {
|
|
||||||
status = 'error'
|
|
||||||
}
|
|
||||||
|
|
||||||
const phases = Array.isArray(job?.phases) ? job.phases : []
|
|
||||||
|
|
||||||
const runningPhase = phases.find((p: any) => String(p?.state || '').toLowerCase() === 'running')
|
|
||||||
const queuedPhase = phases.find((p: any) => String(p?.state || '').toLowerCase() === 'queued')
|
|
||||||
|
|
||||||
const phaseLabel =
|
|
||||||
String(runningPhase?.label || '').trim() ||
|
|
||||||
String(queuedPhase?.label || '').trim()
|
|
||||||
|
|
||||||
const relevantPhases = phases.filter((p: any) => {
|
|
||||||
const q = String(p?.queue || '').toLowerCase()
|
|
||||||
const ph = String(p?.phase || '').toLowerCase()
|
|
||||||
return (
|
|
||||||
(q === 'postwork' && ['meta', 'thumb', 'teaser', 'sprites'].includes(ph)) ||
|
|
||||||
(q === 'enrich' && ph === 'analyze')
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const total = relevantPhases.length || 5
|
|
||||||
const done = relevantPhases.filter((p: any) => String(p?.state || '').toLowerCase() === 'done').length
|
|
||||||
|
|
||||||
const text =
|
|
||||||
status === 'running'
|
|
||||||
? phaseLabel
|
|
||||||
? `${shortTaskFilename(file, 38)} · ${phaseLabel}`
|
|
||||||
: shortTaskFilename(file, 52)
|
|
||||||
: shortTaskFilename(file, 52)
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: regenerateTaskId(file),
|
|
||||||
status,
|
|
||||||
title: regenerateTaskTitle(file),
|
|
||||||
text,
|
|
||||||
done: status === 'done' ? total : done,
|
|
||||||
total,
|
|
||||||
err: status === 'error' ? error || 'Fehler beim Regenerieren.' : undefined,
|
|
||||||
cancellable: status === 'running',
|
|
||||||
fading: false,
|
|
||||||
startedAtMs,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(Boolean) as TaskItem[]
|
|
||||||
|
|
||||||
setRegenerateAssetsTasks((prev) => {
|
setRegenerateAssetsTasks((prev) => {
|
||||||
const nextById = new Map(nextTasks.map((t) => [t.id, t]))
|
|
||||||
const prevById = new Map(prev.map((t) => [t.id, t]))
|
const prevById = new Map(prev.map((t) => [t.id, t]))
|
||||||
|
|
||||||
|
const nextTasks: TaskItem[] = regenJobs
|
||||||
|
.map((job: any): TaskItem | null => {
|
||||||
|
const file = String(job?.file || '').trim()
|
||||||
|
if (!file) return null
|
||||||
|
|
||||||
|
const id = regenerateTaskId(file)
|
||||||
|
const prevTask = prevById.get(id)
|
||||||
|
|
||||||
|
const state = String(job?.state || '').trim().toLowerCase()
|
||||||
|
const error = String(job?.error || '').trim()
|
||||||
|
|
||||||
|
const cancelled =
|
||||||
|
state === 'cancelled' ||
|
||||||
|
error.toLowerCase() === 'abgebrochen' ||
|
||||||
|
error.toLowerCase() === 'abbruch' ||
|
||||||
|
error.toLowerCase() === 'cancelled' ||
|
||||||
|
error.toLowerCase() === 'canceled'
|
||||||
|
|
||||||
|
let status: TaskItem['status'] = 'idle'
|
||||||
|
|
||||||
|
if (state === 'queued') {
|
||||||
|
status = 'queued'
|
||||||
|
} else if (state === 'running') {
|
||||||
|
status = 'running'
|
||||||
|
} else if (state === 'done') {
|
||||||
|
status = 'done'
|
||||||
|
} else if (cancelled) {
|
||||||
|
status = 'cancelled'
|
||||||
|
} else if (state === 'error') {
|
||||||
|
status = 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
const phases = Array.isArray(job?.phases) ? job.phases : []
|
||||||
|
|
||||||
|
const relevantPhases = phases
|
||||||
|
.filter((p: any) => {
|
||||||
|
const q = String(p?.queue || '').toLowerCase()
|
||||||
|
const ph = String(p?.phase || '').toLowerCase()
|
||||||
|
return (
|
||||||
|
(q === 'postwork' && ['meta', 'thumb', 'teaser', 'sprites'].includes(ph)) ||
|
||||||
|
(q === 'enrich' && ph === 'analyze')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.sort((a: any, b: any) => {
|
||||||
|
return regeneratePhaseOrder(a?.queue, a?.phase) - regeneratePhaseOrder(b?.queue, b?.phase)
|
||||||
|
})
|
||||||
|
|
||||||
|
const runningPhase = relevantPhases.find(
|
||||||
|
(p: any) => String(p?.state || '').toLowerCase() === 'running'
|
||||||
|
)
|
||||||
|
|
||||||
|
const queuedPhase = relevantPhases.find(
|
||||||
|
(p: any) => String(p?.state || '').toLowerCase() === 'queued'
|
||||||
|
)
|
||||||
|
|
||||||
|
const activePhase = runningPhase ?? queuedPhase
|
||||||
|
|
||||||
|
const phaseLabel = assetsPhaseLabel(
|
||||||
|
String(activePhase?.label || '').trim(),
|
||||||
|
String(activePhase?.phase || '').trim()
|
||||||
|
)
|
||||||
|
|
||||||
|
const total = relevantPhases.length || 5
|
||||||
|
const done = relevantPhases.filter(
|
||||||
|
(p: any) => String(p?.state || '').toLowerCase() === 'done'
|
||||||
|
).length
|
||||||
|
|
||||||
|
// Wichtig:
|
||||||
|
// Reihenfolge in der UI immer über den ersten bekannten Queue-Zeitpunkt stabil halten
|
||||||
|
const stableQueuedAtMs =
|
||||||
|
prevTask?.queuedAtMs ||
|
||||||
|
Number(job?.enqueuedAt ?? 0) ||
|
||||||
|
Number(job?.startedAt ?? 0) ||
|
||||||
|
Date.now()
|
||||||
|
|
||||||
|
const stableStartedAtMs =
|
||||||
|
Number(job?.startedAt ?? 0) ||
|
||||||
|
prevTask?.startedAtMs ||
|
||||||
|
0
|
||||||
|
|
||||||
|
const text =
|
||||||
|
status === 'running' || status === 'queued'
|
||||||
|
? phaseLabel
|
||||||
|
? `${shortTaskFilename(file, 38)} · ${phaseLabel}`
|
||||||
|
: shortTaskFilename(file, 52)
|
||||||
|
: shortTaskFilename(file, 52)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
title: regenerateTaskTitle(file),
|
||||||
|
text,
|
||||||
|
done: status === 'done' ? total : done,
|
||||||
|
total,
|
||||||
|
err: status === 'error' ? error || 'Fehler beim Regenerieren.' : undefined,
|
||||||
|
cancellable: status === 'queued' || status === 'running',
|
||||||
|
fading: prevTask?.fading ?? false,
|
||||||
|
queuedAtMs: stableQueuedAtMs,
|
||||||
|
startedAtMs: stableStartedAtMs,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean) as TaskItem[]
|
||||||
|
|
||||||
|
const nextById = new Map(nextTasks.map((t) => [t.id, t]))
|
||||||
const merged: TaskItem[] = []
|
const merged: TaskItem[] = []
|
||||||
|
|
||||||
for (const next of nextTasks) {
|
for (const next of nextTasks) {
|
||||||
@ -421,7 +524,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
|
|
||||||
for (const prevTask of prev) {
|
for (const prevTask of prev) {
|
||||||
if (nextById.has(prevTask.id)) continue
|
if (nextById.has(prevTask.id)) continue
|
||||||
if (prevTask.status === 'running') continue
|
if (prevTask.status === 'running' || prevTask.status === 'queued') continue
|
||||||
|
|
||||||
if (prevTask.fading) {
|
if (prevTask.fading) {
|
||||||
merged.push(prevTask)
|
merged.push(prevTask)
|
||||||
continue
|
continue
|
||||||
@ -429,24 +533,146 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
|
|
||||||
merged.push({ ...prevTask, fading: true })
|
merged.push({ ...prevTask, fading: true })
|
||||||
|
|
||||||
const file = prevTask.id.startsWith('regenerate-assets:')
|
window.setTimeout(() => {
|
||||||
? prevTask.id.slice('regenerate-assets:'.length)
|
setRegenerateAssetsTasks((cur) => cur.filter((t) => t.id !== prevTask.id))
|
||||||
: ''
|
}, 500)
|
||||||
|
|
||||||
if (file) {
|
|
||||||
window.setTimeout(() => {
|
|
||||||
setRegenerateAssetsTasks((cur) => cur.filter((t) => t.id !== prevTask.id))
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return merged
|
return merged
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const checkVideos = data.checkVideos
|
||||||
|
const checkVideosQueuedAtMs = parseTaskTimeMs(checkVideos?.queuedAt)
|
||||||
|
const checkVideosStartedAtMs = parseTaskTimeMs(checkVideos?.startedAt)
|
||||||
|
|
||||||
|
if (checkVideos?.queued) {
|
||||||
|
setCheckVideosTask((t) => ({
|
||||||
|
...t,
|
||||||
|
status: 'queued',
|
||||||
|
title: 'Downloads prüfen',
|
||||||
|
text: String(checkVideos.text || 'Wartet…'),
|
||||||
|
done: Number(checkVideos.done ?? 0),
|
||||||
|
total: Number(checkVideos.total ?? 0),
|
||||||
|
err: undefined,
|
||||||
|
cancellable: true,
|
||||||
|
fading: false,
|
||||||
|
queuedAtMs: t.queuedAtMs || checkVideosQueuedAtMs || Date.now(),
|
||||||
|
startedAtMs: checkVideosStartedAtMs || 0,
|
||||||
|
}))
|
||||||
|
} else if (checkVideos?.running) {
|
||||||
|
const currentFile = String(checkVideos.currentFile || '').trim()
|
||||||
|
|
||||||
|
setCheckVideosTask((t) => ({
|
||||||
|
...t,
|
||||||
|
status: 'running',
|
||||||
|
title: 'Downloads prüfen',
|
||||||
|
text: currentFile ? shortTaskFilename(currentFile, 52) : 'Prüfe Videos…',
|
||||||
|
done: Number(checkVideos.done ?? 0),
|
||||||
|
total: Number(checkVideos.total ?? 0),
|
||||||
|
err: undefined,
|
||||||
|
cancellable: true,
|
||||||
|
fading: false,
|
||||||
|
queuedAtMs: t.queuedAtMs || checkVideosQueuedAtMs || Date.now(),
|
||||||
|
startedAtMs: checkVideosStartedAtMs || t.startedAtMs || Date.now(),
|
||||||
|
}))
|
||||||
|
} else if (checkVideos?.error) {
|
||||||
|
const checkErr = String(checkVideos.error || '').trim()
|
||||||
|
const cancelled =
|
||||||
|
checkErr.toLowerCase() === 'abgebrochen' ||
|
||||||
|
checkErr.toLowerCase() === 'abbruch' ||
|
||||||
|
checkErr.toLowerCase() === 'cancelled' ||
|
||||||
|
checkErr.toLowerCase() === 'canceled'
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
checkVideosCancelRequestedRef.current = false
|
||||||
|
setCheckVideosTask((t) => ({
|
||||||
|
...t,
|
||||||
|
status: 'idle',
|
||||||
|
title: 'Downloads prüfen',
|
||||||
|
text: '',
|
||||||
|
done: 0,
|
||||||
|
total: 0,
|
||||||
|
err: undefined,
|
||||||
|
cancellable: true,
|
||||||
|
fading: false,
|
||||||
|
queuedAtMs: 0,
|
||||||
|
startedAtMs: 0,
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCheckVideosTask((t) => ({
|
||||||
|
...t,
|
||||||
|
status: 'error',
|
||||||
|
title: 'Downloads prüfen',
|
||||||
|
text: String(checkVideos.text || ''),
|
||||||
|
done: Number(checkVideos.done ?? 0),
|
||||||
|
total: Number(checkVideos.total ?? 0),
|
||||||
|
err: checkErr,
|
||||||
|
cancellable: false,
|
||||||
|
fading: false,
|
||||||
|
queuedAtMs: t.queuedAtMs || checkVideosQueuedAtMs || 0,
|
||||||
|
startedAtMs: checkVideosStartedAtMs || t.startedAtMs || 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
fadeOutTask(setCheckVideosTask)
|
||||||
|
} else if (checkVideos?.finishedAt) {
|
||||||
|
setCheckVideosTask((t) => ({
|
||||||
|
...t,
|
||||||
|
status: 'done',
|
||||||
|
title: 'Downloads prüfen',
|
||||||
|
text: String(checkVideos.text || 'Prüfung abgeschlossen.'),
|
||||||
|
done: Number(checkVideos.done ?? 0),
|
||||||
|
total: Number(checkVideos.total ?? 0),
|
||||||
|
err: undefined,
|
||||||
|
cancellable: false,
|
||||||
|
fading: false,
|
||||||
|
queuedAtMs: t.queuedAtMs || checkVideosQueuedAtMs || 0,
|
||||||
|
startedAtMs: checkVideosStartedAtMs || t.startedAtMs || 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('finished-downloads:integrity-refresh', {
|
||||||
|
detail: { all: true },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
fadeOutTask(setCheckVideosTask)
|
||||||
|
} else {
|
||||||
|
setCheckVideosTask((t) => ({
|
||||||
|
...t,
|
||||||
|
status: 'idle',
|
||||||
|
title: 'Downloads prüfen',
|
||||||
|
text: '',
|
||||||
|
done: 0,
|
||||||
|
total: 0,
|
||||||
|
err: undefined,
|
||||||
|
cancellable: true,
|
||||||
|
fading: false,
|
||||||
|
queuedAtMs: 0,
|
||||||
|
startedAtMs: 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
const cleanup = data.cleanup
|
const cleanup = data.cleanup
|
||||||
|
const cleanupQueuedAtMs = parseTaskTimeMs(cleanup?.queuedAt)
|
||||||
|
const cleanupStartedAtMs = parseTaskTimeMs(cleanup?.startedAt)
|
||||||
|
|
||||||
if (cleanup?.running) {
|
if (cleanup?.queued) {
|
||||||
setCleaning(true)
|
setCleanupTask((t) => ({
|
||||||
|
...t,
|
||||||
|
status: 'queued',
|
||||||
|
title: 'Aufräumen',
|
||||||
|
text: String(cleanup.text || cleanup.currentFile || 'Wartet…'),
|
||||||
|
done: Number(cleanup.done ?? 0),
|
||||||
|
total: Number(cleanup.total ?? 0),
|
||||||
|
err: undefined,
|
||||||
|
cancellable: false,
|
||||||
|
fading: false,
|
||||||
|
queuedAtMs: t.queuedAtMs || cleanupQueuedAtMs || Date.now(),
|
||||||
|
startedAtMs: cleanupStartedAtMs || 0,
|
||||||
|
}))
|
||||||
|
} else if (cleanup?.running) {
|
||||||
setCleanupTask((t) => ({
|
setCleanupTask((t) => ({
|
||||||
...t,
|
...t,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
@ -457,9 +683,10 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
err: undefined,
|
err: undefined,
|
||||||
cancellable: false,
|
cancellable: false,
|
||||||
fading: false,
|
fading: false,
|
||||||
|
queuedAtMs: t.queuedAtMs || cleanupQueuedAtMs || Date.now(),
|
||||||
|
startedAtMs: cleanupStartedAtMs || t.startedAtMs || Date.now(),
|
||||||
}))
|
}))
|
||||||
} else if (cleanup?.error) {
|
} else if (cleanup?.error) {
|
||||||
setCleaning(false)
|
|
||||||
setCleanupTask((t) => ({
|
setCleanupTask((t) => ({
|
||||||
...t,
|
...t,
|
||||||
status: 'error',
|
status: 'error',
|
||||||
@ -470,9 +697,10 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
err: String(cleanup.error),
|
err: String(cleanup.error),
|
||||||
cancellable: false,
|
cancellable: false,
|
||||||
fading: false,
|
fading: false,
|
||||||
|
queuedAtMs: t.queuedAtMs || cleanupQueuedAtMs || 0,
|
||||||
|
startedAtMs: cleanupStartedAtMs || t.startedAtMs || 0,
|
||||||
}))
|
}))
|
||||||
} else if (cleanup?.finishedAt || cleanup?.text) {
|
} else if (cleanup?.finishedAt) {
|
||||||
setCleaning(false)
|
|
||||||
setCleanupTask((t) => ({
|
setCleanupTask((t) => ({
|
||||||
...t,
|
...t,
|
||||||
status: 'done',
|
status: 'done',
|
||||||
@ -483,9 +711,10 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
err: undefined,
|
err: undefined,
|
||||||
cancellable: false,
|
cancellable: false,
|
||||||
fading: false,
|
fading: false,
|
||||||
|
queuedAtMs: t.queuedAtMs || cleanupQueuedAtMs || 0,
|
||||||
|
startedAtMs: cleanupStartedAtMs || t.startedAtMs || 0,
|
||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
setCleaning(false)
|
|
||||||
setCleanupTask((t) => ({
|
setCleanupTask((t) => ({
|
||||||
...t,
|
...t,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
@ -496,6 +725,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
err: undefined,
|
err: undefined,
|
||||||
cancellable: true,
|
cancellable: true,
|
||||||
fading: false,
|
fading: false,
|
||||||
|
queuedAtMs: 0,
|
||||||
|
startedAtMs: 0,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@ -601,6 +832,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
const teaserAudio = !!value.teaserAudio
|
const teaserAudio = !!value.teaserAudio
|
||||||
const lowDiskPauseBelowGB = Math.max(1, Math.floor(Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB)))
|
const lowDiskPauseBelowGB = Math.max(1, Math.floor(Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB)))
|
||||||
const enableNotifications = !!value.enableNotifications
|
const enableNotifications = !!value.enableNotifications
|
||||||
|
const generateAssetsMeta = !!value.generateAssetsMeta
|
||||||
|
const generateAssetsThumb = !!value.generateAssetsThumb
|
||||||
|
const generateAssetsTeaser = !!value.generateAssetsTeaser
|
||||||
|
const generateAssetsSprites = !!value.generateAssetsSprites
|
||||||
|
const generateAssetsAnalyze = !!value.generateAssetsAnalyze
|
||||||
|
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
@ -625,13 +861,17 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
teaserAudio,
|
teaserAudio,
|
||||||
lowDiskPauseBelowGB,
|
lowDiskPauseBelowGB,
|
||||||
enableNotifications,
|
enableNotifications,
|
||||||
|
generateAssetsMeta,
|
||||||
|
generateAssetsThumb,
|
||||||
|
generateAssetsTeaser,
|
||||||
|
generateAssetsSprites,
|
||||||
|
generateAssetsAnalyze,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const t = await res.text().catch(() => '')
|
const t = await res.text().catch(() => '')
|
||||||
throw new Error(t || `HTTP ${res.status}`)
|
throw new Error(t || `HTTP ${res.status}`)
|
||||||
}
|
}
|
||||||
setMsg('✅ Gespeichert.')
|
|
||||||
// Button-Label: "Gespeichert" für 2.5s
|
// Button-Label: "Gespeichert" für 2.5s
|
||||||
const until = Date.now() + 2500
|
const until = Date.now() + 2500
|
||||||
setSaveSuccessUntilMs(until)
|
setSaveSuccessUntilMs(until)
|
||||||
@ -669,7 +909,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fadeOutTask(setter: React.Dispatch<React.SetStateAction<TaskItem>>, delayMs = 3000, fadeMs = 500) {
|
function fadeOutTask(setter: Dispatch<SetStateAction<TaskItem>>, delayMs = 3000, fadeMs = 500) {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
setter((t) => ({ ...t, fading: true }))
|
setter((t) => ({ ...t, fading: true }))
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
@ -681,6 +921,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
done: 0,
|
done: 0,
|
||||||
total: 0,
|
total: 0,
|
||||||
fading: false,
|
fading: false,
|
||||||
|
queuedAtMs: 0,
|
||||||
startedAtMs: 0,
|
startedAtMs: 0,
|
||||||
}))
|
}))
|
||||||
}, fadeMs)
|
}, fadeMs)
|
||||||
@ -703,7 +944,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
|
|
||||||
async function cleanupSmallDone() {
|
async function cleanupSmallDone() {
|
||||||
setErr(null)
|
setErr(null)
|
||||||
setMsg(null)
|
|
||||||
|
|
||||||
const mb = Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB ?? 0)
|
const mb = Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB ?? 0)
|
||||||
const doneDir = (value.doneDir || DEFAULTS.doneDir).trim()
|
const doneDir = (value.doneDir || DEFAULTS.doneDir).trim()
|
||||||
@ -725,18 +965,20 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
)
|
)
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
|
|
||||||
// ✅ Task starten (als letzter Eintrag in TaskList)
|
// Sofort in TaskList sichtbar machen
|
||||||
setCleaning(true)
|
const now = Date.now()
|
||||||
|
|
||||||
setCleanupTask((t) => ({
|
setCleanupTask((t) => ({
|
||||||
...t,
|
...t,
|
||||||
status: 'running',
|
status: 'queued',
|
||||||
title: 'Aufräumen',
|
title: 'Aufräumen',
|
||||||
text: 'Räume auf…',
|
text: 'Wartet…',
|
||||||
err: undefined,
|
err: undefined,
|
||||||
done: 0,
|
done: 0,
|
||||||
total: 0,
|
total: 0,
|
||||||
fading: false,
|
fading: false,
|
||||||
startedAtMs: t.startedAtMs || Date.now(),
|
queuedAtMs: t.queuedAtMs || now,
|
||||||
|
startedAtMs: 0,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -745,27 +987,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const t = await res.text().catch(() => '')
|
const t = await res.text().catch(() => '')
|
||||||
throw new Error(t || `HTTP ${res.status}`)
|
throw new Error(t || `HTTP ${res.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json()
|
|
||||||
|
|
||||||
const scannedFiles = Number(data.scannedFiles ?? 0)
|
|
||||||
const deletedFiles = Number(data.deletedFiles ?? 0)
|
|
||||||
const deletedBytesHuman = String(data.deletedBytesHuman ?? '0 B')
|
|
||||||
|
|
||||||
setCleanupTask((t) => ({
|
|
||||||
...t,
|
|
||||||
status: 'done',
|
|
||||||
done: scannedFiles,
|
|
||||||
total: scannedFiles,
|
|
||||||
title: 'Aufräumen',
|
|
||||||
text: `gelöscht: ${deletedFiles}/${scannedFiles} · freigegeben: ${deletedBytesHuman}`,
|
|
||||||
}))
|
|
||||||
|
|
||||||
fadeOutTask(setCleanupTask, 7000, 500)
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = e?.message ?? String(e)
|
const msg = e?.message ?? String(e)
|
||||||
setErr(msg)
|
setErr(msg)
|
||||||
@ -773,13 +999,14 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
setCleanupTask((t) => ({
|
setCleanupTask((t) => ({
|
||||||
...t,
|
...t,
|
||||||
status: 'error',
|
status: 'error',
|
||||||
text: 'Fehler beim Aufräumen.',
|
title: 'Aufräumen',
|
||||||
|
text: 'Fehler beim Starten des Aufräumens.',
|
||||||
err: msg,
|
err: msg,
|
||||||
|
cancellable: false,
|
||||||
|
fading: false,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
fadeOutTask(setCleanupTask, 7000, 500)
|
fadeOutTask(setCleanupTask, 7000, 500)
|
||||||
} finally {
|
|
||||||
setCleaning(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -807,6 +1034,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
total: 0,
|
total: 0,
|
||||||
cancellable: true,
|
cancellable: true,
|
||||||
fading: false,
|
fading: false,
|
||||||
|
queuedAtMs: 0,
|
||||||
startedAtMs: 0,
|
startedAtMs: 0,
|
||||||
}))
|
}))
|
||||||
}, 500)
|
}, 500)
|
||||||
@ -860,7 +1088,50 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
fadeOutRegenerateTask(cleanFile)
|
fadeOutRegenerateTask(cleanFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleTasks = [assetsTask, ...regenerateAssetsTasks, cleanupTask].filter(
|
async function cancelCheckVideosTask() {
|
||||||
|
checkVideosCancelRequestedRef.current = true
|
||||||
|
|
||||||
|
const ac = checkVideosAbortRef.current
|
||||||
|
checkVideosAbortRef.current = null
|
||||||
|
|
||||||
|
setCheckVideosTask((t) => ({
|
||||||
|
...t,
|
||||||
|
cancellable: false,
|
||||||
|
fading: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setCheckVideosTask((t) => ({
|
||||||
|
...t,
|
||||||
|
status: 'idle',
|
||||||
|
title: 'Downloads prüfen',
|
||||||
|
text: '',
|
||||||
|
err: undefined,
|
||||||
|
done: 0,
|
||||||
|
total: 0,
|
||||||
|
cancellable: true,
|
||||||
|
fading: false,
|
||||||
|
queuedAtMs: 0,
|
||||||
|
startedAtMs: 0,
|
||||||
|
}))
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
if (ac) {
|
||||||
|
ac.abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch('/api/tasks/check-videos', {
|
||||||
|
method: 'DELETE',
|
||||||
|
cache: 'no-store' as any,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleTasks = [assetsTask, ...regenerateAssetsTasks, checkVideosTask, cleanupTask].filter(
|
||||||
(t) => t.status !== 'idle'
|
(t) => t.status !== 'idle'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -908,6 +1179,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
void cancelRegenerateAssetsTask(file)
|
void cancelRegenerateAssetsTask(file)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (id === 'check-videos') {
|
||||||
|
void cancelCheckVideosTask()
|
||||||
|
return
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -941,7 +1217,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-3">
|
||||||
<Task
|
<Task
|
||||||
title="Assets-Generator"
|
title="Assets-Generator"
|
||||||
description="Erzeugt nur fehlende oder unvollständige Assets (Meta, Vorschaubild, Teaser, Sprites, KI-Analyse). Bereits vorhandene Assets werden übersprungen."
|
description="Erzeugt nur fehlende oder unvollständige Assets der aktivierten Phasen. Nicht ausgewählte Phasen werden komplett übersprungen."
|
||||||
startLabel="Start"
|
startLabel="Start"
|
||||||
startingLabel="Starte…"
|
startingLabel="Starte…"
|
||||||
startUrl="/api/tasks/generate-assets"
|
startUrl="/api/tasks/generate-assets"
|
||||||
@ -960,7 +1236,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
err: undefined,
|
err: undefined,
|
||||||
cancellable: true,
|
cancellable: true,
|
||||||
fading: false,
|
fading: false,
|
||||||
startedAtMs: t.startedAtMs || Date.now(),
|
queuedAtMs: t.queuedAtMs || Date.now(),
|
||||||
|
startedAtMs: 0,
|
||||||
}))
|
}))
|
||||||
}}
|
}}
|
||||||
onStart={(ac) => {
|
onStart={(ac) => {
|
||||||
@ -976,7 +1253,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
err: undefined,
|
err: undefined,
|
||||||
cancellable: true,
|
cancellable: true,
|
||||||
fading: false,
|
fading: false,
|
||||||
startedAtMs: t.startedAtMs || Date.now(),
|
queuedAtMs: t.queuedAtMs || Date.now(),
|
||||||
|
startedAtMs: Date.now(),
|
||||||
}))
|
}))
|
||||||
}}
|
}}
|
||||||
onProgress={(p) => {
|
onProgress={(p) => {
|
||||||
@ -1027,13 +1305,152 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<LabeledSwitch
|
||||||
|
checked={!!value.generateAssetsMeta}
|
||||||
|
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsMeta: checked }))}
|
||||||
|
label="Meta"
|
||||||
|
description="Erzeugt oder ergänzt die meta.json mit Dauer, Auflösung, FPS und weiteren Videoinformationen."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LabeledSwitch
|
||||||
|
checked={!!value.generateAssetsThumb}
|
||||||
|
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsThumb: checked }))}
|
||||||
|
label="Vorschaubild"
|
||||||
|
description="Erzeugt das statische Vorschaubild preview.jpg für Listen und Kartenansicht."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LabeledSwitch
|
||||||
|
checked={!!value.generateAssetsTeaser}
|
||||||
|
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsTeaser: checked }))}
|
||||||
|
label="Teaser"
|
||||||
|
description="Erzeugt die kurze Video-Vorschau preview.mp4 für Hover- und Preview-Wiedergabe."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LabeledSwitch
|
||||||
|
checked={!!value.generateAssetsSprites}
|
||||||
|
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsSprites: checked }))}
|
||||||
|
label="Sprites"
|
||||||
|
description="Erzeugt das Sprite-Bild preview-sprite.jpg für die Scrubber- und Timeline-Vorschau."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LabeledSwitch
|
||||||
|
checked={!!value.generateAssetsAnalyze}
|
||||||
|
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsAnalyze: checked }))}
|
||||||
|
label="Analyse"
|
||||||
|
description="Führt die Analyse aus und ergänzt Treffer, Segmente und weitere Auswertungsdaten."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Task
|
||||||
|
title="Downloads prüfen"
|
||||||
|
description="Prüft fertige Videos anhand der preview-sprite.jpg auf schwarze Endzellen und schreibt das Ergebnis in die meta.json."
|
||||||
|
startLabel="Prüfen"
|
||||||
|
startingLabel="Starte…"
|
||||||
|
startUrl="/api/tasks/check-videos"
|
||||||
|
stopUrl="/api/tasks/check-videos"
|
||||||
|
sseUrl="/api/events/stream"
|
||||||
|
onQueued={() => {
|
||||||
|
checkVideosCancelRequestedRef.current = false
|
||||||
|
setCheckVideosTask((t: TaskItem) => ({
|
||||||
|
...t,
|
||||||
|
status: 'queued',
|
||||||
|
title: 'Downloads prüfen',
|
||||||
|
text: 'Wartet…',
|
||||||
|
done: 0,
|
||||||
|
total: 0,
|
||||||
|
err: undefined,
|
||||||
|
cancellable: true,
|
||||||
|
fading: false,
|
||||||
|
queuedAtMs: t.queuedAtMs || Date.now(),
|
||||||
|
startedAtMs: 0,
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
onStart={(ac) => {
|
||||||
|
checkVideosCancelRequestedRef.current = false
|
||||||
|
checkVideosAbortRef.current = ac
|
||||||
|
setCheckVideosTask((t: TaskItem) => ({
|
||||||
|
...t,
|
||||||
|
status: 'running',
|
||||||
|
title: 'Downloads prüfen',
|
||||||
|
text: '',
|
||||||
|
done: 0,
|
||||||
|
total: 0,
|
||||||
|
err: undefined,
|
||||||
|
cancellable: true,
|
||||||
|
fading: false,
|
||||||
|
queuedAtMs: t.queuedAtMs || Date.now(),
|
||||||
|
startedAtMs: Date.now(),
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
onProgress={(p) => {
|
||||||
|
const currentFile = String((p as any).currentFile ?? '').trim()
|
||||||
|
|
||||||
|
setCheckVideosTask((t: TaskItem) => ({
|
||||||
|
...t,
|
||||||
|
status: 'running',
|
||||||
|
title: 'Downloads prüfen',
|
||||||
|
text: currentFile ? shortTaskFilename(currentFile, 52) : 'Prüfe Videos…',
|
||||||
|
done: Number((p as any).done ?? 0),
|
||||||
|
total: Number((p as any).total ?? 0),
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
onDone={() => {
|
||||||
|
checkVideosAbortRef.current = null
|
||||||
|
|
||||||
|
setCheckVideosTask((t: TaskItem) => ({
|
||||||
|
...t,
|
||||||
|
status: 'done',
|
||||||
|
title: 'Downloads prüfen',
|
||||||
|
text: 'Prüfung abgeschlossen.',
|
||||||
|
}))
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('finished-downloads:integrity-refresh', {
|
||||||
|
detail: { all: true },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
fadeOutTask(setCheckVideosTask)
|
||||||
|
}}
|
||||||
|
onCancelled={() => {
|
||||||
|
checkVideosAbortRef.current = null
|
||||||
|
|
||||||
|
if (checkVideosCancelRequestedRef.current) {
|
||||||
|
checkVideosCancelRequestedRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCheckVideosTask((t: TaskItem) => ({
|
||||||
|
...t,
|
||||||
|
status: 'cancelled',
|
||||||
|
title: 'Downloads prüfen',
|
||||||
|
text: '',
|
||||||
|
}))
|
||||||
|
fadeOutTask(setCheckVideosTask)
|
||||||
|
}}
|
||||||
|
onError={(message) => {
|
||||||
|
checkVideosAbortRef.current = null
|
||||||
|
|
||||||
|
setCheckVideosTask((t: TaskItem) => ({
|
||||||
|
...t,
|
||||||
|
status: 'error',
|
||||||
|
title: 'Downloads prüfen',
|
||||||
|
text: 'Fehler bei der Video-Prüfung.',
|
||||||
|
err: message,
|
||||||
|
}))
|
||||||
|
fadeOutTask(setCheckVideosTask)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<Task
|
<Task
|
||||||
title="Aufräumen"
|
title="Aufräumen"
|
||||||
description='Löscht Dateien im doneDir kleiner als die Mindestgröße (Ordner "keep" wird übersprungen) und entfernt verwaiste Assets.'
|
description='Löscht Dateien im doneDir kleiner als die Mindestgröße (Ordner "keep" wird übersprungen) und entfernt verwaiste Assets.'
|
||||||
startLabel="Aufräumen"
|
startLabel="Aufräumen"
|
||||||
startingLabel="Läuft…"
|
startingLabel="Läuft…"
|
||||||
onTrigger={cleanupSmallDone}
|
onTrigger={cleanupSmallDone}
|
||||||
busy={cleaning}
|
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onError={(message) => {
|
onError={(message) => {
|
||||||
// Optional: zusätzlicher Fallback für Startfehler-Anzeige direkt im Task
|
// Optional: zusätzlicher Fallback für Startfehler-Anzeige direkt im Task
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import { useId, type SyntheticEvent, type CSSProperties } from 'react'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
id?: string
|
id?: string
|
||||||
@ -65,7 +65,7 @@ export default function Slider({
|
|||||||
className = '',
|
className = '',
|
||||||
inputClassName = '',
|
inputClassName = '',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const reactId = React.useId()
|
const reactId = useId()
|
||||||
const inputId = id ?? `slider-${reactId}`
|
const inputId = id ?? `slider-${reactId}`
|
||||||
|
|
||||||
const safeValue = Number.isFinite(value) ? value : min
|
const safeValue = Number.isFinite(value) ? value : min
|
||||||
@ -95,7 +95,7 @@ export default function Slider({
|
|||||||
onChange(clamp(next, min, max))
|
onChange(clamp(next, min, max))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCommit = (e: React.SyntheticEvent<HTMLInputElement>) => {
|
const handleCommit = (e: SyntheticEvent<HTMLInputElement>) => {
|
||||||
onChangeEnd?.(clamp(Number(e.currentTarget.value), min, max))
|
onChangeEnd?.(clamp(Number(e.currentTarget.value), min, max))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,7 +198,7 @@ export default function Slider({
|
|||||||
['--slider-track-h' as any]: `${trackH}px`,
|
['--slider-track-h' as any]: `${trackH}px`,
|
||||||
['--slider-thumb-size' as any]: `${thumbSize}px`,
|
['--slider-thumb-size' as any]: `${thumbSize}px`,
|
||||||
['--slider-thumb-offset' as any]: `${thumbOffset}px`,
|
['--slider-thumb-offset' as any]: `${thumbOffset}px`,
|
||||||
} as React.CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
onChange={(e) => handleChange(Number(e.target.value))}
|
onChange={(e) => handleChange(Number(e.target.value))}
|
||||||
onPointerUp={handleCommit}
|
onPointerUp={handleCommit}
|
||||||
@ -389,7 +389,7 @@ export default function Slider({
|
|||||||
['--slider-track-h' as any]: `${trackH}px`,
|
['--slider-track-h' as any]: `${trackH}px`,
|
||||||
['--slider-thumb-size' as any]: `${thumbSize}px`,
|
['--slider-thumb-size' as any]: `${thumbSize}px`,
|
||||||
['--slider-thumb-offset' as any]: `${thumbOffset}px`,
|
['--slider-thumb-offset' as any]: `${thumbOffset}px`,
|
||||||
} as React.CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
onChange={(e) => handleChange(Number(e.target.value))}
|
onChange={(e) => handleChange(Number(e.target.value))}
|
||||||
onPointerUp={handleCommit}
|
onPointerUp={handleCommit}
|
||||||
|
|||||||
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'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useRef, useState } from 'react'
|
import {
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
useCallback,
|
||||||
|
type ReactNode,
|
||||||
|
type PointerEvent,
|
||||||
|
type MouseEvent as ReactMouseEvent,
|
||||||
|
} from 'react'
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.ReactNode
|
children: ReactNode
|
||||||
actionLabel: string
|
actionLabel: string
|
||||||
actionColor?: 'red' | 'indigo'
|
actionColor?: 'red' | 'indigo'
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
@ -89,7 +95,7 @@ export default function SwipeActionRow({
|
|||||||
return -Math.min(resisted, width + MAX_OVERSWIPE, Math.abs(maxLeft))
|
return -Math.min(resisted, width + MAX_OVERSWIPE, Math.abs(maxLeft))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
const onPointerDown = useCallback((e: PointerEvent<HTMLDivElement>) => {
|
||||||
if (disabled) return
|
if (disabled) return
|
||||||
if (e.pointerType === 'mouse') return
|
if (e.pointerType === 'mouse') return
|
||||||
|
|
||||||
@ -111,7 +117,7 @@ export default function SwipeActionRow({
|
|||||||
}
|
}
|
||||||
}, [disabled])
|
}, [disabled])
|
||||||
|
|
||||||
const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
const onPointerMove = useCallback((e: PointerEvent<HTMLDivElement>) => {
|
||||||
if (!dragging) return
|
if (!dragging) return
|
||||||
if (pointerIdRef.current !== e.pointerId) return
|
if (pointerIdRef.current !== e.pointerId) return
|
||||||
|
|
||||||
@ -129,7 +135,7 @@ export default function SwipeActionRow({
|
|||||||
setDragX(next)
|
setDragX(next)
|
||||||
}, [dragging, applyDragResistance])
|
}, [dragging, applyDragResistance])
|
||||||
|
|
||||||
const onPointerEnd = useCallback(async (e: React.PointerEvent<HTMLDivElement>) => {
|
const onPointerEnd = useCallback(async (e: PointerEvent<HTMLDivElement>) => {
|
||||||
if (!dragging) return
|
if (!dragging) return
|
||||||
if (pointerIdRef.current !== e.pointerId) return
|
if (pointerIdRef.current !== e.pointerId) return
|
||||||
|
|
||||||
@ -167,9 +173,9 @@ export default function SwipeActionRow({
|
|||||||
setDragX(0)
|
setDragX(0)
|
||||||
}, [dragging, dragX, getRevealWidth, runAction])
|
}, [dragging, dragX, getRevealWidth, runAction])
|
||||||
|
|
||||||
const onActionClick = useCallback(async (e: React.MouseEvent) => {
|
const onActionClick = useCallback((e: ReactMouseEvent<HTMLDivElement>) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
await runAction()
|
void runAction()
|
||||||
}, [runAction])
|
}, [runAction])
|
||||||
|
|
||||||
const revealedPx = Math.max(0, Math.abs(dragX))
|
const revealedPx = Math.max(0, Math.abs(dragX))
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import { useEffect, useRef, useState, useCallback, type ReactNode, forwardRef, useImperativeHandle } from 'react'
|
||||||
import { FireIcon as FireSolidIcon } from '@heroicons/react/24/solid'
|
import { FireIcon as FireSolidIcon } from '@heroicons/react/24/solid'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
|
||||||
@ -31,12 +31,12 @@ function getGlobalFxLayer(): HTMLDivElement | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type SwipeAction = {
|
export type SwipeAction = {
|
||||||
label: React.ReactNode
|
label: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SwipeCardProps = {
|
export type SwipeCardProps = {
|
||||||
children: React.ReactNode
|
children: ReactNode
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
onTap?: () => void
|
onTap?: () => void
|
||||||
@ -93,7 +93,7 @@ function rubberBand(delta: number, width: number) {
|
|||||||
return sign * Math.min(resisted, width + MAX_OVERSWIPE_PX)
|
return sign * Math.min(resisted, width + MAX_OVERSWIPE_PX)
|
||||||
}
|
}
|
||||||
|
|
||||||
const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard(
|
const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard(
|
||||||
{
|
{
|
||||||
children,
|
children,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
@ -120,26 +120,26 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
const cardRef = React.useRef<HTMLDivElement | null>(null)
|
const cardRef = useRef<HTMLDivElement | null>(null)
|
||||||
const outerRef = React.useRef<HTMLDivElement | null>(null)
|
const outerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
const doubleTapBusyRef = React.useRef(false)
|
const doubleTapBusyRef = useRef(false)
|
||||||
const pressTimerRef = React.useRef<number | null>(null)
|
const pressTimerRef = useRef<number | null>(null)
|
||||||
const longPressActiveRef = React.useRef(false)
|
const longPressActiveRef = useRef(false)
|
||||||
|
|
||||||
const rafRef = React.useRef<number | null>(null)
|
const rafRef = useRef<number | null>(null)
|
||||||
const dxRef = React.useRef(0)
|
const dxRef = useRef(0)
|
||||||
const thresholdRef = React.useRef(0)
|
const thresholdRef = useRef(0)
|
||||||
const widthRef = React.useRef(360)
|
const widthRef = useRef(360)
|
||||||
|
|
||||||
const tapTimerRef = React.useRef<number | null>(null)
|
const tapTimerRef = useRef<number | null>(null)
|
||||||
const lastTapRef = React.useRef<{ t: number; x: number; y: number } | null>(null)
|
const lastTapRef = useRef<{ t: number; x: number; y: number } | null>(null)
|
||||||
|
|
||||||
const velocityXRef = React.useRef(0)
|
const velocityXRef = useRef(0)
|
||||||
const lastMoveXRef = React.useRef(0)
|
const lastMoveXRef = useRef(0)
|
||||||
const lastMoveTRef = React.useRef(0)
|
const lastMoveTRef = useRef(0)
|
||||||
|
|
||||||
const pointer = React.useRef<{
|
const pointer = useRef<{
|
||||||
id: number | null
|
id: number | null
|
||||||
startX: number
|
startX: number
|
||||||
startY: number
|
startY: number
|
||||||
@ -159,25 +159,25 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
axisLocked: null,
|
axisLocked: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const [dx, setDx] = React.useState(0)
|
const [dx, setDx] = useState(0)
|
||||||
const [armedDir, setArmedDir] = React.useState<null | 'left' | 'right'>(null)
|
const [armedDir, setArmedDir] = useState<null | 'left' | 'right'>(null)
|
||||||
const [animMs, setAnimMs] = React.useState(0)
|
const [animMs, setAnimMs] = useState(0)
|
||||||
|
|
||||||
const clearTapTimer = React.useCallback(() => {
|
const clearTapTimer = useCallback(() => {
|
||||||
if (tapTimerRef.current != null) {
|
if (tapTimerRef.current != null) {
|
||||||
window.clearTimeout(tapTimerRef.current)
|
window.clearTimeout(tapTimerRef.current)
|
||||||
tapTimerRef.current = null
|
tapTimerRef.current = null
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const clearPressTimer = React.useCallback(() => {
|
const clearPressTimer = useCallback(() => {
|
||||||
if (pressTimerRef.current != null) {
|
if (pressTimerRef.current != null) {
|
||||||
window.clearTimeout(pressTimerRef.current)
|
window.clearTimeout(pressTimerRef.current)
|
||||||
pressTimerRef.current = null
|
pressTimerRef.current = null
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const endLongPress = React.useCallback(() => {
|
const endLongPress = useCallback(() => {
|
||||||
clearPressTimer()
|
clearPressTimer()
|
||||||
|
|
||||||
if (!longPressActiveRef.current) return
|
if (!longPressActiveRef.current) return
|
||||||
@ -186,7 +186,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
onPressEnd?.()
|
onPressEnd?.()
|
||||||
}, [clearPressTimer, onPressEnd])
|
}, [clearPressTimer, onPressEnd])
|
||||||
|
|
||||||
const flushDx = React.useCallback((nextDx: number) => {
|
const flushDx = useCallback((nextDx: number) => {
|
||||||
dxRef.current = nextDx
|
dxRef.current = nextDx
|
||||||
if (rafRef.current != null) return
|
if (rafRef.current != null) return
|
||||||
rafRef.current = requestAnimationFrame(() => {
|
rafRef.current = requestAnimationFrame(() => {
|
||||||
@ -195,7 +195,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const reset = React.useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
clearPressTimer()
|
clearPressTimer()
|
||||||
longPressActiveRef.current = false
|
longPressActiveRef.current = false
|
||||||
|
|
||||||
@ -220,7 +220,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
}, snapMs)
|
}, snapMs)
|
||||||
}, [snapMs, clearPressTimer])
|
}, [snapMs, clearPressTimer])
|
||||||
|
|
||||||
const commit = React.useCallback(
|
const commit = useCallback(
|
||||||
async (dir: 'left' | 'right', runAction: boolean) => {
|
async (dir: 'left' | 'right', runAction: boolean) => {
|
||||||
if (rafRef.current != null) {
|
if (rafRef.current != null) {
|
||||||
cancelAnimationFrame(rafRef.current)
|
cancelAnimationFrame(rafRef.current)
|
||||||
@ -267,7 +267,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
[commitMs, onSwipeLeft, onSwipeRight, snapMs]
|
[commitMs, onSwipeLeft, onSwipeRight, snapMs]
|
||||||
)
|
)
|
||||||
|
|
||||||
const softResetForTap = React.useCallback(() => {
|
const softResetForTap = useCallback(() => {
|
||||||
if (rafRef.current != null) {
|
if (rafRef.current != null) {
|
||||||
cancelAnimationFrame(rafRef.current)
|
cancelAnimationFrame(rafRef.current)
|
||||||
rafRef.current = null
|
rafRef.current = null
|
||||||
@ -286,7 +286,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const runHotFx = React.useCallback(
|
const runHotFx = useCallback(
|
||||||
(clientX?: number, clientY?: number) => {
|
(clientX?: number, clientY?: number) => {
|
||||||
const outer = outerRef.current
|
const outer = outerRef.current
|
||||||
const card = cardRef.current
|
const card = cardRef.current
|
||||||
@ -401,14 +401,14 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
[hotTargetSelector]
|
[hotTargetSelector]
|
||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
clearTapTimer()
|
clearTapTimer()
|
||||||
clearPressTimer()
|
clearPressTimer()
|
||||||
}
|
}
|
||||||
}, [clearTapTimer, clearPressTimer])
|
}, [clearTapTimer, clearPressTimer])
|
||||||
|
|
||||||
React.useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
swipeLeft: (opts) => commit('left', opts?.runAction ?? true),
|
swipeLeft: (opts) => commit('left', opts?.runAction ?? true),
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// components/ui/Switch.tsx
|
// components/ui/Switch.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import type { ChangeEvent } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
type SwitchSize = 'default' | 'short'
|
type SwitchSize = 'default' | 'short'
|
||||||
@ -22,6 +22,7 @@ export type SwitchProps = {
|
|||||||
|
|
||||||
size?: SwitchSize
|
size?: SwitchSize
|
||||||
variant?: SwitchVariant
|
variant?: SwitchVariant
|
||||||
|
compact?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,9 +38,10 @@ export default function Switch({
|
|||||||
ariaDescribedby,
|
ariaDescribedby,
|
||||||
size = 'default',
|
size = 'default',
|
||||||
variant = 'simple',
|
variant = 'simple',
|
||||||
|
compact = false,
|
||||||
className,
|
className,
|
||||||
}: SwitchProps) {
|
}: SwitchProps) {
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
if (disabled) return
|
if (disabled) return
|
||||||
onChange(e.target.checked)
|
onChange(e.target.checked)
|
||||||
}
|
}
|
||||||
@ -49,16 +51,25 @@ export default function Switch({
|
|||||||
disabled && 'cursor-not-allowed'
|
disabled && 'cursor-not-allowed'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const rootBase =
|
||||||
|
'group relative inline-flex shrink-0 rounded-full bg-gray-200 p-0.5 inset-ring inset-ring-gray-900/5 outline-offset-2 outline-indigo-600 transition-colors duration-200 ease-in-out has-checked:bg-indigo-600 has-focus-visible:outline-2 dark:bg-white/5 dark:inset-ring-white/10 dark:outline-indigo-500 dark:has-checked:bg-indigo-500'
|
||||||
|
|
||||||
if (size === 'short') {
|
if (size === 'short') {
|
||||||
|
const trackClass = compact ? 'h-4 w-8' : 'h-5 w-10'
|
||||||
|
const thumbClass = compact
|
||||||
|
? 'size-3 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked:translate-x-4'
|
||||||
|
: 'size-4 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked:translate-x-5'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'group relative inline-flex h-5 w-10 shrink-0 rounded-full bg-gray-200 p-0.5 inset-ring inset-ring-gray-900/5 outline-offset-2 outline-indigo-600 transition-colors duration-200 ease-in-out has-checked:bg-indigo-600 has-focus-visible:outline-2 dark:bg-white/5 dark:inset-ring-white/10 dark:outline-indigo-500 dark:has-checked:bg-indigo-500',
|
rootBase,
|
||||||
|
trackClass,
|
||||||
disabled && 'opacity-60',
|
disabled && 'opacity-60',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="size-4 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked:translate-x-5" />
|
<span className={thumbClass} />
|
||||||
|
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
@ -77,16 +88,24 @@ export default function Switch({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trackClass = compact ? 'h-5 w-9' : 'h-6 w-11'
|
||||||
|
const thumbClass = compact
|
||||||
|
? 'size-4 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked:translate-x-4'
|
||||||
|
: 'size-5 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked:translate-x-5'
|
||||||
|
|
||||||
|
const iconSizeClass = compact ? 'size-2.5' : 'size-3'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'group relative inline-flex w-11 shrink-0 rounded-full bg-gray-200 p-0.5 inset-ring inset-ring-gray-900/5 outline-offset-2 outline-indigo-600 transition-colors duration-200 ease-in-out has-checked:bg-indigo-600 has-focus-visible:outline-2 dark:bg-white/5 dark:inset-ring-white/10 dark:outline-indigo-500 dark:has-checked:bg-indigo-500',
|
rootBase,
|
||||||
|
trackClass,
|
||||||
disabled && 'opacity-60',
|
disabled && 'opacity-60',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{variant === 'icon' ? (
|
{variant === 'icon' ? (
|
||||||
<span className="relative size-5 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked:translate-x-5">
|
<span className={clsx('relative', thumbClass)}>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@ -94,7 +113,11 @@ export default function Switch({
|
|||||||
checked ? 'opacity-0 duration-100' : 'opacity-100 duration-200'
|
checked ? 'opacity-0 duration-100' : 'opacity-100 duration-200'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<svg fill="none" viewBox="0 0 12 12" className="size-3 text-gray-400 dark:text-gray-600">
|
<svg
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
className={clsx(iconSizeClass, 'text-gray-400 dark:text-gray-600')}
|
||||||
|
>
|
||||||
<path
|
<path
|
||||||
d="M4 8l2-2m0 0l2-2M6 6L4 4m2 2l2 2"
|
d="M4 8l2-2m0 0l2-2M6 6L4 4m2 2l2 2"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@ -112,13 +135,17 @@ export default function Switch({
|
|||||||
checked ? 'opacity-100 duration-200' : 'opacity-0 duration-100'
|
checked ? 'opacity-100 duration-200' : 'opacity-0 duration-100'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<svg fill="currentColor" viewBox="0 0 12 12" className="size-3 text-indigo-600 dark:text-indigo-500">
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
className={clsx(iconSizeClass, 'text-indigo-600 dark:text-indigo-500')}
|
||||||
|
>
|
||||||
<path d="M3.707 5.293a1 1 0 00-1.414 1.414l1.414-1.414zM5 8l-.707.707a1 1 0 001.414 0L5 8zm4.707-3.293a1 1 0 00-1.414-1.414l1.414 1.414zm-7.414 2l2 2 1.414-1.414-2-2-1.414 1.414zm3.414 2l4-4-1.414-1.414-4 4 1.414 1.414z" />
|
<path d="M3.707 5.293a1 1 0 00-1.414 1.414l1.414-1.414zM5 8l-.707.707a1 1 0 001.414 0L5 8zm4.707-3.293a1 1 0 00-1.414-1.414l1.414 1.414zm-7.414 2l2 2 1.414-1.414-2-2-1.414 1.414zm3.414 2l4-4-1.414-1.414-4 4 1.414 1.414z" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="size-5 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked:translate-x-5" />
|
<span className={thumbClass} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
|||||||
@ -1,6 +1,15 @@
|
|||||||
|
// frontend\src\components\ui\Table.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import {
|
||||||
|
useState,
|
||||||
|
useMemo,
|
||||||
|
useCallback,
|
||||||
|
type ReactNode,
|
||||||
|
type HTMLAttributes,
|
||||||
|
type MouseEvent,
|
||||||
|
} from 'react'
|
||||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||||
|
|
||||||
type Align = 'left' | 'center' | 'right'
|
type Align = 'left' | 'center' | 'right'
|
||||||
@ -10,16 +19,16 @@ export type SortState = { key: string; direction: SortDirection } | null
|
|||||||
|
|
||||||
export type Column<T> = {
|
export type Column<T> = {
|
||||||
key: string
|
key: string
|
||||||
header: React.ReactNode
|
header: ReactNode
|
||||||
widthClassName?: string
|
widthClassName?: string
|
||||||
align?: Align
|
align?: Align
|
||||||
className?: string
|
className?: string
|
||||||
headerClassName?: string
|
headerClassName?: string
|
||||||
|
|
||||||
/** Wenn gesetzt: so wird die Zelle gerendert */
|
/** Wenn gesetzt: so wird die Zelle gerendert */
|
||||||
cell?: (row: T, rowIndex: number) => React.ReactNode
|
cell?: (row: T, rowIndex: number) => ReactNode
|
||||||
/** Standard: row[col.key] */
|
/** Standard: row[col.key] */
|
||||||
accessor?: (row: T) => React.ReactNode
|
accessor?: (row: T) => ReactNode
|
||||||
/** Optional: sr-only Header (z.B. für Action-Spalte) */
|
/** Optional: sr-only Header (z.B. für Action-Spalte) */
|
||||||
srOnlyHeader?: boolean
|
srOnlyHeader?: boolean
|
||||||
|
|
||||||
@ -35,7 +44,7 @@ export type Column<T> = {
|
|||||||
sortFn?: (a: T, b: T) => number
|
sortFn?: (a: T, b: T) => number
|
||||||
}
|
}
|
||||||
|
|
||||||
type TableRowAttrs = React.HTMLAttributes<HTMLTableRowElement> & {
|
type TableRowAttrs = HTMLAttributes<HTMLTableRowElement> & {
|
||||||
[key: `data-${string}`]: string | number | undefined
|
[key: `data-${string}`]: string | number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,9 +53,9 @@ export type TableProps<T> = {
|
|||||||
rows: T[]
|
rows: T[]
|
||||||
getRowKey?: (row: T, index: number) => string
|
getRowKey?: (row: T, index: number) => string
|
||||||
/** Optionaler Titelbereich über der Tabelle */
|
/** Optionaler Titelbereich über der Tabelle */
|
||||||
title?: React.ReactNode
|
title?: ReactNode
|
||||||
description?: React.ReactNode
|
description?: ReactNode
|
||||||
actions?: React.ReactNode
|
actions?: ReactNode
|
||||||
|
|
||||||
/** Styling / Layout */
|
/** Styling / Layout */
|
||||||
fullWidth?: boolean
|
fullWidth?: boolean
|
||||||
@ -57,16 +66,16 @@ export type TableProps<T> = {
|
|||||||
|
|
||||||
/** States */
|
/** States */
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
emptyLabel?: React.ReactNode
|
emptyLabel?: ReactNode
|
||||||
|
|
||||||
className?: string
|
className?: string
|
||||||
|
|
||||||
rowClassName?: (row: T, rowIndex: number) => string | undefined
|
rowClassName?: (row: T, rowIndex: number) => string | undefined
|
||||||
|
|
||||||
onRowClick?: (row: T) => void
|
onRowClick?: (row: T) => void
|
||||||
onRowContextMenu?: (row: T, e: React.MouseEvent<HTMLTableRowElement>) => void
|
onRowContextMenu?: (row: T, e: MouseEvent<HTMLTableRowElement>) => void
|
||||||
onRowMouseEnter?: (row: T, e: React.MouseEvent<HTMLTableRowElement>) => void
|
onRowMouseEnter?: (row: T, e: MouseEvent<HTMLTableRowElement>) => void
|
||||||
onRowMouseLeave?: (row: T, e: React.MouseEvent<HTMLTableRowElement>) => void
|
onRowMouseLeave?: (row: T, e: MouseEvent<HTMLTableRowElement>) => void
|
||||||
rowAttrs?: (row: T, rowIndex: number) => TableRowAttrs
|
rowAttrs?: (row: T, rowIndex: number) => TableRowAttrs
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -148,10 +157,10 @@ export default function Table<T>({
|
|||||||
const headY = compact ? 'py-3' : 'py-3.5'
|
const headY = compact ? 'py-3' : 'py-3.5'
|
||||||
|
|
||||||
const isControlled = sort !== undefined
|
const isControlled = sort !== undefined
|
||||||
const [internalSort, setInternalSort] = React.useState<SortState>(defaultSort)
|
const [internalSort, setInternalSort] = useState<SortState>(defaultSort)
|
||||||
const sortState = isControlled ? sort! : internalSort
|
const sortState = isControlled ? sort! : internalSort
|
||||||
|
|
||||||
const setSortState = React.useCallback(
|
const setSortState = useCallback(
|
||||||
(next: SortState) => {
|
(next: SortState) => {
|
||||||
if (!isControlled) setInternalSort(next)
|
if (!isControlled) setInternalSort(next)
|
||||||
onSortChange?.(next)
|
onSortChange?.(next)
|
||||||
@ -159,7 +168,7 @@ export default function Table<T>({
|
|||||||
[isControlled, onSortChange]
|
[isControlled, onSortChange]
|
||||||
)
|
)
|
||||||
|
|
||||||
const sortedRows = React.useMemo(() => {
|
const sortedRows = useMemo(() => {
|
||||||
if (!sortState) return rows
|
if (!sortState) return rows
|
||||||
const col = columns.find((c) => c.key === sortState.key)
|
const col = columns.find((c) => c.key === sortState.key)
|
||||||
if (!col) return rows
|
if (!col) return rows
|
||||||
|
|||||||
@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import { type ComponentType, type SVGProps } from 'react'
|
||||||
import { ChevronDownIcon } from '@heroicons/react/16/solid'
|
import { ChevronDownIcon } from '@heroicons/react/16/solid'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
export type TabIcon = React.ComponentType<React.SVGProps<SVGSVGElement>>
|
export type TabIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||||
|
|
||||||
export type TabItem = {
|
export type TabItem = {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
import type { ReactNode, SyntheticEvent } from 'react'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** Entweder tag ODER children nutzen (tag ist praktisch, wenn du onClick nutzt). */
|
/** Entweder tag ODER children nutzen (tag ist praktisch, wenn du onClick nutzt). */
|
||||||
tag?: string
|
tag?: string
|
||||||
children?: React.ReactNode
|
children?: ReactNode
|
||||||
|
|
||||||
title?: string
|
title?: string
|
||||||
|
|
||||||
@ -68,7 +68,7 @@ export default function TagBadge({
|
|||||||
|
|
||||||
const cls = clsx(base, clickableCls, className)
|
const cls = clsx(base, clickableCls, className)
|
||||||
|
|
||||||
const stop = (e: React.SyntheticEvent) => e.stopPropagation()
|
const stop = (e: SyntheticEvent) => e.stopPropagation()
|
||||||
|
|
||||||
const commonHandlers = stopPropagation
|
const commonHandlers = stopPropagation
|
||||||
? {
|
? {
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import { useEffect, useRef, useState, useMemo, useCallback, type KeyboardEvent } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { PlusIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
import { PlusIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
@ -31,42 +31,40 @@ export default function TagFilterBar({
|
|||||||
emptyText = 'Keine passenden Tags gefunden.',
|
emptyText = 'Keine passenden Tags gefunden.',
|
||||||
className = '',
|
className = '',
|
||||||
}: TagFilterBarProps) {
|
}: TagFilterBarProps) {
|
||||||
const [open, setOpen] = React.useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [query, setQuery] = React.useState('')
|
const [query, setQuery] = useState('')
|
||||||
|
|
||||||
const rootRef = React.useRef<HTMLDivElement | null>(null)
|
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||||
const inputRef = React.useRef<HTMLInputElement | null>(null)
|
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const activeTagSet = React.useMemo(
|
const activeTagSet = useMemo(
|
||||||
() => new Set(activeTags.map(lower)),
|
() => new Set(activeTags.map(lower)),
|
||||||
[activeTags]
|
[activeTags]
|
||||||
)
|
)
|
||||||
|
|
||||||
const addableTags = React.useMemo(() => {
|
const addableTags = useMemo(() => {
|
||||||
return allTags.filter((tag) => !activeTagSet.has(lower(tag)))
|
return allTags.filter((tag) => !activeTagSet.has(lower(tag)))
|
||||||
}, [allTags, activeTagSet])
|
}, [allTags, activeTagSet])
|
||||||
|
|
||||||
const normalizedQuery = React.useMemo(() => lower(query), [query])
|
const normalizedQuery = useMemo(() => lower(query), [query])
|
||||||
|
|
||||||
const filteredOptions = React.useMemo(() => {
|
const filteredOptions = useMemo(() => {
|
||||||
if (!normalizedQuery) return addableTags.slice(0, 40)
|
if (!normalizedQuery) return addableTags
|
||||||
return addableTags
|
return addableTags.filter((tag) => lower(tag).includes(normalizedQuery))
|
||||||
.filter((tag) => lower(tag).includes(normalizedQuery))
|
|
||||||
.slice(0, 40)
|
|
||||||
}, [addableTags, normalizedQuery])
|
}, [addableTags, normalizedQuery])
|
||||||
|
|
||||||
const close = React.useCallback(() => {
|
const close = useCallback(() => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
setQuery('')
|
setQuery('')
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const openPicker = React.useCallback(() => {
|
const openPicker = useCallback(() => {
|
||||||
if (addableTags.length === 0) return
|
if (addableTags.length === 0) return
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
setQuery('')
|
setQuery('')
|
||||||
}, [addableTags.length])
|
}, [addableTags.length])
|
||||||
|
|
||||||
const selectTag = React.useCallback(
|
const selectTag = useCallback(
|
||||||
(tag: string) => {
|
(tag: string) => {
|
||||||
onAddTag(tag)
|
onAddTag(tag)
|
||||||
setQuery('')
|
setQuery('')
|
||||||
@ -75,13 +73,13 @@ export default function TagFilterBar({
|
|||||||
[onAddTag]
|
[onAddTag]
|
||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const t = window.setTimeout(() => inputRef.current?.focus(), 0)
|
const t = window.setTimeout(() => inputRef.current?.focus(), 0)
|
||||||
return () => window.clearTimeout(t)
|
return () => window.clearTimeout(t)
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
||||||
const onPointerDown = (e: MouseEvent) => {
|
const onPointerDown = (e: MouseEvent) => {
|
||||||
@ -95,8 +93,8 @@ export default function TagFilterBar({
|
|||||||
return () => document.removeEventListener('mousedown', onPointerDown)
|
return () => document.removeEventListener('mousedown', onPointerDown)
|
||||||
}, [open, close])
|
}, [open, close])
|
||||||
|
|
||||||
const handleKeyDown = React.useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
close()
|
close()
|
||||||
|
|||||||
@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import type { CSSProperties, SyntheticEvent } from 'react'
|
||||||
|
import { useEffect, useRef, useState, useMemo, useCallback, useLayoutEffect } from 'react'
|
||||||
import TagBadge from './TagBadge'
|
import TagBadge from './TagBadge'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -32,28 +33,28 @@ export default function TagOverflowRow({
|
|||||||
expandMode = 'cover',
|
expandMode = 'cover',
|
||||||
dropdownMaxHeightClassName = 'max-h-[40vh]',
|
dropdownMaxHeightClassName = 'max-h-[40vh]',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [open, setOpen] = React.useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
// ✅ misst die komplette Zeile (Tags + Button)
|
// ✅ misst die komplette Zeile (Tags + Button)
|
||||||
const rowWrapRef = React.useRef<HTMLDivElement | null>(null)
|
const rowWrapRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
// ✅ hidden “all tags” measurement
|
// ✅ hidden “all tags” measurement
|
||||||
const measureRef = React.useRef<HTMLDivElement | null>(null)
|
const measureRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
// ✅ worst-case +X measurement
|
// ✅ worst-case +X measurement
|
||||||
const plusMeasureRef = React.useRef<HTMLButtonElement | null>(null)
|
const plusMeasureRef = useRef<HTMLButtonElement | null>(null)
|
||||||
|
|
||||||
const hostRef = React.useRef<HTMLDivElement | null>(null)
|
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||||
const [overlayStyle, setOverlayStyle] = React.useState<React.CSSProperties>({
|
const [overlayStyle, setOverlayStyle] = useState<CSSProperties>({
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
React.useEffect(() => setOpen(false), [rowKey])
|
useEffect(() => setOpen(false), [rowKey])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') setOpen(false)
|
if (e.key === 'Escape') setOpen(false)
|
||||||
@ -62,22 +63,22 @@ export default function TagOverflowRow({
|
|||||||
return () => document.removeEventListener('keydown', onKeyDown)
|
return () => document.removeEventListener('keydown', onKeyDown)
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const cap = React.useMemo(() => {
|
const cap = useMemo(() => {
|
||||||
return typeof maxVisible === 'number' && maxVisible > 0 ? maxVisible : Number.POSITIVE_INFINITY
|
return typeof maxVisible === 'number' && maxVisible > 0 ? maxVisible : Number.POSITIVE_INFINITY
|
||||||
}, [maxVisible])
|
}, [maxVisible])
|
||||||
|
|
||||||
const sortedTags = React.useMemo(() => {
|
const sortedTags = useMemo(() => {
|
||||||
return [...tags].sort((a, b) =>
|
return [...tags].sort((a, b) =>
|
||||||
a.localeCompare(b, undefined, { sensitivity: 'base' })
|
a.localeCompare(b, undefined, { sensitivity: 'base' })
|
||||||
)
|
)
|
||||||
}, [tags])
|
}, [tags])
|
||||||
|
|
||||||
const [visibleCount, setVisibleCount] = React.useState<number>(() => {
|
const [visibleCount, setVisibleCount] = useState<number>(() => {
|
||||||
const total = Math.min(sortedTags.length, cap)
|
const total = Math.min(sortedTags.length, cap)
|
||||||
return Math.min(total, 6) // optional initial guess; recalc korrigiert direkt
|
return Math.min(total, 6) // optional initial guess; recalc korrigiert direkt
|
||||||
})
|
})
|
||||||
|
|
||||||
const recalc = React.useCallback(() => {
|
const recalc = useCallback(() => {
|
||||||
const rowWrap = rowWrapRef.current
|
const rowWrap = rowWrapRef.current
|
||||||
const measure = measureRef.current
|
const measure = measureRef.current
|
||||||
if (!rowWrap || !measure) return
|
if (!rowWrap || !measure) return
|
||||||
@ -130,7 +131,7 @@ export default function TagOverflowRow({
|
|||||||
setVisibleCount(Math.max(0, Math.min(count, totalTags)))
|
setVisibleCount(Math.max(0, Math.min(count, totalTags)))
|
||||||
}, [sortedTags, cap, gapPx])
|
}, [sortedTags, cap, gapPx])
|
||||||
|
|
||||||
const recalcOverlay = React.useCallback(() => {
|
const recalcOverlay = useCallback(() => {
|
||||||
const host = hostRef.current
|
const host = hostRef.current
|
||||||
if (!host) return
|
if (!host) return
|
||||||
|
|
||||||
@ -145,7 +146,7 @@ export default function TagOverflowRow({
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!open || expandMode !== 'cover') return
|
if (!open || expandMode !== 'cover') return
|
||||||
|
|
||||||
recalcOverlay()
|
recalcOverlay()
|
||||||
@ -164,7 +165,7 @@ export default function TagOverflowRow({
|
|||||||
return () => ro.disconnect()
|
return () => ro.disconnect()
|
||||||
}, [open, expandMode, recalcOverlay])
|
}, [open, expandMode, recalcOverlay])
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
||||||
recalcOverlay()
|
recalcOverlay()
|
||||||
@ -183,12 +184,12 @@ export default function TagOverflowRow({
|
|||||||
return () => ro.disconnect()
|
return () => ro.disconnect()
|
||||||
}, [open, recalcOverlay])
|
}, [open, recalcOverlay])
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
recalc()
|
recalc()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [recalc, sortedTags.join('\u0000'), maxWidthClassName, cap])
|
}, [recalc, sortedTags.join('\u0000'), maxWidthClassName, cap])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
const el = rowWrapRef.current
|
const el = rowWrapRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const ro = new ResizeObserver(() => recalc())
|
const ro = new ResizeObserver(() => recalc())
|
||||||
@ -199,7 +200,7 @@ export default function TagOverflowRow({
|
|||||||
const totalTags = Math.min(sortedTags.length, cap)
|
const totalTags = Math.min(sortedTags.length, cap)
|
||||||
const visibleTags = sortedTags.slice(0, Math.min(visibleCount, totalTags))
|
const visibleTags = sortedTags.slice(0, Math.min(visibleCount, totalTags))
|
||||||
|
|
||||||
const stop = (e: React.SyntheticEvent) => e.stopPropagation()
|
const stop = (e: SyntheticEvent) => e.stopPropagation()
|
||||||
|
|
||||||
// ✅ Hinweis: auch wenn cap < tags.length, zählt "rest" nur gegen totalTags (capped)
|
// ✅ Hinweis: auch wenn cap < tags.length, zählt "rest" nur gegen totalTags (capped)
|
||||||
// willst du immer gegen alle tags: rest = tags.length - visibleTags.length
|
// willst du immer gegen alle tags: rest = tags.length - visibleTags.length
|
||||||
|
|||||||
@ -269,8 +269,6 @@ export default function Task({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const running = !!state?.running
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@ -285,8 +283,8 @@ export default function Task({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<Button variant="primary" onClick={start} disabled={disabled || busy || starting || running}>
|
<Button variant="primary" onClick={start} disabled={disabled || starting}>
|
||||||
{(starting || busy) ? startingLabel : startLabel}
|
{starting ? startingLabel : startLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export type TaskItem = {
|
|||||||
err?: string
|
err?: string
|
||||||
cancellable?: boolean
|
cancellable?: boolean
|
||||||
fading?: boolean
|
fading?: boolean
|
||||||
|
queuedAtMs?: number
|
||||||
startedAtMs?: number
|
startedAtMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,8 +32,8 @@ export default function TaskList({ tasks, onCancel }: Props) {
|
|||||||
const visible = [...(tasks || [])]
|
const visible = [...(tasks || [])]
|
||||||
.filter((t) => t.status !== 'idle')
|
.filter((t) => t.status !== 'idle')
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const aMs = Number(a.startedAtMs ?? 0)
|
const aMs = Number(a.queuedAtMs ?? a.startedAtMs ?? 0)
|
||||||
const bMs = Number(b.startedAtMs ?? 0)
|
const bMs = Number(b.queuedAtMs ?? b.startedAtMs ?? 0)
|
||||||
return aMs - bMs
|
return aMs - bMs
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -122,7 +123,11 @@ export default function TaskList({ tasks, onCancel }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{t.status === 'done' ? (
|
{t.status === 'queued' ? (
|
||||||
|
<span className="shrink-0 inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-800 ring-1 ring-inset ring-amber-200 dark:bg-amber-500/20 dark:text-amber-200 dark:ring-amber-400/30">
|
||||||
|
Wartet
|
||||||
|
</span>
|
||||||
|
) : t.status === 'done' ? (
|
||||||
<span className="shrink-0 inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-semibold text-green-800 ring-1 ring-inset ring-green-200 dark:bg-green-500/20 dark:text-green-200 dark:ring-green-400/30">
|
<span className="shrink-0 inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-semibold text-green-800 ring-1 ring-inset ring-green-200 dark:bg-green-500/20 dark:text-green-200 dark:ring-green-400/30">
|
||||||
Fertig
|
Fertig
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -2,12 +2,18 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import {
|
||||||
|
useRef,
|
||||||
|
useCallback,
|
||||||
|
forwardRef,
|
||||||
|
type InputHTMLAttributes,
|
||||||
|
type Ref,
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
type TextInputSize = 'sm' | 'md' | 'lg'
|
type TextInputSize = 'sm' | 'md' | 'lg'
|
||||||
|
|
||||||
export type TextInputProps = Omit<
|
export type TextInputProps = Omit<
|
||||||
React.InputHTMLAttributes<HTMLInputElement>,
|
InputHTMLAttributes<HTMLInputElement>,
|
||||||
'size'
|
'size'
|
||||||
> & {
|
> & {
|
||||||
size?: TextInputSize
|
size?: TextInputSize
|
||||||
@ -28,16 +34,18 @@ const sizeClassName: Record<TextInputSize, string> = {
|
|||||||
lg: 'rounded-lg px-4 py-3 text-base',
|
lg: 'rounded-lg px-4 py-3 text-base',
|
||||||
}
|
}
|
||||||
|
|
||||||
function assignRef<T>(ref: React.Ref<T> | undefined, value: T) {
|
function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
|
||||||
if (!ref) return
|
if (!ref) return
|
||||||
|
|
||||||
if (typeof ref === 'function') {
|
if (typeof ref === 'function') {
|
||||||
ref(value)
|
ref(value)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
;(ref as React.MutableRefObject<T | null>).current = value
|
|
||||||
|
;(ref as { current: T | null }).current = value
|
||||||
}
|
}
|
||||||
|
|
||||||
const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(
|
const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
className = '',
|
className = '',
|
||||||
@ -51,9 +59,9 @@ const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(
|
|||||||
},
|
},
|
||||||
forwardedRef
|
forwardedRef
|
||||||
) => {
|
) => {
|
||||||
const innerRef = React.useRef<HTMLInputElement | null>(null)
|
const innerRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const setRefs = React.useCallback(
|
const setRefs = useCallback(
|
||||||
(node: HTMLInputElement | null) => {
|
(node: HTMLInputElement | null) => {
|
||||||
innerRef.current = node
|
innerRef.current = node
|
||||||
assignRef(forwardedRef, node)
|
assignRef(forwardedRef, node)
|
||||||
@ -61,7 +69,7 @@ const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(
|
|||||||
[forwardedRef]
|
[forwardedRef]
|
||||||
)
|
)
|
||||||
|
|
||||||
const selectAll = React.useCallback(() => {
|
const selectAll = useCallback(() => {
|
||||||
const el = innerRef.current
|
const el = innerRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
el.focus()
|
el.focus()
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import { useEffect, useRef, useState, useMemo, useCallback, createContext, useContext, type CSSProperties, type ReactNode, type PointerEvent } from 'react'
|
||||||
import { Transition } from '@headlessui/react'
|
import { Transition } from '@headlessui/react'
|
||||||
import {
|
import {
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
@ -47,7 +47,7 @@ type ToastContextValue = {
|
|||||||
clear: () => void
|
clear: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToastContext = React.createContext<ToastContextValue | null>(null)
|
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||||
|
|
||||||
const TOAST_LEAVE_MS = 220
|
const TOAST_LEAVE_MS = 220
|
||||||
const TOAST_SWIPE_DISMISS_PX = 96
|
const TOAST_SWIPE_DISMISS_PX = 96
|
||||||
@ -135,25 +135,25 @@ export function ToastProvider({
|
|||||||
defaultDurationMs = 3500,
|
defaultDurationMs = 3500,
|
||||||
position = 'bottom-right',
|
position = 'bottom-right',
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: ReactNode
|
||||||
maxToasts?: number
|
maxToasts?: number
|
||||||
defaultDurationMs?: number
|
defaultDurationMs?: number
|
||||||
position?: 'bottom-right' | 'top-right' | 'bottom-left' | 'top-left'
|
position?: 'bottom-right' | 'top-right' | 'bottom-left' | 'top-left'
|
||||||
}) {
|
}) {
|
||||||
const [toasts, setToasts] = React.useState<ToastInternal[]>([])
|
const [toasts, setToasts] = useState<ToastInternal[]>([])
|
||||||
const [notificationsEnabled, setNotificationsEnabled] = React.useState(true)
|
const [notificationsEnabled, setNotificationsEnabled] = useState(true)
|
||||||
|
|
||||||
const autoCloseTimersRef = React.useRef<Record<string, number>>({})
|
const autoCloseTimersRef = useRef<Record<string, number>>({})
|
||||||
const finalizeRemoveTimersRef = React.useRef<Record<string, number>>({})
|
const finalizeRemoveTimersRef = useRef<Record<string, number>>({})
|
||||||
|
|
||||||
const toastStartedAtRef = React.useRef<Record<string, number>>({})
|
const toastStartedAtRef = useRef<Record<string, number>>({})
|
||||||
const toastRemainingMsRef = React.useRef<Record<string, number>>({})
|
const toastRemainingMsRef = useRef<Record<string, number>>({})
|
||||||
const toastPausedRef = React.useRef<Record<string, boolean>>({})
|
const toastPausedRef = useRef<Record<string, boolean>>({})
|
||||||
|
|
||||||
const swipeStateRef = React.useRef<Record<string, SwipeState>>({})
|
const swipeStateRef = useRef<Record<string, SwipeState>>({})
|
||||||
const [, forceSwipeRender] = React.useState(0)
|
const [, forceSwipeRender] = useState(0)
|
||||||
|
|
||||||
const clearTimersFor = React.useCallback((id: string) => {
|
const clearTimersFor = useCallback((id: string) => {
|
||||||
const autoId = autoCloseTimersRef.current[id]
|
const autoId = autoCloseTimersRef.current[id]
|
||||||
if (autoId) {
|
if (autoId) {
|
||||||
window.clearTimeout(autoId)
|
window.clearTimeout(autoId)
|
||||||
@ -172,7 +172,7 @@ export function ToastProvider({
|
|||||||
delete swipeStateRef.current[id]
|
delete swipeStateRef.current[id]
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadNotificationSetting = React.useCallback(async () => {
|
const loadNotificationSetting = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/settings', { cache: 'no-store' })
|
const r = await fetch('/api/settings', { cache: 'no-store' })
|
||||||
if (!r.ok) return
|
if (!r.ok) return
|
||||||
@ -183,7 +183,7 @@ export function ToastProvider({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
loadNotificationSetting()
|
loadNotificationSetting()
|
||||||
|
|
||||||
const onUpdated = () => loadNotificationSetting()
|
const onUpdated = () => loadNotificationSetting()
|
||||||
@ -191,7 +191,7 @@ export function ToastProvider({
|
|||||||
return () => window.removeEventListener('recorder-settings-updated', onUpdated)
|
return () => window.removeEventListener('recorder-settings-updated', onUpdated)
|
||||||
}, [loadNotificationSetting])
|
}, [loadNotificationSetting])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!notificationsEnabled) {
|
if (!notificationsEnabled) {
|
||||||
// Nur Fehler sichtbar lassen (animiert schließen)
|
// Nur Fehler sichtbar lassen (animiert schließen)
|
||||||
setToasts((prev) => prev.map((t) => (t.type === 'error' ? t : { ...t, open: false })))
|
setToasts((prev) => prev.map((t) => (t.type === 'error' ? t : { ...t, open: false })))
|
||||||
@ -208,7 +208,7 @@ export function ToastProvider({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [notificationsEnabled])
|
}, [notificationsEnabled])
|
||||||
|
|
||||||
const remove = React.useCallback(
|
const remove = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
const cur = swipeStateRef.current[id]
|
const cur = swipeStateRef.current[id]
|
||||||
if (cur) {
|
if (cur) {
|
||||||
@ -232,7 +232,7 @@ export function ToastProvider({
|
|||||||
[clearTimersFor]
|
[clearTimersFor]
|
||||||
)
|
)
|
||||||
|
|
||||||
const clear = React.useCallback(() => {
|
const clear = useCallback(() => {
|
||||||
setToasts((prev) => prev.map((t) => ({ ...t, open: false })))
|
setToasts((prev) => prev.map((t) => ({ ...t, open: false })))
|
||||||
|
|
||||||
Object.keys(autoCloseTimersRef.current).forEach((id) => {
|
Object.keys(autoCloseTimersRef.current).forEach((id) => {
|
||||||
@ -247,7 +247,7 @@ export function ToastProvider({
|
|||||||
window.setTimeout(() => setToasts([]), TOAST_LEAVE_MS)
|
window.setTimeout(() => setToasts([]), TOAST_LEAVE_MS)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const startAutoCloseTimer = React.useCallback(
|
const startAutoCloseTimer = useCallback(
|
||||||
(id: string, ms: number) => {
|
(id: string, ms: number) => {
|
||||||
if (!ms || ms <= 0) return
|
if (!ms || ms <= 0) return
|
||||||
|
|
||||||
@ -273,7 +273,7 @@ export function ToastProvider({
|
|||||||
[remove]
|
[remove]
|
||||||
)
|
)
|
||||||
|
|
||||||
const push = React.useCallback(
|
const push = useCallback(
|
||||||
(t: Omit<Toast, 'id'>) => {
|
(t: Omit<Toast, 'id'>) => {
|
||||||
if (!notificationsEnabled && t.type !== 'error') return ''
|
if (!notificationsEnabled && t.type !== 'error') return ''
|
||||||
|
|
||||||
@ -300,7 +300,7 @@ export function ToastProvider({
|
|||||||
[defaultDurationMs, maxToasts, notificationsEnabled, clearTimersFor, startAutoCloseTimer]
|
[defaultDurationMs, maxToasts, notificationsEnabled, clearTimersFor, startAutoCloseTimer]
|
||||||
)
|
)
|
||||||
|
|
||||||
const pauseAutoCloseTimer = React.useCallback((id: string) => {
|
const pauseAutoCloseTimer = useCallback((id: string) => {
|
||||||
if (toastPausedRef.current[id]) return
|
if (toastPausedRef.current[id]) return
|
||||||
|
|
||||||
const timerId = autoCloseTimersRef.current[id]
|
const timerId = autoCloseTimersRef.current[id]
|
||||||
@ -318,7 +318,7 @@ export function ToastProvider({
|
|||||||
toastPausedRef.current[id] = true
|
toastPausedRef.current[id] = true
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const resumeAutoCloseTimer = React.useCallback(
|
const resumeAutoCloseTimer = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
if (!toastPausedRef.current[id]) return
|
if (!toastPausedRef.current[id]) return
|
||||||
|
|
||||||
@ -342,7 +342,7 @@ export function ToastProvider({
|
|||||||
[remove]
|
[remove]
|
||||||
)
|
)
|
||||||
|
|
||||||
const isTouchLikePointer = (e: React.PointerEvent) => {
|
const isTouchLikePointer = (e: PointerEvent) => {
|
||||||
return e.pointerType === 'touch' || e.pointerType === 'pen'
|
return e.pointerType === 'touch' || e.pointerType === 'pen'
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -361,7 +361,7 @@ export function ToastProvider({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToastPointerDown = React.useCallback((id: string, e: React.PointerEvent<HTMLDivElement>) => {
|
const handleToastPointerDown = useCallback((id: string, e: PointerEvent<HTMLDivElement>) => {
|
||||||
if (!isTouchLikePointer(e)) return
|
if (!isTouchLikePointer(e)) return
|
||||||
|
|
||||||
swipeStateRef.current[id] = {
|
swipeStateRef.current[id] = {
|
||||||
@ -379,7 +379,7 @@ export function ToastProvider({
|
|||||||
forceSwipeRender((n) => n + 1)
|
forceSwipeRender((n) => n + 1)
|
||||||
}, [pauseAutoCloseTimer])
|
}, [pauseAutoCloseTimer])
|
||||||
|
|
||||||
const handleToastPointerMove = React.useCallback((id: string, e: React.PointerEvent<HTMLDivElement>) => {
|
const handleToastPointerMove = useCallback((id: string, e: PointerEvent<HTMLDivElement>) => {
|
||||||
const s = swipeStateRef.current[id]
|
const s = swipeStateRef.current[id]
|
||||||
if (!s) return
|
if (!s) return
|
||||||
if (s.pointerId !== e.pointerId) return
|
if (s.pointerId !== e.pointerId) return
|
||||||
@ -420,7 +420,7 @@ export function ToastProvider({
|
|||||||
forceSwipeRender((n) => n + 1)
|
forceSwipeRender((n) => n + 1)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleToastPointerEnd = React.useCallback((id: string, e?: React.PointerEvent<HTMLDivElement>) => {
|
const handleToastPointerEnd = useCallback((id: string, e?: PointerEvent<HTMLDivElement>) => {
|
||||||
const s = swipeStateRef.current[id]
|
const s = swipeStateRef.current[id]
|
||||||
if (!s) return
|
if (!s) return
|
||||||
if (e && s.pointerId !== e.pointerId) return
|
if (e && s.pointerId !== e.pointerId) return
|
||||||
@ -446,7 +446,7 @@ export function ToastProvider({
|
|||||||
forceSwipeRender((n) => n + 1)
|
forceSwipeRender((n) => n + 1)
|
||||||
}, [remove, resumeAutoCloseTimer])
|
}, [remove, resumeAutoCloseTimer])
|
||||||
|
|
||||||
const handleToastPointerCancel = React.useCallback((id: string, e?: React.PointerEvent<HTMLDivElement>) => {
|
const handleToastPointerCancel = useCallback((id: string, e?: PointerEvent<HTMLDivElement>) => {
|
||||||
const s = swipeStateRef.current[id]
|
const s = swipeStateRef.current[id]
|
||||||
if (!s) return
|
if (!s) return
|
||||||
if (e && s.pointerId !== e.pointerId) return
|
if (e && s.pointerId !== e.pointerId) return
|
||||||
@ -465,7 +465,7 @@ export function ToastProvider({
|
|||||||
forceSwipeRender((n) => n + 1)
|
forceSwipeRender((n) => n + 1)
|
||||||
}, [resumeAutoCloseTimer])
|
}, [resumeAutoCloseTimer])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
Object.values(autoCloseTimersRef.current).forEach((n) => window.clearTimeout(n))
|
Object.values(autoCloseTimersRef.current).forEach((n) => window.clearTimeout(n))
|
||||||
Object.values(finalizeRemoveTimersRef.current).forEach((n) => window.clearTimeout(n))
|
Object.values(finalizeRemoveTimersRef.current).forEach((n) => window.clearTimeout(n))
|
||||||
@ -473,7 +473,7 @@ export function ToastProvider({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const ctx = React.useMemo<ToastContextValue>(() => ({ push, remove, clear }), [push, remove, clear])
|
const ctx = useMemo<ToastContextValue>(() => ({ push, remove, clear }), [push, remove, clear])
|
||||||
|
|
||||||
const posCls =
|
const posCls =
|
||||||
position === 'top-right'
|
position === 'top-right'
|
||||||
@ -532,7 +532,7 @@ export function ToastProvider({
|
|||||||
? Math.max(0.985, 1 - absDx / 1600)
|
? Math.max(0.985, 1 - absDx / 1600)
|
||||||
: 1
|
: 1
|
||||||
|
|
||||||
const swipeStyle: React.CSSProperties = {
|
const swipeStyle: CSSProperties = {
|
||||||
transform: `translateX(${swipe.dx}px) scale(${dragScale})`,
|
transform: `translateX(${swipe.dx}px) scale(${dragScale})`,
|
||||||
opacity: dragOpacity,
|
opacity: dragOpacity,
|
||||||
transition: swipe.dragging
|
transition: swipe.dragging
|
||||||
@ -720,7 +720,7 @@ export function ToastProvider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useToast() {
|
export function useToast() {
|
||||||
const ctx = React.useContext(ToastContext)
|
const ctx = useContext(ToastContext)
|
||||||
if (!ctx) throw new Error('useToast must be used within <ToastProvider>')
|
if (!ctx) throw new Error('useToast must be used within <ToastProvider>')
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -34,3 +34,28 @@ export function formatResolution(r?: { w: number; h: number } | null): string {
|
|||||||
// Fallback: z.B. 1600p (Ultrawide) oder 800p
|
// Fallback: z.B. 1600p (Ultrawide) oder 800p
|
||||||
return `${p}p`
|
return `${p}p`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function formatDuration(ms: number): string {
|
||||||
|
if (!Number.isFinite(ms) || ms <= 0) return '—'
|
||||||
|
const totalSec = Math.floor(ms / 1000)
|
||||||
|
const h = Math.floor(totalSec / 3600)
|
||||||
|
const m = Math.floor((totalSec % 3600) / 60)
|
||||||
|
const s = totalSec % 60
|
||||||
|
if (h > 0) return `${h}h ${m}m`
|
||||||
|
if (m > 0) return `${m}m ${s}s`
|
||||||
|
return `${s}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes?: number | null): string {
|
||||||
|
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '—'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
let v = bytes
|
||||||
|
let i = 0
|
||||||
|
while (v >= 1024 && i < units.length - 1) {
|
||||||
|
v /= 1024
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
const digits = i === 0 ? 0 : v >= 100 ? 0 : v >= 10 ? 1 : 2
|
||||||
|
return `${v.toFixed(digits)} ${units[i]}`
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user