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(),
}
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")

View File

@ -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))

View File

@ -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
}

Binary file not shown.

Binary file not shown.

View File

@ -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")
}
}

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

View File

@ -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,