diff --git a/backend/training.go b/backend/training.go index f1bbf04..bedb69a 100644 --- a/backend/training.go +++ b/backend/training.go @@ -1842,6 +1842,9 @@ func trainingCreateNextSample() (*TrainingSample, error) { func trainingCreateUncertainNextSampleWithProgress(startedAtMs int64) (*TrainingSample, error) { const attempts = 8 + const stepsPerAttempt = 4 + + totalSteps := attempts*stepsPerAttempt + 1 root, err := trainingRootDir() if err != nil { @@ -1853,22 +1856,47 @@ func trainingCreateUncertainNextSampleWithProgress(startedAtMs int64) (*Training errs := []string{} for i := 0; i < attempts; i++ { - publishAnalysisStep( + attempt := i + 1 + stepStart := i*stepsPerAttempt + 1 + + prefix := fmt.Sprintf("Kandidat %d/%d: ", attempt, attempts) + + sample, err := trainingCreateNextSampleWithProgressRange( startedAtMs, - i+1, - attempts+1, - "", - fmt.Sprintf("Unsicherer Kandidat %d/%d wird analysiert…", i+1, attempts), + stepStart, + totalSteps, + prefix, ) - sample, err := trainingCreateNextSample() if err != nil { errs = append(errs, err.Error()) + + publishAnalysisStep( + startedAtMs, + stepStart+stepsPerAttempt-1, + totalSteps, + "", + fmt.Sprintf("Kandidat %d/%d fehlgeschlagen…", attempt, attempts), + ) + continue } score := trainingPredictionUncertaintyScore(sample.Prediction) + publishAnalysisStep( + startedAtMs, + stepStart+stepsPerAttempt-1, + totalSteps, + sample.SourceFile, + fmt.Sprintf( + "Kandidat %d/%d bewertet · Unsicherheit %.0f%%", + attempt, + attempts, + score*100, + ), + ) + if score > bestScore { if best != nil { trainingDeleteSampleFiles(root, best.SampleID) @@ -1891,8 +1919,8 @@ func trainingCreateUncertainNextSampleWithProgress(startedAtMs int64) (*Training publishAnalysisStep( startedAtMs, - attempts+1, - attempts+1, + totalSteps, + totalSteps, best.SourceFile, fmt.Sprintf("Unsicherer Kandidat gewählt · Score %.0f%%", bestScore*100), ) @@ -1988,7 +2016,31 @@ func trainingPredictionUncertaintyScore(pred TrainingPrediction) float64 { } func trainingCreateNextSampleWithProgress(startedAtMs int64) (*TrainingSample, error) { - publishAnalysisStep(startedAtMs, 1, 4, "", "Video wird ausgewählt…") + return trainingCreateNextSampleWithProgressRange( + startedAtMs, + 1, + 4, + "", + ) +} + +func trainingCreateNextSampleWithProgressRange( + startedAtMs int64, + stepStart int, + stepTotal int, + prefix string, +) (*TrainingSample, error) { + publishStep := func(localStep int, sourceFile string, message string) { + publishAnalysisStep( + startedAtMs, + stepStart+localStep-1, + stepTotal, + sourceFile, + prefix+message, + ) + } + + publishStep(1, "", "Video wird ausgewählt…") settings := getSettings() @@ -2002,7 +2054,9 @@ func trainingCreateNextSampleWithProgress(startedAtMs int64) (*TrainingSample, e return nil, err } - publishAnalysisStep(startedAtMs, 2, 4, filepath.Base(videoPath), "Bild wird extrahiert…") + sourceFile := filepath.Base(videoPath) + + publishStep(2, sourceFile, "Bild wird extrahiert…") duration := trainingProbeDurationSeconds(videoPath) second := trainingRandomSecond(duration) @@ -2019,6 +2073,7 @@ func trainingCreateNextSampleWithProgress(startedAtMs int64) (*TrainingSample, e if err := os.MkdirAll(filepath.Join(root, "frames"), 0755); err != nil { return nil, err } + if err := os.MkdirAll(filepath.Join(root, "samples"), 0755); err != nil { return nil, err } @@ -2027,6 +2082,8 @@ func trainingCreateNextSampleWithProgress(startedAtMs int64) (*TrainingSample, e framePath := filepath.Join(root, "frames", id+".jpg") if err := trainingExtractFrame(videoPath, framePath, second); err != nil { + publishStep(2, sourceFile, "Bild wird erneut bei Sekunde 0 extrahiert…") + second = 0 id = trainingMakeSampleID(videoPath, second) framePath = filepath.Join(root, "frames", id+".jpg") @@ -2036,7 +2093,7 @@ func trainingCreateNextSampleWithProgress(startedAtMs int64) (*TrainingSample, e } } - publishAnalysisStep(startedAtMs, 3, 4, filepath.Base(videoPath), "Bild wird analysiert…") + publishStep(3, sourceFile, "Bild wird analysiert…") prediction := trainingPredictFrame(framePath) @@ -2048,7 +2105,7 @@ func trainingCreateNextSampleWithProgress(startedAtMs int64) (*TrainingSample, e sample := &TrainingSample{ SampleID: id, FrameURL: "/api/training/frame?id=" + id, - SourceFile: filepath.Base(videoPath), + SourceFile: sourceFile, SourcePath: videoPath, SourceSizeBytes: sourceSizeBytes, Second: second, @@ -2056,7 +2113,7 @@ func trainingCreateNextSampleWithProgress(startedAtMs int64) (*TrainingSample, e Prediction: prediction, } - publishAnalysisStep(startedAtMs, 4, 4, filepath.Base(videoPath), "Analyse-Ergebnis wird gespeichert…") + publishStep(4, sourceFile, "Analyse-Ergebnis wird gespeichert…") if err := trainingWriteSample(root, sample); err != nil { return nil, err diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b0b96c3..47a1cc4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2741,7 +2741,6 @@ export default function App() { if (!authed) return let es: EventSource | null = null - let autostartEs: EventSource | null = null let fallbackTimer: number | null = null const stopFallbackPoll = () => { @@ -2844,6 +2843,12 @@ export default function App() { if (msg?.type !== 'analysis_progress') return + window.dispatchEvent( + new CustomEvent('app:sse:analysis', { + detail: msg, + }) + ) + const file = baseName(String(msg?.file ?? '').trim()) if (!file) return @@ -2929,6 +2934,12 @@ export default function App() { if (data?.type !== 'training_status') return setTrainingTabRunning(Boolean(data?.training?.running)) + + window.dispatchEvent( + new CustomEvent('app:sse:training', { + detail: data, + }) + ) } catch { // ignore } @@ -2944,20 +2955,6 @@ export default function App() { eventSourceRef.current = es modelEventNamesRef.current = new Set() - autostartEs = new EventSource('/api/autostart/state/stream') - - autostartEs.addEventListener('autostart', (ev) => { - try { - applyAutostartState(JSON.parse(String((ev as MessageEvent).data ?? 'null'))) - } catch { - // ignore - } - }) - - autostartEs.onerror = () => { - // Falls der separate Stream ausfällt, wenigstens beim Fokus/Visibility wieder korrekt ziehen. - } - es.onopen = () => { stopFallbackPoll() void loadTaskStateOnce() @@ -3010,10 +3007,6 @@ export default function App() { es.close() } - if (autostartEs) { - autostartEs.close() - } - eventSourceRef.current = null modelEventNamesRef.current = new Set() } diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx index e6b3300..17def61 100644 --- a/frontend/src/components/ui/TrainingTab.tsx +++ b/frontend/src/components/ui/TrainingTab.tsx @@ -2326,10 +2326,10 @@ export default function TrainingTab(props: { const url = `/api/training/next${params.toString() ? `?${params.toString()}` : ''}` - setAnalysisProgress(25) + setAnalysisProgress(uncertainMode ? 5 : 25) setAnalysisStep( uncertainMode - ? 'Mehrere Kandidaten werden bewertet…' + ? 'Mehrere Kandidaten werden vorbereitet…' : 'Bild wird vorbereitet…' ) @@ -2441,11 +2441,9 @@ export default function TrainingTab(props: { }, []) useEffect(() => { - const es = new EventSource('/api/events/stream') - - const onTraining = (ev: MessageEvent) => { + const onTraining = (event: Event) => { try { - const data = JSON.parse(String(ev.data ?? 'null')) + const data = (event as CustomEvent).detail if (data?.type !== 'training_status') return @@ -2457,18 +2455,69 @@ export default function TrainingTab(props: { } } - es.addEventListener('training', onTraining as EventListener) - - es.onerror = () => { - // Optional: Polling-Fallback bleibt separat bestehen. - } + window.addEventListener('app:sse:training', onTraining as EventListener) return () => { - es.removeEventListener('training', onTraining as EventListener) - es.close() + window.removeEventListener('app:sse:training', onTraining as EventListener) } }, [applyTrainingStatus]) + useEffect(() => { + const onAnalysis = (event: Event) => { + try { + const data = (event as CustomEvent).detail + + const message = String( + data?.message || + data?.step || + data?.title || + '' + ).trim() + + const current = Number(data?.current ?? data?.stepIndex ?? data?.index) + const total = Number(data?.total ?? data?.steps ?? data?.stepTotal) + + const rawProgress = Number( + data?.progress ?? + data?.percent ?? + data?.analysis?.progress + ) + + let nextProgress: number | null = null + + if ( + Number.isFinite(current) && + Number.isFinite(total) && + total > 0 + ) { + nextProgress = (current / total) * 100 + } else if (Number.isFinite(rawProgress)) { + nextProgress = rawProgress <= 1 + ? rawProgress * 100 + : rawProgress + } + + if (message) { + setAnalysisStep(message) + } + + if (nextProgress !== null) { + setAnalysisProgress((prev) => + Math.max(prev, clampPercent(nextProgress)) + ) + } + } catch { + // ignore + } + } + + window.addEventListener('app:sse:analysis', onAnalysis as EventListener) + + return () => { + window.removeEventListener('app:sse:analysis', onAnalysis as EventListener) + } + }, []) + useEffect(() => { const draggingBox = Boolean(drawingBox || boxInteraction)