This commit is contained in:
Linrador 2026-07-13 15:01:58 +02:00
parent d538a664cf
commit caf3e8b56b
8 changed files with 151 additions and 55 deletions

View File

@ -1049,7 +1049,10 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) {
AnalyzedAtUnix: time.Now().Unix(), 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) logAnalyzeError("write-meta-ai", file, outPath, err)
publishAnalysisError(analyzeStartedAtMs, file, "Speichern fehlgeschlagen", err) publishAnalysisError(analyzeStartedAtMs, file, "Speichern fehlgeschlagen", err)
@ -1065,7 +1068,7 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
autoDeleteLowRatedDownloadAfterAnalysis(ctx, outPath, rating) autoDeleteLowRatedDownloadAfterAnalysis(persistCtx, outPath, rating)
publishAnalyzePersistProgress(analyzeStartedAtMs, file, 1, "") publishAnalyzePersistProgress(analyzeStartedAtMs, file, 1, "")
publishAnalysisFinished(analyzeStartedAtMs, analyzeProgressTotal, file, "Analyse abgeschlossen") publishAnalysisFinished(analyzeStartedAtMs, analyzeProgressTotal, file, "Analyse abgeschlossen")

View File

@ -17,7 +17,9 @@ const (
analyzeVideoMAEClipWindowSeconds = 4.0 analyzeVideoMAEClipWindowSeconds = 4.0
analyzeVideoMAEClipStrideSeconds = 2.0 analyzeVideoMAEClipStrideSeconds = 2.0
analyzeVideoMAEMinScore = 0.34 analyzeVideoMAEMinScore = 0.34
analyzeVideoMAERequestBatchSize = 48 analyzeVideoMAERequestBatchSize = 8
analyzeVideoMAEMaxClips = 180
analyzeVideoMAERequestTimeout = 75 * time.Second
) )
type analyzeVideoMAEClipReqItem struct { type analyzeVideoMAEClipReqItem struct {
@ -112,6 +114,31 @@ func buildAnalyzeVideoMAEClips(
return clips 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( func predictVideoMAEPositionClipsForAnalyze(
ctx context.Context, ctx context.Context,
clips []analyzeVideoMAEClipReqItem, clips []analyzeVideoMAEClipReqItem,
@ -145,54 +172,66 @@ func predictVideoMAEPositionClipsForAnalyze(
return nil, err return nil, err
} }
parsed, err := func() (analyzeVideoMAEClipPredictResp, error) {
var parsed analyzeVideoMAEClipPredictResp
reqCtx, cancel := context.WithTimeout(ctx, analyzeVideoMAERequestTimeout)
defer cancel()
req, err := http.NewRequestWithContext( req, err := http.NewRequestWithContext(
ctx, reqCtx,
http.MethodPost, http.MethodPost,
analyzeAIServerURL()+"/predict-position-clips", analyzeAIServerURL()+"/predict-position-clips",
bytes.NewReader(body), bytes.NewReader(body),
) )
if err != nil { if err != nil {
return nil, err return parsed, err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
addAIServerAuth(req) addAIServerAuth(req)
client := &http.Client{ client := &http.Client{
Timeout: 180 * time.Second, Timeout: analyzeVideoMAERequestTimeout + 5*time.Second,
} }
res, err := client.Do(req) res, err := client.Do(req)
if err != nil { if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil { if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr return parsed, ctxErr
} }
return nil, err return parsed, err
} }
rawBody, readErr := io.ReadAll(res.Body) rawBody, readErr := io.ReadAll(res.Body)
statusCode := res.StatusCode
_ = res.Body.Close() _ = res.Body.Close()
if readErr != nil { if readErr != nil {
if ctxErr := ctx.Err(); ctxErr != nil { if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr return parsed, ctxErr
} }
return nil, readErr return parsed, readErr
} }
var parsed analyzeVideoMAEClipPredictResp
if err := json.Unmarshal(rawBody, &parsed); err != nil { if err := json.Unmarshal(rawBody, &parsed); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil { if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr return parsed, ctxErr
} }
return nil, fmt.Errorf("AI server VideoMAE JSON ungueltig: HTTP %d: %s", res.StatusCode, strings.TrimSpace(string(rawBody))) return parsed, fmt.Errorf("AI server VideoMAE JSON ungueltig: HTTP %d: %s", statusCode, strings.TrimSpace(string(rawBody)))
} }
if res.StatusCode < 200 || res.StatusCode >= 300 || !parsed.OK { if statusCode < 200 || statusCode >= 300 || !parsed.OK {
msg := strings.TrimSpace(parsed.Error) msg := strings.TrimSpace(parsed.Error)
if msg == "" { if msg == "" {
msg = fmt.Sprintf("AI server VideoMAE HTTP %d", res.StatusCode) msg = fmt.Sprintf("AI server VideoMAE HTTP %d", statusCode)
} }
return nil, fmt.Errorf("%s", msg) return parsed, fmt.Errorf("%s", msg)
}
return parsed, nil
}()
if err != nil {
return nil, err
} }
if !parsed.Available { if !parsed.Available {
@ -227,6 +266,14 @@ func applyVideoMAEPositionClipsForAnalyze(
if len(clips) == 0 { if len(clips) == 0 {
return highlightHits, positionEvidence 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 { if onProgress != nil {
onProgress(0, len(clips)) onProgress(0, len(clips))

View File

@ -196,7 +196,10 @@ func ensureAnalyzeAllGoalsForVideoCtxForce(
AnalyzedAtUnix: time.Now().Unix(), 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) publishAnalysisError(analyzeStartedAtMs, analyzeFile, "Speichern fehlgeschlagen", err)
return false, err return false, err
} }
@ -210,7 +213,7 @@ func ensureAnalyzeAllGoalsForVideoCtxForce(
// Direkt nach erfolgreicher Analyse löschen. // Direkt nach erfolgreicher Analyse löschen.
// Wichtig: hier rating übergeben, nicht nil. // Wichtig: hier rating übergeben, nicht nil.
autoDeleteLowRatedDownloadAfterAnalysis(actx, videoPath, rating) autoDeleteLowRatedDownloadAfterAnalysis(persistCtx, videoPath, rating)
publishAnalyzePersistProgress(analyzeStartedAtMs, analyzeFile, 1, "") publishAnalyzePersistProgress(analyzeStartedAtMs, analyzeFile, 1, "")
publishAnalysisFinished(analyzeStartedAtMs, analyzeProgressTotal, analyzeFile, "Analyse abgeschlossen") 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 { 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{ ai := &aiAnalysisMeta{
Goal: "highlights", Goal: "highlights",
Mode: "video", Mode: "video",
Completed: true,
Hits: hits, Hits: hits,
Segments: segments, Segments: segments,
Rating: rating, Rating: rating,
AnalyzedAtUnix: time.Now().Unix(), 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) publishAnalysisError(analyzeStartedAtMs2, analyzeFile2, "Speichern fehlgeschlagen", werr)
return out, nil return out, nil
} }

Binary file not shown.

Binary file not shown.

View File

@ -680,7 +680,7 @@ func runGenerateMissingAssetsJob(jobID string, ctx context.Context) {
return false, false return false, false
} }
if errors.Is(stepErr, context.Canceled) || errors.Is(stepErr, context.DeadlineExceeded) { if errors.Is(stepErr, context.Canceled) {
// globaler Task wurde beendet // globaler Task wurde beendet
if ctx.Err() != nil && !isAssetsJobSkipRequested(jobID, it.name) { if ctx.Err() != nil && !isAssetsJobSkipRequested(jobID, it.name) {
finishFileControl() finishFileControl()
@ -693,6 +693,18 @@ func runGenerateMissingAssetsJob(jobID string, ctx context.Context) {
} }
// Datei wurde evtl. während des Verarbeitungsschritts gelöscht/verschoben // 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) { if isAssetsJobSkipRequested(jobID, it.name) || !fileExistsNonEmpty(it.path) {
return true, false return true, false
} }
@ -939,10 +951,12 @@ func runGenerateMissingAssetsJob(jobID string, ctx context.Context) {
} }
if aerr != nil { if aerr != nil {
appLogln("❌ assets task analyse fehlgeschlagen für", it.path+":", aerr)
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen") publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen")
} else if assetsTaskTruthForVideo(id, it.path).AnalyzeReady { } else if assetsTaskTruthForVideo(id, it.path).AnalyzeReady {
publishAssetsTaskPhase(it.name, "enrich", "analyze", "done", "Analyse") publishAssetsTaskPhase(it.name, "enrich", "analyze", "done", "Analyse")
} else { } else {
appLogln("❌ assets task analyse fehlt nach Lauf:", it.path)
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen") publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen")
} }
} }

View File

@ -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) { func TestTrainingResolveVideoMAEModelUsesConfigDir(t *testing.T) {
root := t.TempDir() root := t.TempDir()
modelDir := filepath.Join(root, "videomae", "model") modelDir := filepath.Join(root, "videomae", "model")

View File

@ -3189,7 +3189,7 @@ export default function App() {
new CustomEvent('finished-downloads:postwork', { new CustomEvent('finished-downloads:postwork', {
detail: { detail: {
file, file,
assetId: file.replace(/\.[^.]+$/, ''), assetId: stripHotPrefix(file.replace(/\.[^.]+$/, '')),
queue: 'enrich', queue: 'enrich',
phase: 'analyze', phase: 'analyze',
state, state,