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 return false, nil
} }
genCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) genCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel() defer cancel()
if err := genSem.Acquire(genCtx); err != nil { 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) return fmt.Errorf("deferred sprite failed for %s: %w", videoPath, err)
} }
truth := finishedPhaseTruthForID(id) spriteTruth := regeneratePhaseTruthForVideo(videoPath, id)
if !truth.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)
} }
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) 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 return nil
} }
@ -995,7 +1001,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
return return
} }
genCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) genCtx, cancel := context.WithTimeout(ctx, 4*time.Minute)
defer cancel() defer cancel()
if err := genSem.Acquire(genCtx); err != nil { if err := genSem.Acquire(genCtx); err != nil {

View File

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

View File

@ -234,7 +234,7 @@ func runMFC(ctx context.Context, username string, outArg string) error {
return err return err
} }
if st != StatusPublic { 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) m3u8URL, err := mfc.GetVideoURL(false)

View File

@ -1,19 +1,19 @@
{ {
"databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable", "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", "recordDir": "C:\\Users\\Rother\\Desktop\\Privat\\records",
"doneDir": "C:\\Users\\Rother\\Desktop\\Privat\\records\\done", "doneDir": "C:\\Users\\Rother\\Desktop\\Privat\\records\\done",
"ffmpegPath": "C:\\ffmpeg\\ffmpeg.exe", "ffmpegPath": "",
"autoAddToDownloadList": false, "autoAddToDownloadList": true,
"autoStartAddedDownloads": false, "autoStartAddedDownloads": true,
"useChaturbateApi": true, "useChaturbateApi": true,
"useMyFreeCamsWatcher": true, "useMyFreeCamsWatcher": true,
"autoDeleteSmallDownloads": true, "autoDeleteSmallDownloads": false,
"autoDeleteSmallDownloadsBelowMB": 200, "autoDeleteSmallDownloadsBelowMB": 50,
"lowDiskPauseBelowGB": 5, "lowDiskPauseBelowGB": 5,
"blurPreviews": false, "blurPreviews": false,
"teaserPlayback": "hover", "teaserPlayback": "all",
"teaserAudio": true, "teaserAudio": true,
"enableNotifications": 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"` 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 { type regenerateAssetsJob struct {
File string File string
VideoPath string VideoPath string
@ -28,12 +36,14 @@ type regenerateAssetsJob struct {
Ctx context.Context Ctx context.Context
Cancel context.CancelFunc Cancel context.CancelFunc
State string // queued | running | done | error | cancelled State string
Error string Error string
EnqueuedAt time.Time EnqueuedAt time.Time
StartedAt time.Time StartedAt time.Time
EndedAt time.Time EndedAt time.Time
Phases map[string]regenerateAssetsPhase
} }
var ( var (
@ -51,6 +61,8 @@ func publishFinishedPostworkPhase(
state string, state string,
label string, label string,
) { ) {
setRegenerateAssetsJobPhase(file, queue, phase, state, label)
ev := finishedPostworkEvent{ ev := finishedPostworkEvent{
Type: "finished_postwork", Type: "finished_postwork",
File: file, 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() { func ensureRegenerateAssetsWorkersStarted() {
regenerateAssetsMu.Lock() regenerateAssetsMu.Lock()
defer regenerateAssetsMu.Unlock() defer regenerateAssetsMu.Unlock()
@ -127,6 +216,34 @@ func finishRegenerateAssetsJob(file string, state string, errMsg string) {
job.EndedAt = time.Now() 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) { func removeRegenerateAssetsJobLater(file string, after time.Duration) {
time.AfterFunc(after, func() { time.AfterFunc(after, func() {
regenerateAssetsMu.Lock() regenerateAssetsMu.Lock()
@ -142,11 +259,23 @@ func getRegenerateAssetsJobsSnapshot() []map[string]any {
out := make([]map[string]any, 0, len(regenerateAssetsJobs)) out := make([]map[string]any, 0, len(regenerateAssetsJobs))
for _, job := range 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{ 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,
"enqueuedAt": job.EnqueuedAt.UnixMilli(), "enqueuedAt": job.EnqueuedAt.UnixMilli(),
"startedAt": func() int64 { "startedAt": func() int64 {
if job.StartedAt.IsZero() { if job.StartedAt.IsZero() {
@ -185,32 +314,44 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
ctx := job.Ctx ctx := job.Ctx
setRegenerateAssetsTaskRunning(file, file) setRegenerateAssetsTaskRunning(file, file)
cancelDeferredEnrichForFile(file) cancelDeferredEnrichForFile(file)
select { cancelJob := func() {
case <-ctx.Done():
finishRegenerateAssetsJob(file, "cancelled", "Abgebrochen") finishRegenerateAssetsJob(file, "cancelled", "Abgebrochen")
setRegenerateAssetsTaskError(file, "Abgebrochen") setRegenerateAssetsTaskError(file, "Abgebrochen")
publishFinishedPostworkPhase(file, id, "postwork", "meta", "missing", "") publishFinishedPostworkPhase(file, id, "postwork", "meta", "missing", "")
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "missing", "")
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "missing", "") publishFinishedPostworkPhase(file, id, "postwork", "thumb", "missing", "")
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "missing", "")
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "missing", "") publishFinishedPostworkPhase(file, id, "postwork", "sprites", "missing", "")
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "") publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "")
removeRegenerateAssetsJobLater(file, 2*time.Second) 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", "meta", "running", "Meta")
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "running", "Teaser") publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild")
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "running", "Vorschaubild") publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser")
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "running", "Sprites") publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites")
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse")
// Alte Assets löschen
if id != "" { if id != "" {
if assetDir, _, _, _, metaPath, err := assetPathsForID(id); err == nil { if assetDir, _, _, _, metaPath, err := assetPathsForID(id); err == nil {
_ = os.Remove(filepath.Join(assetDir, "preview.jpg")) _ = os.Remove(filepath.Join(assetDir, "preview.jpg"))
@ -220,116 +361,99 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
} }
} }
assetWatchDone := make(chan struct{}) // 1) Meta
if _, err := ensureMetaForVideoCtx(ctx, videoPath, ""); err != nil {
go func(ctx context.Context, file string, id string) { if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
ticker := time.NewTicker(300 * time.Millisecond) cancelJob()
defer ticker.Stop() return
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")
}
} }
failJob("postwork", "meta", err.Error())
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)
return return
} }
if errAssets == nil { if !regeneratePhaseTruthForVideo(videoPath, id).MetaReady {
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "KI-Analyse") failJob("postwork", "meta", "meta.json fehlt oder ist noch unvollständig")
}
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)
return return
} }
if errAssets == nil && errAI == nil { publishFinishedPostworkPhase(file, id, "postwork", "meta", "done", "Meta")
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "KI-Analyse")
}
if errAssets != nil || errAI != nil { // 2) Thumb
errMsg := "" publishFinishedPostworkPhase(file, id, "postwork", "thumb", "running", "Vorschaubild")
if errAssets != nil {
errMsg = errAssets.Error() if _, err := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, videoPath, "", nil); err != nil {
} else { if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
errMsg = errAI.Error() cancelJob()
return
} }
failJob("postwork", "thumb", err.Error())
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)
return 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", "") finishRegenerateAssetsJob(file, "done", "")
setRegenerateAssetsTaskDone(file, "Fertig") setRegenerateAssetsTaskDone(file, "Fertig")
removeRegenerateAssetsJobLater(file, 3*time.Second) removeRegenerateAssetsJobLater(file, 3*time.Second)
@ -346,6 +470,18 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&req) _ = json.NewDecoder(r.Body).Decode(&req)
file := strings.TrimSpace(req.File) 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 == "" { if file == "" {
http.Error(w, "missing file", http.StatusBadRequest) http.Error(w, "missing file", http.StatusBadRequest)
return return
@ -412,10 +548,23 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
regenerateAssetsMu.Unlock() regenerateAssetsMu.Unlock()
w.Header().Set("Content-Type", "application/json") 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{ _ = json.NewEncoder(w).Encode(map[string]any{
"ok": true, "ok": true,
"file": file, "file": file,
"state": existing.State, "state": existing.State,
"error": existing.Error,
"phases": phases,
}) })
return return
} }
@ -436,13 +585,14 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
Cancel: runCancel, Cancel: runCancel,
State: "queued", State: "queued",
EnqueuedAt: time.Now(), EnqueuedAt: time.Now(),
Phases: make(map[string]regenerateAssetsPhase),
} }
enqueueRegenerateAssetsJob(job) enqueueRegenerateAssetsJob(job)
publishFinishedPostworkPhase(file, id, "postwork", "meta", "queued", "Meta") 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", "thumb", "queued", "Vorschaubild")
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", "KI-Analyse")

View File

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

View File

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

View File

@ -441,10 +441,10 @@ function labelForStep(queue: 'postwork' | 'enrich', phase?: string): string {
switch (`${queue}:${p}`) { switch (`${queue}:${p}`) {
case 'postwork:meta': case 'postwork:meta':
return 'Meta' return 'Meta'
case 'postwork:teaser':
return 'Teaser'
case 'postwork:thumb': case 'postwork:thumb':
return 'Vorschaubild' return 'Vorschaubild'
case 'postwork:teaser':
return 'Teaser'
case 'postwork:sprites': case 'postwork:sprites':
return 'Sprites' return 'Sprites'
case 'enrich:analyze': case 'enrich:analyze':
@ -738,10 +738,10 @@ function buildCompletedPostworkSummaryFromMeta(
if (stepDef.key === 'postwork:meta') { if (stepDef.key === 'postwork:meta') {
state = hasMeta ? 'done' : 'missing' state = hasMeta ? 'done' : 'missing'
} else if (stepDef.key === 'postwork:teaser') {
state = hasTeaser ? 'done' : 'missing'
} else if (stepDef.key === 'postwork:thumb') { } else if (stepDef.key === 'postwork:thumb') {
state = hasThumb ? 'done' : 'missing' state = hasThumb ? 'done' : 'missing'
} else if (stepDef.key === 'postwork:teaser') {
state = hasTeaser ? 'done' : 'missing'
} else if (stepDef.key === 'postwork:sprites') { } else if (stepDef.key === 'postwork:sprites') {
state = hasSprites ? 'done' : 'missing' state = hasSprites ? 'done' : 'missing'
} else if (stepDef.key === 'enrich:analyze') { } else if (stepDef.key === 'enrich:analyze') {
@ -849,10 +849,10 @@ function normalizeFinishedPhase(
case 'remuxing': case 'remuxing':
case 'moving': case 'moving':
return 'meta' return 'meta'
case 'assets':
return 'teaser'
case 'thumb': case 'thumb':
return 'thumb' return 'thumb'
case 'assets':
return 'teaser'
case 'sprites': case 'sprites':
return 'sprites' return 'sprites'
default: default:
@ -1489,10 +1489,73 @@ export default function FinishedDownloads({
const file = String(item?.file ?? '').trim() const file = String(item?.file ?? '').trim()
if (!file) continue if (!file) continue
const rawState = String(item?.state ?? '').trim().toLowerCase()
const assetId = const assetId =
String(item?.assetId ?? '').trim() || fileStemFromOutput(file) 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 = const state: FinishedPostworkState =
rawState === 'queued' || rawState === 'queued' ||
rawState === 'running' || rawState === 'running' ||
@ -1525,8 +1588,8 @@ export default function FinishedDownloads({
assetId, assetId,
queue: 'postwork', queue: 'postwork',
state, state,
phase: 'teaser', phase: 'thumb',
label: 'Teaser', label: 'Vorschaubild',
ts: Date.now(), ts: Date.now(),
}, },
{ {
@ -1534,8 +1597,8 @@ export default function FinishedDownloads({
assetId, assetId,
queue: 'postwork', queue: 'postwork',
state, state,
phase: 'thumb', phase: 'teaser',
label: 'Vorschaubild', label: 'Teaser',
ts: Date.now(), ts: Date.now(),
}, },
{ {
@ -2835,8 +2898,8 @@ export default function FinishedDownloads({
assetId: fileStemFromOutput(cleanFile), assetId: fileStemFromOutput(cleanFile),
queue: 'postwork', queue: 'postwork',
state: 'queued', state: 'queued',
phase: 'teaser', phase: 'thumb',
label: 'Teaser', label: 'Vorschaubild',
ts: Date.now(), ts: Date.now(),
}, },
{ {
@ -2844,8 +2907,8 @@ export default function FinishedDownloads({
assetId: fileStemFromOutput(cleanFile), assetId: fileStemFromOutput(cleanFile),
queue: 'postwork', queue: 'postwork',
state: 'queued', state: 'queued',
phase: 'thumb', phase: 'teaser',
label: 'Vorschaubild', label: 'Teaser',
ts: Date.now(), ts: Date.now(),
}, },
{ {

View File

@ -69,6 +69,15 @@ function shortTaskFilename(name?: string, max = 52) {
return '…' + s.slice(-(max - 1)) 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) { 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)
@ -141,14 +150,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
fading: false, fading: false,
}) })
const [regenerateAssetsTask, setRegenerateAssetsTask] = useState<TaskItem>({ const [regenerateAssetsTasks, setRegenerateAssetsTasks] = useState<TaskItem[]>([])
id: 'regenerate-assets',
status: 'idle',
title: 'Assets neu generieren',
text: '',
cancellable: true,
fading: false,
})
const pauseGB = Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB ?? 5) const pauseGB = Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB ?? 5)
const uiPauseGB = diskStatus?.pauseGB ?? pauseGB const uiPauseGB = diskStatus?.pauseGB ?? pauseGB
@ -274,56 +276,106 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
})) }))
} }
const regen = data.regenerateAssets const regenJobs = Array.isArray(data?.regenerateAssetsJobs)
if (regen?.running) { ? data.regenerateAssetsJobs
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'
setRegenerateAssetsTask((t) => ({ const nextTasks: TaskItem[] = regenJobs
...t, .map((job: any): TaskItem | null => {
status: cancelled ? 'cancelled' : 'error', const file = String(job?.file || '').trim()
title: 'Assets neu generieren', if (!file) return null
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 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 const cleanup = data.cleanup
@ -555,6 +607,20 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
}, delayMs) }, 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() { async function cleanupSmallDone() {
setErr(null) setErr(null)
setMsg(null) setMsg(null)
@ -659,31 +725,44 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
} }
} }
async function cancelRegenerateAssetsTask() { async function cancelRegenerateAssetsTask(file: string) {
// UI sofort aktualisieren const cleanFile = String(file || '').trim()
setRegenerateAssetsTask((t) => ({ if (!cleanFile) return
...t,
status: 'cancelled', setRegenerateAssetsTasks((prev) =>
title: 'Assets neu generieren', prev.map((t) =>
text: t.text, t.id === regenerateTaskId(cleanFile)
err: undefined, ? {
cancellable: false, ...t,
fading: false, status: 'cancelled',
})) title: regenerateTaskTitle(cleanFile),
text: shortTaskFilename(cleanFile, 52),
err: undefined,
cancellable: false,
fading: false,
}
: t
)
)
try { try {
// Vorschlag: gleicher Endpoint wie Start, aber DELETE zum Abbrechen
await fetch('/api/record/regenerate-assets', { await fetch('/api/record/regenerate-assets', {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store' as any, cache: 'no-store' as any,
body: JSON.stringify({ file: cleanFile }),
}) })
} catch { } catch {
// ignore // ignore
} }
fadeOutTask(setRegenerateAssetsTask) fadeOutRegenerateTask(cleanFile)
} }
const visibleTasks = [assetsTask, ...regenerateAssetsTasks, cleanupTask].filter(
(t) => t.status !== 'idle'
)
return ( return (
<Card <Card
header={ header={
@ -716,15 +795,16 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
> >
<div className="space-y-4"> <div className="space-y-4">
<TaskList <TaskList
tasks={[assetsTask, regenerateAssetsTask, cleanupTask]} tasks={visibleTasks}
onCancel={(id: string) => { onCancel={(id: string) => {
if (id === 'generate-assets') { if (id === 'generate-assets') {
void cancelAssetsTask() void cancelAssetsTask()
return return
} }
if (id === 'regenerate-assets') { if (id.startsWith('regenerate-assets:')) {
void cancelRegenerateAssetsTask() const file = id.slice('regenerate-assets:'.length)
void cancelRegenerateAssetsTask(file)
return return
} }
}} }}