fixed sprites

This commit is contained in:
Linrador 2026-03-27 13:17:16 +01:00
parent 9cb7c530ed
commit e19e735997
9 changed files with 545 additions and 238 deletions

View File

@ -489,7 +489,7 @@ func ensureSpritesForVideoCtx(
return false, nil
}
genCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
genCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
if err := genSem.Acquire(genCtx); err != nil {
@ -637,15 +637,21 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string)
return fmt.Errorf("deferred sprite failed for %s: %w", videoPath, err)
}
truth := finishedPhaseTruthForID(id)
if !truth.SpritesReady {
spriteTruth := regeneratePhaseTruthForVideo(videoPath, id)
if !spriteTruth.SpritesReady {
return fmt.Errorf("preview-sprite.jpg fehlt nach deferred generation: %s", id)
}
if _, err := ensureAnalyzeForVideoCtx(ctx, videoPath, sourceURL, "nsfw"); err != nil {
analyzeReady, err := ensureAnalyzeForVideoCtx(ctx, videoPath, sourceURL, "nsfw")
if err != nil {
return fmt.Errorf("deferred analyze failed for %s: %w", videoPath, err)
}
analyzeTruth := regeneratePhaseTruthForVideo(videoPath, id)
if !analyzeReady && !analyzeTruth.AnalyzeReady {
return fmt.Errorf("KI-Analyse fehlt nach deferred analyze: %s", id)
}
return nil
}
@ -995,7 +1001,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
return
}
genCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
genCtx, cancel := context.WithTimeout(ctx, 4*time.Minute)
defer cancel()
if err := genSem.Acquire(genCtx); err != nil {

View File

@ -3,6 +3,7 @@
package main
import (
"bytes"
"context"
"fmt"
"os"
@ -107,36 +108,45 @@ func generatePreviewSpriteJPG(
cols, rows,
)
cmd := exec.CommandContext(
ctx,
ffmpegPath,
args := []string{
"-hide_banner",
"-y",
}
args = append(args, ffmpegInputTol...)
args = append(args,
"-i", videoPath,
"-an",
"-sn",
"-threads", "0",
"-vf", vf,
"-frames:v", "1",
"-update", "1",
"-c:v", "mjpeg",
"-q:v", "4",
"-f", "image2",
tmpPath,
)
out, err := cmd.CombinedOutput()
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
msg := strings.TrimSpace(string(out))
if msg == "" {
msg = "(keine ffmpeg-ausgabe)"
msgErr := strings.TrimSpace(stderr.String())
if msgErr == "" {
msgErr = "(kein stderr)"
}
return fmt.Errorf(
"ffmpeg sprite failed for %q -> %q: %w | args=%q | output=%s",
"ffmpeg sprite failed for %q -> %q: %w | ctxErr=%v | args=%q | stderr=%s",
videoPath,
tmpPath,
err,
ctx.Err(),
strings.Join(cmd.Args, " "),
msg,
msgErr,
)
}

View File

@ -234,7 +234,7 @@ func runMFC(ctx context.Context, username string, outArg string) error {
return err
}
if st != StatusPublic {
return fmt.Errorf("Stream ist nicht öffentlich (Status: %s)", st)
return fmt.Errorf("Stream ist nicht live (Status: %s)", st)
}
m3u8URL, err := mfc.GetVideoURL(false)

View File

@ -1,19 +1,19 @@
{
"databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable",
"encryptedDbPassword": "7fOaXQbCfrAh++k3iuU8HVFIldwQ/xFTCGS5BY92/BiVk/NzkfgsxJU=",
"encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=",
"recordDir": "C:\\Users\\Rother\\Desktop\\Privat\\records",
"doneDir": "C:\\Users\\Rother\\Desktop\\Privat\\records\\done",
"ffmpegPath": "C:\\ffmpeg\\ffmpeg.exe",
"autoAddToDownloadList": false,
"autoStartAddedDownloads": false,
"ffmpegPath": "",
"autoAddToDownloadList": true,
"autoStartAddedDownloads": true,
"useChaturbateApi": true,
"useMyFreeCamsWatcher": true,
"autoDeleteSmallDownloads": true,
"autoDeleteSmallDownloadsBelowMB": 200,
"autoDeleteSmallDownloads": false,
"autoDeleteSmallDownloadsBelowMB": 50,
"lowDiskPauseBelowGB": 5,
"blurPreviews": false,
"teaserPlayback": "hover",
"teaserPlayback": "all",
"teaserAudio": true,
"enableNotifications": true,
"encryptedCookies": "khmON0nSN9PsCC7Fb8btmhpmC9GmuQfWglOutI11SwrgM+EVs4SwkmcdOkPykPoWM+6oMqCYUCrCYF0ZHH18gVyZdiDC6GEpPhUQkjVYZJ8aOs4Gz0PSPQj89cMrQ9OBWVakaXAyFk1/m2xjez1ofA2uWlDREcSHPXhh24Gw9nICx23TkoKt5GJdy/+MH4qWI+qGI5zaYvr6dEO9iq4yDCpVgXeKPsylyRonDS0NuqYthjbay5P0A4Ir4Xr/66d4fp1+YLrEiWyASOsYvEmBhs9WO6/zNH/Uj4ZJqBlHaoCv1doaRBUy3XizWwh0Q71fGuGPrlqm8jjJBee3hbrgoC/22Ao6gC8+mLoG6ruqZNFjSecVSuDl/XTcIYgXqudUScLCemF40UT5rbs0aFH3awY4pm75K1AzL55lBHiSIb05UX/Rf1UUyP+ruFsGJBvHZkiOgAj8TpMoVNXpfTJQYDyZR6ISJHaFMfLZBUQ2U9Dim59Aon0nf6f84gsUhX2j3UsulkbtJxU="
"encryptedCookies": "tZzqhwM+6rrdV36edfxpkhfikSfzfs4VZESLJIpYw1U4Bwn7vo+P8Sq86M7nvXpvbQVg00QLl5Wo2dD2agojU58GYY5P5P6Wpc1ZPr2T409BaQXRA/WhHiZv7z/b4JoVYX7fScZ4FDtscQYuTdMpnU6Wr725j66Qg2x8VgXqUHb1qceUes9P3L2kc8MAvZHWnZHIEjbgDW9NAZOADlbBxOonmc5hMGpz5xzmpWWV7Gqg6n1i75U/XBDV6Ks1Jn5Ic9dwi0f1BH6zo56v5oDdPAxKPlw01TUsV9ZAA+u+TjWUiq52AJP/C20bujUEsmQupqKxMs3E4rs6DEb7SYfbQ6SSryKyYYLCv0T3+CbeUgDmgYd2n+yjatE8udHvmkCtZCWvBIK4lSglsVeFq1prgpiGBL2ItyLcR13musHGbZ7Whq/p1btJsFDTTyfGnILW+sRUcd1Y/17YM5zKAWYyi81qKop6DYqh9kBoM2y3DYVxsLkb9+lUmwN6N7oopqh/bJ8vxJKyC74="
}

View File

@ -20,6 +20,14 @@ type regenerateAssetsRequest struct {
File string `json:"file"`
}
type regenerateAssetsPhase struct {
Queue string `json:"queue"`
Phase string `json:"phase"`
State string `json:"state"`
Label string `json:"label,omitempty"`
TS int64 `json:"ts"`
}
type regenerateAssetsJob struct {
File string
VideoPath string
@ -28,12 +36,14 @@ type regenerateAssetsJob struct {
Ctx context.Context
Cancel context.CancelFunc
State string // queued | running | done | error | cancelled
State string
Error string
EnqueuedAt time.Time
StartedAt time.Time
EndedAt time.Time
Phases map[string]regenerateAssetsPhase
}
var (
@ -51,6 +61,8 @@ func publishFinishedPostworkPhase(
state string,
label string,
) {
setRegenerateAssetsJobPhase(file, queue, phase, state, label)
ev := finishedPostworkEvent{
Type: "finished_postwork",
File: file,
@ -66,6 +78,83 @@ func publishFinishedPostworkPhase(
}
}
type regeneratePhaseTruth struct {
MetaReady bool
ThumbReady bool
TeaserReady bool
SpritesReady bool
AnalyzeReady bool
}
func regeneratePhaseTruthForVideo(videoPath string, assetID string) regeneratePhaseTruth {
videoPath = strings.TrimSpace(videoPath)
assetID = strings.TrimSpace(assetID)
if assetID == "" && videoPath != "" {
assetID = assetIDFromVideoPath(videoPath)
}
var out regeneratePhaseTruth
if assetID == "" {
return out
}
_, thumbPath, previewPath, spritePath, metaPath, err := assetPathsForID(assetID)
if err != nil {
return out
}
fileExists := func(p string) bool {
fi, err := os.Stat(strings.TrimSpace(p))
return err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0
}
out.ThumbReady = fileExists(thumbPath)
out.TeaserReady = fileExists(previewPath)
out.SpritesReady = fileExists(spritePath)
// Meta ist nur "ready", wenn die meta.json gültig zur aktuellen Videodatei passt
if videoPath != "" {
if vfi, err := os.Stat(videoPath); err == nil && vfi != nil && !vfi.IsDir() && vfi.Size() > 0 {
if meta, ok := readVideoMetaIfValid(metaPath, vfi); ok && meta != nil {
out.MetaReady = true
}
}
// KI nur dann fertig, wenn Sprite existiert UND AI in meta.json vorhanden ist
out.AnalyzeReady = out.SpritesReady && hasAIResultsForOutput(videoPath)
}
return out
}
func firstMissingPrimaryRegeneratePhaseError(videoPath string, assetID string) error {
truth := regeneratePhaseTruthForVideo(videoPath, assetID)
if !truth.MetaReady {
return errors.New("meta.json fehlt oder ist noch unvollständig")
}
if !truth.ThumbReady {
return errors.New("preview.jpg fehlt oder ist leer")
}
if !truth.TeaserReady {
return errors.New("preview.mp4 fehlt oder ist leer")
}
return nil
}
func firstMissingDeferredRegeneratePhaseError(videoPath string, assetID string) error {
truth := regeneratePhaseTruthForVideo(videoPath, assetID)
if !truth.SpritesReady {
return errors.New("preview-sprite.jpg fehlt oder ist leer")
}
if !truth.AnalyzeReady {
return errors.New("KI-Analyse fehlt oder wurde nicht in meta.json gespeichert")
}
return nil
}
func ensureRegenerateAssetsWorkersStarted() {
regenerateAssetsMu.Lock()
defer regenerateAssetsMu.Unlock()
@ -127,6 +216,34 @@ func finishRegenerateAssetsJob(file string, state string, errMsg string) {
job.EndedAt = time.Now()
}
func setRegenerateAssetsJobPhase(
file string,
queue string,
phase string,
state string,
label string,
) {
regenerateAssetsMu.Lock()
defer regenerateAssetsMu.Unlock()
job := regenerateAssetsJobs[file]
if job == nil {
return
}
if job.Phases == nil {
job.Phases = make(map[string]regenerateAssetsPhase)
}
key := strings.ToLower(strings.TrimSpace(queue)) + ":" + strings.ToLower(strings.TrimSpace(phase))
job.Phases[key] = regenerateAssetsPhase{
Queue: strings.TrimSpace(queue),
Phase: strings.TrimSpace(phase),
State: strings.TrimSpace(state),
Label: strings.TrimSpace(label),
TS: time.Now().UnixMilli(),
}
}
func removeRegenerateAssetsJobLater(file string, after time.Duration) {
time.AfterFunc(after, func() {
regenerateAssetsMu.Lock()
@ -142,11 +259,23 @@ func getRegenerateAssetsJobsSnapshot() []map[string]any {
out := make([]map[string]any, 0, len(regenerateAssetsJobs))
for _, job := range regenerateAssetsJobs {
phases := make([]map[string]any, 0, len(job.Phases))
for _, p := range job.Phases {
phases = append(phases, map[string]any{
"queue": p.Queue,
"phase": p.Phase,
"state": p.State,
"label": p.Label,
"ts": p.TS,
})
}
out = append(out, map[string]any{
"file": job.File,
"assetId": job.AssetID,
"state": job.State,
"error": job.Error,
"phases": phases,
"enqueuedAt": job.EnqueuedAt.UnixMilli(),
"startedAt": func() int64 {
if job.StartedAt.IsZero() {
@ -185,32 +314,44 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
ctx := job.Ctx
setRegenerateAssetsTaskRunning(file, file)
cancelDeferredEnrichForFile(file)
select {
case <-ctx.Done():
cancelJob := func() {
finishRegenerateAssetsJob(file, "cancelled", "Abgebrochen")
setRegenerateAssetsTaskError(file, "Abgebrochen")
publishFinishedPostworkPhase(file, id, "postwork", "meta", "missing", "")
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "missing", "")
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "missing", "")
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "missing", "")
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "missing", "")
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "")
removeRegenerateAssetsJobLater(file, 2*time.Second)
return
default:
}
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "")
failJob := func(queue, phase, msg string) {
if queue != "" && phase != "" {
publishFinishedPostworkPhase(file, id, queue, phase, "error", "Fehler")
}
finishRegenerateAssetsJob(file, "error", msg)
setRegenerateAssetsTaskError(file, msg)
removeRegenerateAssetsJobLater(file, 5*time.Second)
}
if ctx.Err() == context.Canceled {
cancelJob()
return
}
// Initialstatus
publishFinishedPostworkPhase(file, id, "postwork", "meta", "running", "Meta")
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "running", "Teaser")
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "running", "Vorschaubild")
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "running", "Sprites")
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild")
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser")
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites")
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse")
// Alte Assets löschen
if id != "" {
if assetDir, _, _, _, metaPath, err := assetPathsForID(id); err == nil {
_ = os.Remove(filepath.Join(assetDir, "preview.jpg"))
@ -220,116 +361,99 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
}
}
assetWatchDone := make(chan struct{})
go func(ctx context.Context, file string, id string) {
ticker := time.NewTicker(300 * time.Millisecond)
defer ticker.Stop()
sentMetaDone := false
sentTeaserDone := false
sentThumbDone := false
sentSpritesDone := false
checkAndEmit := func() {
truth := finishedPhaseTruthForID(id)
if truth.MetaReady && !sentMetaDone {
sentMetaDone = true
publishFinishedPostworkPhase(file, id, "postwork", "meta", "done", "Meta")
}
if truth.TeaserReady && !sentTeaserDone {
sentTeaserDone = true
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "done", "Teaser")
}
if truth.ThumbReady && !sentThumbDone {
sentThumbDone = true
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "done", "Vorschaubild")
}
if truth.SpritesReady && !sentSpritesDone {
sentSpritesDone = true
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites")
}
// 1) Meta
if _, err := ensureMetaForVideoCtx(ctx, videoPath, ""); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
cancelJob()
return
}
for {
select {
case <-ctx.Done():
return
case <-assetWatchDone:
checkAndEmit()
return
case <-ticker.C:
checkAndEmit()
}
}
}(ctx, file, id)
_, errAssets := ensureAssetsForVideoWithProgressCtx(ctx, videoPath, "", nil)
close(assetWatchDone)
if errors.Is(errAssets, context.Canceled) || ctx.Err() == context.Canceled {
finishRegenerateAssetsJob(file, "cancelled", "Abgebrochen")
setRegenerateAssetsTaskError(file, "Abgebrochen")
publishFinishedPostworkPhase(file, id, "postwork", "meta", "missing", "")
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "missing", "")
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "missing", "")
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "missing", "")
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "")
removeRegenerateAssetsJobLater(file, 2*time.Second)
failJob("postwork", "meta", err.Error())
return
}
if errAssets == nil {
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "KI-Analyse")
}
errAI := ensureDeferredAssetsAndAI(ctx, videoPath, "")
if errors.Is(errAI, context.Canceled) || ctx.Err() == context.Canceled {
finishRegenerateAssetsJob(file, "cancelled", "Abgebrochen")
setRegenerateAssetsTaskError(file, "Abgebrochen")
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "")
removeRegenerateAssetsJobLater(file, 2*time.Second)
if !regeneratePhaseTruthForVideo(videoPath, id).MetaReady {
failJob("postwork", "meta", "meta.json fehlt oder ist noch unvollständig")
return
}
if errAssets == nil && errAI == nil {
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "KI-Analyse")
}
publishFinishedPostworkPhase(file, id, "postwork", "meta", "done", "Meta")
if errAssets != nil || errAI != nil {
errMsg := ""
if errAssets != nil {
errMsg = errAssets.Error()
} else {
errMsg = errAI.Error()
// 2) Thumb
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "running", "Vorschaubild")
if _, err := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, videoPath, "", nil); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
cancelJob()
return
}
finishRegenerateAssetsJob(file, "error", errMsg)
setRegenerateAssetsTaskError(file, errMsg)
errEv := finishedPostworkEvent{
Type: "finished_postwork",
File: file,
AssetID: id,
Queue: "enrich",
State: "error",
Phase: "assets",
Label: "Fehler",
TS: time.Now().UnixMilli(),
}
if b, err := json.Marshal(errEv); err == nil {
publishSSE("finishedPostwork", b)
}
removeRegenerateAssetsJobLater(file, 5*time.Second)
failJob("postwork", "thumb", err.Error())
return
}
if !regeneratePhaseTruthForVideo(videoPath, id).ThumbReady {
failJob("postwork", "thumb", "preview.jpg fehlt oder ist leer")
return
}
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "done", "Vorschaubild")
// 3) Teaser
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "running", "Teaser")
if _, err := ensureTeaserForVideoCtx(ctx, videoPath, ""); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
cancelJob()
return
}
failJob("postwork", "teaser", err.Error())
return
}
if !regeneratePhaseTruthForVideo(videoPath, id).TeaserReady {
failJob("postwork", "teaser", "preview.mp4 fehlt oder ist leer")
return
}
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "done", "Teaser")
// 4) Sprites
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "running", "Sprites")
if _, err := ensureSpritesForVideoCtx(ctx, videoPath, ""); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
cancelJob()
return
}
failJob("postwork", "sprites", err.Error())
return
}
if !regeneratePhaseTruthForVideo(videoPath, id).SpritesReady {
failJob("postwork", "sprites", "preview-sprite.jpg fehlt oder ist leer")
return
}
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites")
// 5) KI-Analyse
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "KI-Analyse")
if _, err := ensureAnalyzeForVideoCtx(ctx, videoPath, "", "nsfw"); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
cancelJob()
return
}
failJob("enrich", "analyze", err.Error())
return
}
if !regeneratePhaseTruthForVideo(videoPath, id).AnalyzeReady {
failJob("enrich", "analyze", "KI-Analyse fehlt oder wurde nicht in meta.json gespeichert")
return
}
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "KI-Analyse")
finishRegenerateAssetsJob(file, "done", "")
setRegenerateAssetsTaskDone(file, "Fertig")
removeRegenerateAssetsJobLater(file, 3*time.Second)
@ -346,6 +470,18 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&req)
file := strings.TrimSpace(req.File)
if file == "" {
regenerateAssetsMu.Lock()
for _, j := range regenerateAssetsJobs {
if j != nil && (j.State == "running" || j.State == "queued") {
file = j.File
break
}
}
regenerateAssetsMu.Unlock()
}
if file == "" {
http.Error(w, "missing file", http.StatusBadRequest)
return
@ -412,10 +548,23 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
regenerateAssetsMu.Unlock()
w.Header().Set("Content-Type", "application/json")
phases := make([]map[string]any, 0, len(existing.Phases))
for _, p := range existing.Phases {
phases = append(phases, map[string]any{
"queue": p.Queue,
"phase": p.Phase,
"state": p.State,
"label": p.Label,
"ts": p.TS,
})
}
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"file": file,
"state": existing.State,
"ok": true,
"file": file,
"state": existing.State,
"error": existing.Error,
"phases": phases,
})
return
}
@ -436,13 +585,14 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
Cancel: runCancel,
State: "queued",
EnqueuedAt: time.Now(),
Phases: make(map[string]regenerateAssetsPhase),
}
enqueueRegenerateAssetsJob(job)
publishFinishedPostworkPhase(file, id, "postwork", "meta", "queued", "Meta")
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser")
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild")
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser")
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites")
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse")

View File

@ -221,7 +221,7 @@ function normalizeRoomStatusText(v: unknown): 'public' | 'private' | 'hidden' |
function roomStatusLabelDE(v: unknown): string {
switch (normalizeRoomStatusText(v)) {
case 'public':
return 'öffentlich'
return 'live'
case 'private':
return 'privat'
case 'hidden':
@ -1207,7 +1207,7 @@ export default function App() {
const title =
nextStatus === 'public'
? `${displayName} ist jetzt öffentlich`
? `${displayName} ist live`
: `${displayName} Status geändert`
const message = `Status: ${roomStatusLabelDE(prevStatus)}${roomStatusLabelDE(nextStatus)}`

View File

@ -59,7 +59,7 @@ export default function CookieModal({
}
return (
<Dialog open={open} onClose={onClose} className="relative z-50">
<Dialog open={open} onClose={onClose} className="relative z-[100]">
<div className="fixed inset-0 bg-black/40 backdrop-blur-[2px]" aria-hidden="true" />
<div className="fixed inset-0 flex items-center justify-center p-4">
@ -147,11 +147,11 @@ export default function CookieModal({
</div>
) : (
<div className="overflow-hidden">
<table className="min-w-full table-fixed text-sm">
<table className="w-full table-fixed text-sm">
<thead className="bg-gray-50 text-gray-700 dark:bg-white/5 dark:text-gray-200">
<tr className="border-b border-gray-200/70 dark:border-white/10">
<th className="w-[180px] px-4 py-2.5 text-left font-medium">Name</th>
<th className="px-4 py-2.5 text-left font-medium">Wert</th>
<th className="w-[calc(100%-290px)] px-4 py-2.5 text-left font-medium">Wert</th>
<th className="w-[110px] px-4 py-2.5 text-right font-medium">Aktion</th>
</tr>
</thead>
@ -166,7 +166,7 @@ export default function CookieModal({
<div
className="
inline-flex max-w-full items-center rounded-md
border border-gray-200 bg-gray-50 px-2 py-1
border border-gray-200 bg-gray-50 px-3 py-2
font-mono text-xs text-gray-800
dark:border-white/10 dark:bg-white/5 dark:text-gray-100
"
@ -189,18 +189,16 @@ export default function CookieModal({
</div>
</td>
<td className="px-4 py-3 text-right align-top">
<button
<td className="w-[110px] px-4 py-3 text-right align-top whitespace-nowrap">
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => removeCookie(c.name)}
className="
inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
text-red-700 hover:bg-red-50
dark:text-red-300 dark:hover:bg-red-500/10
"
className="text-red-700 dark:text-red-300"
>
Entfernen
</button>
</Button>
</td>
</tr>
))}

View File

@ -441,10 +441,10 @@ function labelForStep(queue: 'postwork' | 'enrich', phase?: string): string {
switch (`${queue}:${p}`) {
case 'postwork:meta':
return 'Meta'
case 'postwork:teaser':
return 'Teaser'
case 'postwork:thumb':
return 'Vorschaubild'
case 'postwork:teaser':
return 'Teaser'
case 'postwork:sprites':
return 'Sprites'
case 'enrich:analyze':
@ -738,10 +738,10 @@ function buildCompletedPostworkSummaryFromMeta(
if (stepDef.key === 'postwork:meta') {
state = hasMeta ? 'done' : 'missing'
} else if (stepDef.key === 'postwork:teaser') {
state = hasTeaser ? 'done' : 'missing'
} else if (stepDef.key === 'postwork:thumb') {
state = hasThumb ? 'done' : 'missing'
} else if (stepDef.key === 'postwork:teaser') {
state = hasTeaser ? 'done' : 'missing'
} else if (stepDef.key === 'postwork:sprites') {
state = hasSprites ? 'done' : 'missing'
} else if (stepDef.key === 'enrich:analyze') {
@ -849,10 +849,10 @@ function normalizeFinishedPhase(
case 'remuxing':
case 'moving':
return 'meta'
case 'assets':
return 'teaser'
case 'thumb':
return 'thumb'
case 'assets':
return 'teaser'
case 'sprites':
return 'sprites'
default:
@ -1489,10 +1489,73 @@ export default function FinishedDownloads({
const file = String(item?.file ?? '').trim()
if (!file) continue
const rawState = String(item?.state ?? '').trim().toLowerCase()
const assetId =
String(item?.assetId ?? '').trim() || fileStemFromOutput(file)
const phases = Array.isArray(item?.phases) ? item.phases : []
// ✅ Bevorzugt: echte Phasen aus dem Backend-Snapshot übernehmen
if (phases.length > 0) {
const restored = phases
.map((p: any): FinishedPostworkBadge | null => {
const queueRaw = String(p?.queue ?? '').trim().toLowerCase()
const phaseRaw = String(p?.phase ?? '').trim().toLowerCase()
const rawState = String(p?.state ?? '').trim().toLowerCase()
const state: FinishedPostworkState =
rawState === 'queued' ||
rawState === 'running' ||
rawState === 'done' ||
rawState === 'error' ||
rawState === 'missing'
? rawState
: rawState === 'cancelled'
? 'missing'
: 'missing'
if (
(queueRaw !== 'postwork' && queueRaw !== 'enrich') ||
!phaseRaw
) {
return null
}
return {
file,
assetId,
queue: queueRaw as 'postwork' | 'enrich',
phase: phaseRaw,
state,
label:
String(p?.label ?? '').trim() ||
labelForStep(queueRaw as 'postwork' | 'enrich', phaseRaw),
position:
typeof p?.position === 'number' ? p.position : undefined,
waiting:
typeof p?.waiting === 'number' ? p.waiting : undefined,
running:
typeof p?.running === 'number' ? p.running : undefined,
maxParallel:
typeof p?.maxParallel === 'number'
? p.maxParallel
: undefined,
ts:
typeof p?.ts === 'number' && Number.isFinite(p.ts)
? p.ts
: Date.now(),
}
})
.filter(Boolean) as FinishedPostworkBadge[]
if (restored.length > 0) {
next[file] = restored
continue
}
}
// ✅ Fallback nur für ältere Backends ohne phases[]
const rawState = String(item?.state ?? '').trim().toLowerCase()
const state: FinishedPostworkState =
rawState === 'queued' ||
rawState === 'running' ||
@ -1525,8 +1588,8 @@ export default function FinishedDownloads({
assetId,
queue: 'postwork',
state,
phase: 'teaser',
label: 'Teaser',
phase: 'thumb',
label: 'Vorschaubild',
ts: Date.now(),
},
{
@ -1534,8 +1597,8 @@ export default function FinishedDownloads({
assetId,
queue: 'postwork',
state,
phase: 'thumb',
label: 'Vorschaubild',
phase: 'teaser',
label: 'Teaser',
ts: Date.now(),
},
{
@ -2835,8 +2898,8 @@ export default function FinishedDownloads({
assetId: fileStemFromOutput(cleanFile),
queue: 'postwork',
state: 'queued',
phase: 'teaser',
label: 'Teaser',
phase: 'thumb',
label: 'Vorschaubild',
ts: Date.now(),
},
{
@ -2844,8 +2907,8 @@ export default function FinishedDownloads({
assetId: fileStemFromOutput(cleanFile),
queue: 'postwork',
state: 'queued',
phase: 'thumb',
label: 'Vorschaubild',
phase: 'teaser',
label: 'Teaser',
ts: Date.now(),
},
{

View File

@ -69,6 +69,15 @@ function shortTaskFilename(name?: string, max = 52) {
return '…' + s.slice(-(max - 1))
}
function regenerateTaskId(file: string) {
return `regenerate-assets:${file}`
}
function regenerateTaskTitle(file: string) {
const short = shortTaskFilename(file, 44)
return short ? `Assets neu generieren · ${short}` : 'Assets neu generieren'
}
export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
const [saving, setSaving] = useState(false)
@ -141,14 +150,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
fading: false,
})
const [regenerateAssetsTask, setRegenerateAssetsTask] = useState<TaskItem>({
id: 'regenerate-assets',
status: 'idle',
title: 'Assets neu generieren',
text: '',
cancellable: true,
fading: false,
})
const [regenerateAssetsTasks, setRegenerateAssetsTasks] = useState<TaskItem[]>([])
const pauseGB = Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB ?? 5)
const uiPauseGB = diskStatus?.pauseGB ?? pauseGB
@ -274,56 +276,106 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
}))
}
const regen = data.regenerateAssets
if (regen?.running) {
setRegenerateAssetsTask((t) => ({
...t,
status: 'running',
title: 'Assets neu generieren',
text: String(regen.text || regen.file || ''),
err: undefined,
cancellable: true,
fading: false,
}))
} else if (regen?.error) {
const regenError = String(regen.error || '').trim()
const cancelled =
regenError.toLowerCase() === 'abgebrochen' ||
regenError.toLowerCase() === 'abbruch' ||
regenError.toLowerCase() === 'cancelled' ||
regenError.toLowerCase() === 'canceled'
const regenJobs = Array.isArray(data?.regenerateAssetsJobs)
? data.regenerateAssetsJobs
: []
setRegenerateAssetsTask((t) => ({
...t,
status: cancelled ? 'cancelled' : 'error',
title: 'Assets neu generieren',
text: String(regen.file || ''), // nur Dateiname anzeigen, kein "Abgebrochen" darunter
err: cancelled ? undefined : regenError,
cancellable: false,
fading: false,
}))
} else if (regen?.text || regen?.file) {
setRegenerateAssetsTask((t) => ({
...t,
status: 'done',
title: 'Assets neu generieren',
text: String(regen.text || regen.file || ''),
err: undefined,
cancellable: false,
fading: false,
}))
} else {
setRegenerateAssetsTask((t) => ({
...t,
status: 'idle',
title: 'Assets neu generieren',
text: '',
err: undefined,
cancellable: true,
fading: false,
}))
}
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 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 (state === 'error') {
status = 'error'
} else if (cancelled) {
status = 'cancelled'
}
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 text =
status === 'running'
? phaseLabel
? `${shortTaskFilename(file, 38)} · ${phaseLabel}`
: shortTaskFilename(file, 52)
: shortTaskFilename(file, 52)
return {
id: regenerateTaskId(file),
status,
title: regenerateTaskTitle(file),
text,
err: status === 'error' ? error || 'Fehler beim Regenerieren.' : undefined,
cancellable: status === 'running',
fading: false,
}
})
.filter(Boolean) as TaskItem[]
nextTasks.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }))
setRegenerateAssetsTasks((prev) => {
const nextById = new Map(nextTasks.map((t) => [t.id, t]))
const prevById = new Map(prev.map((t) => [t.id, t]))
const merged: TaskItem[] = []
for (const next of nextTasks) {
const prevTask = prevById.get(next.id)
merged.push(
prevTask?.fading
? { ...next, fading: true }
: next
)
}
for (const prevTask of prev) {
if (nextById.has(prevTask.id)) continue
if (prevTask.status === 'running') continue
if (prevTask.fading) {
merged.push(prevTask)
continue
}
merged.push({ ...prevTask, fading: true })
const file = prevTask.id.startsWith('regenerate-assets:')
? prevTask.id.slice('regenerate-assets:'.length)
: ''
if (file) {
window.setTimeout(() => {
setRegenerateAssetsTasks((cur) => cur.filter((t) => t.id !== prevTask.id))
}, 500)
}
}
return merged.sort((a, b) =>
a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })
)
})
const cleanup = data.cleanup
@ -555,6 +607,20 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
}, delayMs)
}
function fadeOutRegenerateTask(file: string, delayMs = 3500, fadeMs = 500) {
const id = regenerateTaskId(file)
window.setTimeout(() => {
setRegenerateAssetsTasks((prev) =>
prev.map((t) => (t.id === id ? { ...t, fading: true } : t))
)
window.setTimeout(() => {
setRegenerateAssetsTasks((prev) => prev.filter((t) => t.id !== id))
}, fadeMs)
}, delayMs)
}
async function cleanupSmallDone() {
setErr(null)
setMsg(null)
@ -659,31 +725,44 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
}
}
async function cancelRegenerateAssetsTask() {
// UI sofort aktualisieren
setRegenerateAssetsTask((t) => ({
...t,
status: 'cancelled',
title: 'Assets neu generieren',
text: t.text,
err: undefined,
cancellable: false,
fading: false,
}))
async function cancelRegenerateAssetsTask(file: string) {
const cleanFile = String(file || '').trim()
if (!cleanFile) return
setRegenerateAssetsTasks((prev) =>
prev.map((t) =>
t.id === regenerateTaskId(cleanFile)
? {
...t,
status: 'cancelled',
title: regenerateTaskTitle(cleanFile),
text: shortTaskFilename(cleanFile, 52),
err: undefined,
cancellable: false,
fading: false,
}
: t
)
)
try {
// Vorschlag: gleicher Endpoint wie Start, aber DELETE zum Abbrechen
await fetch('/api/record/regenerate-assets', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store' as any,
body: JSON.stringify({ file: cleanFile }),
})
} catch {
// ignore
}
fadeOutTask(setRegenerateAssetsTask)
fadeOutRegenerateTask(cleanFile)
}
const visibleTasks = [assetsTask, ...regenerateAssetsTasks, cleanupTask].filter(
(t) => t.status !== 'idle'
)
return (
<Card
header={
@ -716,15 +795,16 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
>
<div className="space-y-4">
<TaskList
tasks={[assetsTask, regenerateAssetsTask, cleanupTask]}
tasks={visibleTasks}
onCancel={(id: string) => {
if (id === 'generate-assets') {
void cancelAssetsTask()
return
}
if (id === 'regenerate-assets') {
void cancelRegenerateAssetsTask()
if (id.startsWith('regenerate-assets:')) {
const file = id.slice('regenerate-assets:'.length)
void cancelRegenerateAssetsTask(file)
return
}
}}