diff --git a/backend/analyze.go b/backend/analyze.go index c661b74..b385759 100644 --- a/backend/analyze.go +++ b/backend/analyze.go @@ -1049,7 +1049,10 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { AnalyzedAtUnix: time.Now().Unix(), } - if err := writeVideoAIForFile(ctx, outPath, "", ai); err != nil { + persistCtx, persistCancel := context.WithTimeout(ctx, 90*time.Second) + defer persistCancel() + + if err := writeVideoAIForFile(persistCtx, outPath, "", ai); err != nil { logAnalyzeError("write-meta-ai", file, outPath, err) publishAnalysisError(analyzeStartedAtMs, file, "Speichern fehlgeschlagen", err) @@ -1065,7 +1068,7 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { return } - autoDeleteLowRatedDownloadAfterAnalysis(ctx, outPath, rating) + autoDeleteLowRatedDownloadAfterAnalysis(persistCtx, outPath, rating) publishAnalyzePersistProgress(analyzeStartedAtMs, file, 1, "") publishAnalysisFinished(analyzeStartedAtMs, analyzeProgressTotal, file, "Analyse abgeschlossen") diff --git a/backend/analyze_videomae.go b/backend/analyze_videomae.go index 30df8c9..34cad82 100644 --- a/backend/analyze_videomae.go +++ b/backend/analyze_videomae.go @@ -17,7 +17,9 @@ const ( analyzeVideoMAEClipWindowSeconds = 4.0 analyzeVideoMAEClipStrideSeconds = 2.0 analyzeVideoMAEMinScore = 0.34 - analyzeVideoMAERequestBatchSize = 48 + analyzeVideoMAERequestBatchSize = 8 + analyzeVideoMAEMaxClips = 180 + analyzeVideoMAERequestTimeout = 75 * time.Second ) type analyzeVideoMAEClipReqItem struct { @@ -112,6 +114,31 @@ func buildAnalyzeVideoMAEClips( return clips } +func limitAnalyzeVideoMAEClips(clips []analyzeVideoMAEClipReqItem, maxClips int) []analyzeVideoMAEClipReqItem { + if maxClips <= 0 || len(clips) <= maxClips { + return clips + } + if maxClips == 1 { + return clips[:1] + } + + out := make([]analyzeVideoMAEClipReqItem, 0, maxClips) + lastIdx := -1 + for i := 0; i < maxClips; i++ { + idx := int(math.Round(float64(i) * float64(len(clips)-1) / float64(maxClips-1))) + if idx <= lastIdx { + idx = lastIdx + 1 + } + if idx >= len(clips) { + idx = len(clips) - 1 + } + out = append(out, clips[idx]) + lastIdx = idx + } + + return out +} + func predictVideoMAEPositionClipsForAnalyze( ctx context.Context, clips []analyzeVideoMAEClipReqItem, @@ -145,56 +172,68 @@ func predictVideoMAEPositionClipsForAnalyze( return nil, err } - req, err := http.NewRequestWithContext( - ctx, - http.MethodPost, - analyzeAIServerURL()+"/predict-position-clips", - bytes.NewReader(body), - ) + parsed, err := func() (analyzeVideoMAEClipPredictResp, error) { + var parsed analyzeVideoMAEClipPredictResp + + reqCtx, cancel := context.WithTimeout(ctx, analyzeVideoMAERequestTimeout) + defer cancel() + + req, err := http.NewRequestWithContext( + reqCtx, + http.MethodPost, + analyzeAIServerURL()+"/predict-position-clips", + bytes.NewReader(body), + ) + if err != nil { + return parsed, err + } + + req.Header.Set("Content-Type", "application/json") + addAIServerAuth(req) + + client := &http.Client{ + Timeout: analyzeVideoMAERequestTimeout + 5*time.Second, + } + + res, err := client.Do(req) + if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return parsed, ctxErr + } + return parsed, err + } + + rawBody, readErr := io.ReadAll(res.Body) + statusCode := res.StatusCode + _ = res.Body.Close() + if readErr != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return parsed, ctxErr + } + return parsed, readErr + } + + if err := json.Unmarshal(rawBody, &parsed); err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return parsed, ctxErr + } + return parsed, fmt.Errorf("AI server VideoMAE JSON ungueltig: HTTP %d: %s", statusCode, strings.TrimSpace(string(rawBody))) + } + + if statusCode < 200 || statusCode >= 300 || !parsed.OK { + msg := strings.TrimSpace(parsed.Error) + if msg == "" { + msg = fmt.Sprintf("AI server VideoMAE HTTP %d", statusCode) + } + return parsed, fmt.Errorf("%s", msg) + } + + return parsed, nil + }() if err != nil { return nil, err } - req.Header.Set("Content-Type", "application/json") - addAIServerAuth(req) - - client := &http.Client{ - Timeout: 180 * time.Second, - } - - res, err := client.Do(req) - if err != nil { - if ctxErr := ctx.Err(); ctxErr != nil { - return nil, ctxErr - } - return nil, err - } - - rawBody, readErr := io.ReadAll(res.Body) - _ = res.Body.Close() - if readErr != nil { - if ctxErr := ctx.Err(); ctxErr != nil { - return nil, ctxErr - } - return nil, readErr - } - - var parsed analyzeVideoMAEClipPredictResp - if err := json.Unmarshal(rawBody, &parsed); err != nil { - if ctxErr := ctx.Err(); ctxErr != nil { - return nil, ctxErr - } - return nil, fmt.Errorf("AI server VideoMAE JSON ungueltig: HTTP %d: %s", res.StatusCode, strings.TrimSpace(string(rawBody))) - } - - if res.StatusCode < 200 || res.StatusCode >= 300 || !parsed.OK { - msg := strings.TrimSpace(parsed.Error) - if msg == "" { - msg = fmt.Sprintf("AI server VideoMAE HTTP %d", res.StatusCode) - } - return nil, fmt.Errorf("%s", msg) - } - if !parsed.Available { if onProgress != nil { onProgress(len(clips), len(clips)) @@ -227,6 +266,14 @@ func applyVideoMAEPositionClipsForAnalyze( if len(clips) == 0 { return highlightHits, positionEvidence } + if len(clips) > analyzeVideoMAEMaxClips { + appLogf( + "VideoMAE Clip-Analyse begrenzt: clips=%d max=%d", + len(clips), + analyzeVideoMAEMaxClips, + ) + clips = limitAnalyzeVideoMAEClips(clips, analyzeVideoMAEMaxClips) + } if onProgress != nil { onProgress(0, len(clips)) diff --git a/backend/assets_generate.go b/backend/assets_generate.go index aeec87d..3887537 100644 --- a/backend/assets_generate.go +++ b/backend/assets_generate.go @@ -196,7 +196,10 @@ func ensureAnalyzeAllGoalsForVideoCtxForce( AnalyzedAtUnix: time.Now().Unix(), } - if err := writeVideoAIForFile(actx, videoPath, meta.sourceURL, ai); err != nil { + persistCtx, persistCancel := context.WithTimeout(ctx, 90*time.Second) + defer persistCancel() + + if err := writeVideoAIForFile(persistCtx, videoPath, meta.sourceURL, ai); err != nil { publishAnalysisError(analyzeStartedAtMs, analyzeFile, "Speichern fehlgeschlagen", err) return false, err } @@ -210,7 +213,7 @@ func ensureAnalyzeAllGoalsForVideoCtxForce( // Direkt nach erfolgreicher Analyse löschen. // Wichtig: hier rating übergeben, nicht nil. - autoDeleteLowRatedDownloadAfterAnalysis(actx, videoPath, rating) + autoDeleteLowRatedDownloadAfterAnalysis(persistCtx, videoPath, rating) publishAnalyzePersistProgress(analyzeStartedAtMs, analyzeFile, 1, "") publishAnalysisFinished(analyzeStartedAtMs, analyzeProgressTotal, analyzeFile, "Analyse abgeschlossen") @@ -928,7 +931,12 @@ func ensureAnalyzeForVideoCtx( } } - return hasAIAnalysisForOutputGoal(videoPath, goal), nil + ready := hasAIAnalysisForOutputGoal(videoPath, goal) + if analyzed && !ready { + return false, appErrorf("analyse-meta konnte nach Analyse nicht verifiziert werden: %s", id) + } + + return ready, nil } func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string, goal string) error { @@ -1611,13 +1619,17 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string ai := &aiAnalysisMeta{ Goal: "highlights", Mode: "video", + Completed: true, Hits: hits, Segments: segments, Rating: rating, AnalyzedAtUnix: time.Now().Unix(), } - if werr := writeVideoAIForFile(ctx, videoPath, sourceURL, ai); werr != nil { + persistCtx, persistCancel := context.WithTimeout(ctx, 90*time.Second) + defer persistCancel() + + if werr := writeVideoAIForFile(persistCtx, videoPath, sourceURL, ai); werr != nil { publishAnalysisError(analyzeStartedAtMs2, analyzeFile2, "Speichern fehlgeschlagen", werr) return out, nil } diff --git a/backend/dist/nsfwapp-linux-amd64 b/backend/dist/nsfwapp-linux-amd64 deleted file mode 100644 index e1a3a1b..0000000 Binary files a/backend/dist/nsfwapp-linux-amd64 and /dev/null differ diff --git a/backend/dist/nsfwapp.exe b/backend/dist/nsfwapp.exe index ab9b4f0..1434c79 100644 Binary files a/backend/dist/nsfwapp.exe and b/backend/dist/nsfwapp.exe differ diff --git a/backend/tasks_assets.go b/backend/tasks_assets.go index 5525592..d346b2e 100644 --- a/backend/tasks_assets.go +++ b/backend/tasks_assets.go @@ -680,7 +680,7 @@ func runGenerateMissingAssetsJob(jobID string, ctx context.Context) { return false, false } - if errors.Is(stepErr, context.Canceled) || errors.Is(stepErr, context.DeadlineExceeded) { + if errors.Is(stepErr, context.Canceled) { // globaler Task wurde beendet if ctx.Err() != nil && !isAssetsJobSkipRequested(jobID, it.name) { finishFileControl() @@ -693,6 +693,18 @@ func runGenerateMissingAssetsJob(jobID string, ctx context.Context) { } // Datei wurde evtl. während des Verarbeitungsschritts gelöscht/verschoben + if errors.Is(stepErr, context.DeadlineExceeded) { + if ctx.Err() != nil { + finishFileControl() + finishWithErr(context.Canceled) + return false, true + } + if isAssetsJobSkipRequested(jobID, it.name) || !fileExistsNonEmpty(it.path) { + return true, false + } + return false, false + } + if isAssetsJobSkipRequested(jobID, it.name) || !fileExistsNonEmpty(it.path) { return true, false } @@ -939,10 +951,12 @@ func runGenerateMissingAssetsJob(jobID string, ctx context.Context) { } if aerr != nil { + appLogln("❌ assets task analyse fehlgeschlagen für", it.path+":", aerr) publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen") } else if assetsTaskTruthForVideo(id, it.path).AnalyzeReady { publishAssetsTaskPhase(it.name, "enrich", "analyze", "done", "Analyse") } else { + appLogln("❌ assets task analyse fehlt nach Lauf:", it.path) publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen") } } diff --git a/backend/videomae_test.go b/backend/videomae_test.go index 4ce9312..22119c4 100644 --- a/backend/videomae_test.go +++ b/backend/videomae_test.go @@ -53,6 +53,26 @@ func TestBuildAnalyzeVideoMAEClipsUsesWindowAndStride(t *testing.T) { } } +func TestLimitAnalyzeVideoMAEClipsKeepsEvenCoverage(t *testing.T) { + clips := make([]analyzeVideoMAEClipReqItem, 10) + for i := range clips { + clips[i] = analyzeVideoMAEClipReqItem{Time: float64(i)} + } + + limited := limitAnalyzeVideoMAEClips(clips, 4) + if len(limited) != 4 { + t.Fatalf("clip count = %d, want 4", len(limited)) + } + + got := []float64{limited[0].Time, limited[1].Time, limited[2].Time, limited[3].Time} + want := []float64{0, 3, 6, 9} + for i := range want { + if got[i] != want[i] { + t.Fatalf("clip times = %v, want %v", got, want) + } + } +} + func TestTrainingResolveVideoMAEModelUsesConfigDir(t *testing.T) { root := t.TempDir() modelDir := filepath.Join(root, "videomae", "model") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2e8f81e..d168a91 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3189,7 +3189,7 @@ export default function App() { new CustomEvent('finished-downloads:postwork', { detail: { file, - assetId: file.replace(/\.[^.]+$/, ''), + assetId: stripHotPrefix(file.replace(/\.[^.]+$/, '')), queue: 'enrich', phase: 'analyze', state,