fixed sprites
This commit is contained in:
parent
9cb7c530ed
commit
e19e735997
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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="
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
|
|
||||||
|
|||||||
@ -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)}`
|
||||||
|
|||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user