From a836bc5c7e74a3451f05a40c6f535cb67162825d Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Tue, 12 May 2026 19:19:18 +0200 Subject: [PATCH] bugfixes --- backend/.env | 2 +- backend/__pycache__/ai_server.cpython-314.pyc | Bin 19096 -> 20461 bytes backend/recorder_settings.json | 2 +- backend/routes.go | 1 + backend/training.go | 264 ++++++- frontend/src/components/ui/TrainingTab.tsx | 677 ++++++++++++++---- 6 files changed, 785 insertions(+), 161 deletions(-) diff --git a/backend/.env b/backend/.env index e33764f..c1ed77b 100644 --- a/backend/.env +++ b/backend/.env @@ -1,2 +1,2 @@ -HTTPS_ENABLED=1 +HTTPS_ENABLED=0 AUTH_RP_ORIGINS=https://l14pbbk95100006.tegdssd.de:9999,https://l14pbbk95100006.tegdssd.de:5173,https://localhost:9999,https://127.0.0.1:9999,https://10.0.1.25:9999,http://localhost:5173,http://127.0.0.1:5173,http://10.0.1.25:5173 \ No newline at end of file diff --git a/backend/__pycache__/ai_server.cpython-314.pyc b/backend/__pycache__/ai_server.cpython-314.pyc index 6265ae864dac9ed41f6fc259f04cb4107e6d9101..7369f49f2612e98961ce6955124f79c25b7d9249 100644 GIT binary patch delta 6014 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 0 { progress = float64(current) / float64(total) } - b, err := json.Marshal(map[string]any{ + payload := map[string]any{ "type": "analysis_progress", "scope": "training", "requestId": requestID, @@ -716,7 +737,13 @@ func trainingPublishAnalysisStep( "sourceFile": strings.TrimSpace(sourceFile), "message": strings.TrimSpace(message), "ts": time.Now().UnixMilli(), - }) + } + + if strings.TrimSpace(previewURL) != "" { + payload["previewUrl"] = strings.TrimSpace(previewURL) + } + + b, err := json.Marshal(payload) if err != nil { return } @@ -729,10 +756,26 @@ func trainingPublishAnalysisStarted( total int, sourceFile string, message string, +) int64 { + return trainingPublishAnalysisStartedWithPreview( + requestID, + total, + sourceFile, + "", + message, + ) +} + +func trainingPublishAnalysisStartedWithPreview( + requestID string, + total int, + sourceFile string, + previewURL string, + message string, ) int64 { startedAtMs := time.Now().UnixMilli() - b, err := json.Marshal(map[string]any{ + payload := map[string]any{ "type": "analysis_progress", "scope": "training", "requestId": requestID, @@ -745,7 +788,13 @@ func trainingPublishAnalysisStarted( "sourceFile": strings.TrimSpace(sourceFile), "message": strings.TrimSpace(message), "ts": time.Now().UnixMilli(), - }) + } + + if strings.TrimSpace(previewURL) != "" { + payload["previewUrl"] = strings.TrimSpace(previewURL) + } + + b, err := json.Marshal(payload) if err == nil { publishSSE("analysisProgress", b) } @@ -1068,6 +1117,187 @@ func trainingSupportedImportVideo(path string) bool { } } +func trainingGeneratedAssetIDCandidatesForVideo(videoPath string) []string { + videoPath = strings.TrimSpace(videoPath) + if videoPath == "" { + return nil + } + + out := []string{} + seen := map[string]bool{} + + add := func(id string) { + id = stripHotPrefix(strings.TrimSpace(id)) + + if id == "" || + id == "." || + id == ".." || + strings.Contains(id, "/") || + strings.Contains(id, "\\") { + return + } + + if seen[id] { + return + } + + seen[id] = true + out = append(out, id) + } + + // Fall 1: + // Video liegt selbst unter /generated//... + // + // Beispiel: + // C:\app\generated\abc123\video.mp4 + // => abc123 + slashPath := filepath.ToSlash(filepath.Clean(videoPath)) + parts := strings.Split(slashPath, "/") + + for i := 0; i+1 < len(parts); i++ { + if strings.EqualFold(parts[i], "generated") { + add(parts[i+1]) + } + } + + // Fall 2: + // Video liegt z.B. in done/keep, aber generated//preview.jpg + // basiert auf dem Dateinamen ohne Extension. + // + // Beispiel: + // done/keep/model/abc123.mp4 + // => generated/abc123/preview.jpg + base := filepath.Base(videoPath) + stem := strings.TrimSuffix(base, filepath.Ext(base)) + add(stem) + + return out +} + +func trainingGeneratedPreviewPathForAssetID(assetID string) (string, bool) { + assetID = stripHotPrefix(strings.TrimSpace(assetID)) + + if assetID == "" || + assetID == "." || + assetID == ".." || + strings.Contains(assetID, "/") || + strings.Contains(assetID, "\\") { + return "", false + } + + previewPath, err := resolvePathRelativeToApp( + filepath.Join("generated", assetID, "preview.jpg"), + ) + if err != nil { + return "", false + } + + if !fileExistsNonEmpty(previewPath) { + return "", false + } + + return previewPath, true +} + +func trainingPreviewPathForVideo(videoPath string) (string, bool) { + for _, assetID := range trainingGeneratedAssetIDCandidatesForVideo(videoPath) { + if previewPath, ok := trainingGeneratedPreviewPathForAssetID(assetID); ok { + return previewPath, true + } + } + + return "", false +} + +func trainingPreviewURLForVideoPath(videoPath string) string { + videoPath = strings.TrimSpace(videoPath) + if videoPath == "" { + return "" + } + + if !trainingSupportedImportVideo(videoPath) { + return "" + } + + return "/api/training/video-preview?output=" + url.QueryEscape(videoPath) +} + +func trainingPreviewAssetIDForVideo(videoPath string) string { + candidates := trainingGeneratedAssetIDCandidatesForVideo(videoPath) + + for _, assetID := range candidates { + if _, ok := trainingGeneratedPreviewPathForAssetID(assetID); ok { + return assetID + } + } + + for _, assetID := range candidates { + if _, err := findFinishedFileByID(assetID); err == nil { + return assetID + } + } + + if len(candidates) > 0 { + return candidates[0] + } + + return "" +} + +func trainingVideoPreviewHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + outPath := strings.TrimSpace(r.URL.Query().Get("output")) + if outPath == "" { + trainingWriteError(w, http.StatusBadRequest, "output missing") + return + } + + if !trainingSupportedImportVideo(outPath) { + trainingWriteError(w, http.StatusBadRequest, "unsupported video type") + return + } + + st, err := os.Stat(outPath) + if err != nil || st == nil || st.IsDir() || st.Size() <= 0 { + trainingWriteError(w, http.StatusNotFound, "video not found") + return + } + + // Fast path: Wenn /generated//preview.jpg schon existiert, direkt ausliefern. + if previewPath, ok := trainingPreviewPathForVideo(outPath); ok { + w.Header().Set("Cache-Control", "no-store") + servePreviewJPGFile(w, r, previewPath) + return + } + + assetID := trainingPreviewAssetIDForVideo(outPath) + if assetID == "" { + trainingWriteError(w, http.StatusNotFound, "preview asset id not found") + return + } + + // Wichtig: + // Nicht file=preview.jpg setzen. + // Ohne file=... darf recordPreviewWithBase die Preview bei Bedarf erzeugen. + r2 := r.Clone(r.Context()) + u := *r.URL + q := u.Query() + + q.Set("id", assetID) + q.Del("output") + q.Del("file") + q.Del("fallbackOnly") + + u.RawQuery = q.Encode() + r2.URL = &u + + recordPreviewWithBase(w, r2, "/api/training/video-preview") +} + func trainingFrameSecondsForVideo(duration float64, count int) []float64 { count = trainingCleanImportVideoCount(count) @@ -1176,6 +1406,7 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) { seconds := trainingFrameSecondsForVideo(duration, req.Count) sourceFile := filepath.Base(outPath) + previewURL := trainingPreviewURLForVideoPath(outPath) requestID := strings.TrimSpace(req.AnalysisRequestID) if requestID == "" { @@ -1187,10 +1418,11 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) { totalSteps = 1 } - startedAtMs := trainingPublishAnalysisStarted( + startedAtMs := trainingPublishAnalysisStartedWithPreview( requestID, totalSteps, sourceFile, + previewURL, "Video wird ins Training übernommen…", ) @@ -1205,12 +1437,13 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) { for i, second := range seconds { stepBase := i * 3 - trainingPublishAnalysisStep( + trainingPublishAnalysisStepWithPreview( requestID, startedAtMs, stepBase+1, totalSteps, sourceFile, + previewURL, fmt.Sprintf("Frame %d/%d wird extrahiert…", i+1, len(seconds)), ) @@ -1222,12 +1455,13 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) { continue } - trainingPublishAnalysisStep( + trainingPublishAnalysisStepWithPreview( requestID, startedAtMs, stepBase+2, totalSteps, sourceFile, + previewURL, fmt.Sprintf("Frame %d/%d wird analysiert…", i+1, len(seconds)), ) @@ -1244,12 +1478,13 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) { Prediction: prediction, } - trainingPublishAnalysisStep( + trainingPublishAnalysisStepWithPreview( requestID, startedAtMs, stepBase+3, totalSteps, sourceFile, + previewURL, fmt.Sprintf("Frame %d/%d wird gespeichert…", i+1, len(seconds)), ) @@ -3191,6 +3426,19 @@ func trainingCreateNextSampleWithProgressRange( } sourceFile := filepath.Base(videoPath) + previewURL := trainingPreviewURLForVideoPath(videoPath) + + publishStep = func(localStep int, sourceFile string, message string) { + trainingPublishAnalysisStepWithPreview( + requestID, + startedAtMs, + stepStart+localStep-1, + stepTotal, + sourceFile, + previewURL, + prefix+message, + ) + } publishStep(2, sourceFile, "Bild wird extrahiert…") diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx index 91687e4..3b2cc17 100644 --- a/frontend/src/components/ui/TrainingTab.tsx +++ b/frontend/src/components/ui/TrainingTab.tsx @@ -143,6 +143,13 @@ type MagnifierState = { imageY: number } +type PendingTrainingVideoImport = { + jobId?: string + output: string + sourceFile?: string + count?: number +} + type TrainingConfidence = { score: number level: 'none' | 'low' | 'mid' | 'high' @@ -751,69 +758,65 @@ function sortTrainingLabels(input: Partial | null | undefined): } } -function TrainingOverlay(props: { step: string; progress: number }) { - return ( -
-
- -
- - -
- Training läuft… -
- -
- {props.step || 'Bitte warten. Die Oberfläche ist währenddessen gesperrt.'} -
- -
-
-
- -
- {Math.round(props.progress)}% -
-
-
- ) -} - -function LoadingImageOverlay(props: { +function TrainingStageOverlay(props: { + mode: 'training' | 'analysis' text?: string progress?: number + backgroundUrl?: string }) { const progress = clampPercent(props.progress ?? 0) + const isTraining = props.mode === 'training' + const hasBackground = !isTraining && Boolean(props.backgroundUrl) + + const title = isTraining ? 'Training läuft…' : 'Analyse läuft…' + const fallbackText = isTraining + ? 'Bitte warten. Die Oberfläche ist währenddessen gesperrt.' + : 'Bild wird erstellt und analysiert. Bitte warten.' return ( -
-
+
+
+ {hasBackground ? ( + + ) : null} + +
+
- Analyse läuft… + {title}
- {props.text || 'Bild wird erstellt und analysiert. Bitte warten.'} + {props.text || fallbackText}
@@ -1977,6 +1980,9 @@ export default function TrainingTab(props: { const wasTrainingRunningRef = useRef(false) const shownTrainingCompletionRef = useRef(null) + const [importedSampleQueue, setImportedSampleQueue] = useState([]) + const importedSampleQueueRef = useRef([]) + const [feedbackModalOpen, setFeedbackModalOpen] = useState(false) const [feedbackItems, setFeedbackItems] = useState([]) const [feedbackLoading, setFeedbackLoading] = useState(false) @@ -2001,6 +2007,15 @@ export default function TrainingTab(props: { const [frameImageLoaded, setFrameImageLoaded] = useState(false) const [imageExpanded, setImageExpanded] = useState(false) + const [frameNaturalSize, setFrameNaturalSize] = useState<{ + width: number + height: number + } | null>(null) + + const [loadingPreviewUrl, setLoadingPreviewUrl] = useState('') + const [loadingPreviewLoaded, setLoadingPreviewLoaded] = useState(false) + const [loadingPreviewFailed, setLoadingPreviewFailed] = useState(false) + const imageBoxRef = useRef(null) const frameImageRef = useRef(null) @@ -2016,13 +2031,13 @@ export default function TrainingTab(props: { } const loadFeedbackHistoryInitial = useCallback(async ( - options?: { + options: { query?: string filter?: FeedbackFilter - } + } = {} ) => { - const query = options?.query ?? feedbackSearchQuery - const filter = options?.filter ?? feedbackSearchFilter + const query = options.query ?? '' + const filter = options.filter ?? 'all' setFeedbackLoading(true) setFeedbackError(null) @@ -2059,7 +2074,7 @@ export default function TrainingTab(props: { } finally { setFeedbackLoading(false) } - }, [feedbackSearchFilter, feedbackSearchQuery]) + }, []) const loadMoreFeedbackHistory = useCallback(async () => { if (feedbackLoading || feedbackLoadingMore || !feedbackHasMore) return @@ -2204,6 +2219,9 @@ export default function TrainingTab(props: { const activeAnalysisRequestIdRef = useRef(null) const loadingRef = useRef(false) + + const videoImportStartedRef = useRef(false) + const videoImportInFlightKeyRef = useRef(null) const epochTimingRef = useRef<{ @@ -2236,6 +2254,7 @@ export default function TrainingTab(props: { const [activeBoxIndex, setActiveBoxIndex] = useState(null) const [imageReloadKey, setImageReloadKey] = useState(0) const [trainingSampleMode, setTrainingSampleMode] = useState('random') + const trainingSampleModeRef = useRef('random') const [expandedCorrectionSections, setExpandedCorrectionSections] = useState({ sexPosition: false, people: false, @@ -2318,6 +2337,10 @@ export default function TrainingTab(props: { const labelsRef = useRef(emptyLabels) + useEffect(() => { + importedSampleQueueRef.current = importedSampleQueue + }, [importedSampleQueue]) + useEffect(() => { if (!feedbackModalOpen) return @@ -2338,6 +2361,10 @@ export default function TrainingTab(props: { labelsRef.current = labels }, [labels]) + useEffect(() => { + trainingSampleModeRef.current = trainingSampleMode + }, [trainingSampleMode]) + const boxLabels = useMemo(() => { return uniqStrings([ ...labels.people, @@ -2411,8 +2438,39 @@ export default function TrainingTab(props: { const imageSrc = useMemo(() => { if (!sample?.frameUrl) return '' - return `${sample.frameUrl}&t=${encodeURIComponent(sample.sampleId)}&r=${imageReloadKey}` - }, [sample, imageReloadKey]) + const sep = sample.frameUrl.includes('?') ? '&' : '?' + + return `${sample.frameUrl}${sep}t=${encodeURIComponent(sample.sampleId)}&r=${imageReloadKey}` + }, [sample?.frameUrl, sample?.sampleId, imageReloadKey]) + + const loadingPreviewRawUrlRef = useRef('') + + const setLoadingPreviewCandidate = useCallback((url: string) => { + const clean = String(url || '').trim() + + if (!clean) { + loadingPreviewRawUrlRef.current = '' + setLoadingPreviewUrl('') + setLoadingPreviewLoaded(false) + setLoadingPreviewFailed(false) + return + } + + // Wichtig: + // Bei gleichem Preview nicht neu cache-busten und nicht loaded=false setzen. + // Sonst flackert das Overlay bei jedem Phasenwechsel. + if (loadingPreviewRawUrlRef.current === clean) { + return + } + + loadingPreviewRawUrlRef.current = clean + + const sep = clean.includes('?') ? '&' : '?' + + setLoadingPreviewUrl(`${clean}${sep}r=${Date.now()}`) + setLoadingPreviewLoaded(false) + setLoadingPreviewFailed(false) + }, []) useEffect(() => { if (!imageSrc) { @@ -2527,6 +2585,71 @@ export default function TrainingTab(props: { } }, []) + const loadTrainingSampleIntoTab = useCallback(( + nextSample: TrainingSample, + opts?: { manualCorrection?: boolean } + ) => { + const nextCorrection = predictionToCorrection(nextSample) + + setDrawingBox(null) + setBoxInteraction(null) + setTouchMagnifier(null) + setBoxLabel('') + setActiveBoxIndex(null) + setMobilePanel(trainingRunningRef.current ? 'training' : 'labels') + + window.requestAnimationFrame(() => { + mobileLabelsScrollRef.current?.scrollTo({ + top: 0, + behavior: 'smooth', + }) + }) + + setSample(nextSample) + setCorrection(nextCorrection) + setHasManualCorrection(Boolean(opts?.manualCorrection)) + + const initiallyExpandedSection: CorrectionSectionKey | null = + nextCorrection.sexPosition && nextCorrection.sexPosition !== 'unknown' + ? 'sexPosition' + : nextCorrection.peoplePresent.length > 0 + ? 'people' + : nextCorrection.bodyPartsPresent.length > 0 + ? 'bodyParts' + : nextCorrection.objectsPresent.length > 0 + ? 'objects' + : nextCorrection.clothingPresent.length > 0 + ? 'clothing' + : null + + setExpandedCorrectionSections( + initiallyExpandedSection + ? nextExpandedCorrectionSections(initiallyExpandedSection, true) + : { + sexPosition: false, + people: false, + bodyParts: false, + objects: false, + clothing: false, + } + ) + }, []) + + const loadNextImportedQueuedSample = useCallback(() => { + const [nextSample, ...rest] = importedSampleQueueRef.current + + if (!nextSample) { + return false + } + + importedSampleQueueRef.current = rest + setImportedSampleQueue(rest) + + loadTrainingSampleIntoTab(nextSample) + + return true + }, [loadTrainingSampleIntoTab]) + const loadNext = useCallback(async (opts?: { forceNew?: boolean refreshPrediction?: boolean @@ -2535,10 +2658,12 @@ export default function TrainingTab(props: { }) => { const requestId = makeRequestId() activeAnalysisRequestIdRef.current = requestId + const isCurrentRequest = () => activeAnalysisRequestIdRef.current === requestId - const mode = opts?.mode ?? trainingSampleMode + const mode = opts?.mode ?? trainingSampleModeRef.current const uncertainMode = mode === 'uncertain' && !opts?.refreshPrediction + setLoadingPreviewCandidate('') setLoading(true) setAnalysisProgress(8) setAnalysisStep( @@ -2581,56 +2706,23 @@ export default function TrainingTab(props: { throw new Error(data?.error || `HTTP ${res.status}`) } + if (!isCurrentRequest()) { + return + } + setAnalysisProgress(92) setAnalysisStep('Analyse-Ergebnis wird übernommen…') - const nextCorrection = predictionToCorrection(data) - - setDrawingBox(null) - setBoxInteraction(null) - setTouchMagnifier(null) - setBoxLabel('') - setActiveBoxIndex(null) - setMobilePanel(trainingRunningRef.current ? 'training' : 'labels') - - window.requestAnimationFrame(() => { - mobileLabelsScrollRef.current?.scrollTo({ - top: 0, - behavior: 'smooth', - }) - }) - - setSample(data) - setCorrection(nextCorrection) - setHasManualCorrection(false) - - const initiallyExpandedSection: CorrectionSectionKey | null = - nextCorrection.sexPosition && nextCorrection.sexPosition !== 'unknown' - ? 'sexPosition' - : nextCorrection.peoplePresent.length > 0 - ? 'people' - : nextCorrection.bodyPartsPresent.length > 0 - ? 'bodyParts' - : nextCorrection.objectsPresent.length > 0 - ? 'objects' - : nextCorrection.clothingPresent.length > 0 - ? 'clothing' - : null - - setExpandedCorrectionSections( - initiallyExpandedSection - ? nextExpandedCorrectionSections(initiallyExpandedSection, true) - : { - sexPosition: false, - people: false, - bodyParts: false, - objects: false, - clothing: false, - } - ) + loadTrainingSampleIntoTab(data as TrainingSample) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + if (isCurrentRequest()) { + setError(e instanceof Error ? e.message : String(e)) + } } finally { + if (!isCurrentRequest()) { + return + } + setAnalysisProgress((value) => Math.max(value, 100)) setAnalysisStep((value) => value || 'Analyse abgeschlossen.') @@ -2645,7 +2737,7 @@ export default function TrainingTab(props: { setAnalysisStep('') }, 500) } - }, [trainingSampleMode]) + }, [loadTrainingSampleIntoTab, setLoadingPreviewCandidate]) const reloadCurrentImage = useCallback(async () => { setDrawingBox(null) @@ -2666,6 +2758,124 @@ export default function TrainingTab(props: { applyTrainingStatus(data) }, [applyTrainingStatus]) + const importVideoIntoTraining = useCallback(async (raw: any) => { + const output = String(raw?.output || '').trim() + if (!output) return false + + setLoadingPreviewCandidate( + `/api/training/video-preview?output=${encodeURIComponent(output)}` + ) + + const detail: PendingTrainingVideoImport = { + jobId: String(raw?.jobId || '').trim(), + output, + sourceFile: String(raw?.sourceFile || '').trim(), + count: Number(raw?.count || 8), + } + + const importKey = `${detail.jobId || ''}|${detail.output}|${detail.count || 8}` + + // Verhindert Doppelimport durch sessionStorage + CustomEvent. + if (videoImportInFlightKeyRef.current === importKey) { + return false + } + + videoImportStartedRef.current = true + videoImportInFlightKeyRef.current = importKey + + const requestId = makeRequestId() + activeAnalysisRequestIdRef.current = requestId + loadingRef.current = true + + setLoading(true) + setAnalysisProgress(5) + setAnalysisStep('Video wird ins Training übernommen…') + setError(null) + setMessage(null) + + try { + try { + window.sessionStorage.removeItem('training:pending-import-video') + } catch { + // ignore + } + + const res = await fetch('/api/training/import-video', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + cache: 'no-store', + body: JSON.stringify({ + jobId: detail.jobId, + output: detail.output, + count: detail.count || 8, + analysisRequestId: requestId, + }), + }) + + const data = await res.json().catch(() => null) + + if (!res.ok || !data?.ok) { + throw new Error(backendText(data, `HTTP ${res.status}`)) + } + + const samples: TrainingSample[] = Array.isArray(data.samples) + ? data.samples + : data.sample + ? [data.sample] + : [] + + if (samples.length === 0) { + throw new Error('Es wurden keine Trainingsframes erzeugt.') + } + + const [firstSample, ...queuedSamples] = samples + + importedSampleQueueRef.current = queuedSamples + setImportedSampleQueue(queuedSamples) + + loadTrainingSampleIntoTab(firstSample) + setImageReloadKey((value) => value + 1) + + await loadTrainingStatus() + + const errorCount = Array.isArray(data.errors) ? data.errors.length : 0 + + setMessage( + errorCount > 0 + ? `${samples.length} Frames ins Training übernommen, ${errorCount} Frames fehlgeschlagen.` + : `${samples.length} Frames ins Training übernommen.` + ) + + return true + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + return false + } finally { + setAnalysisProgress(100) + setAnalysisStep('Video-Import abgeschlossen.') + + const finishedRequestId = requestId + + window.setTimeout(() => { + if (activeAnalysisRequestIdRef.current === finishedRequestId) { + activeAnalysisRequestIdRef.current = null + loadingRef.current = false + setLoading(false) + setAnalysisProgress(0) + setAnalysisStep('') + } + + if (videoImportInFlightKeyRef.current === importKey) { + videoImportInFlightKeyRef.current = null + } + }, 500) + } + }, [ + loadTrainingSampleIntoTab, + loadTrainingStatus, + setLoadingPreviewCandidate, + ]) + const loadTrainingStats = useCallback(async () => { setTrainingStatsLoading(true) setTrainingStatsError(null) @@ -2790,6 +3000,16 @@ export default function TrainingTab(props: { '' ).trim() + const previewUrl = String( + data?.previewUrl || + data?.analysis?.previewUrl || + '' + ).trim() + + if (previewUrl) { + setLoadingPreviewCandidate(previewUrl) + } + const current = Number(data?.current ?? data?.stepIndex ?? data?.index) const total = Number(data?.total ?? data?.steps ?? data?.stepTotal) @@ -2832,7 +3052,7 @@ export default function TrainingTab(props: { return () => { window.removeEventListener('app:sse:analysis', onAnalysis as EventListener) } - }, []) + }, [setLoadingPreviewCandidate]) useEffect(() => { const draggingBox = Boolean(drawingBox || boxInteraction) @@ -2959,6 +3179,30 @@ export default function TrainingTab(props: { }) }, [activeBoxIndex]) + useEffect(() => { + const onImportVideo = (event: Event) => { + const detail = (event as CustomEvent).detail + void importVideoIntoTraining(detail) + } + + window.addEventListener('training:import-video', onImportVideo as EventListener) + + try { + const raw = window.sessionStorage.getItem('training:pending-import-video') + + if (raw) { + const detail = JSON.parse(raw) + void importVideoIntoTraining(detail) + } + } catch { + // ignore + } + + return () => { + window.removeEventListener('training:import-video', onImportVideo as EventListener) + } + }, [importVideoIntoTraining]) + useEffect(() => { let cancelled = false @@ -2968,8 +3212,13 @@ export default function TrainingTab(props: { if (cancelled) return - // Wichtig: Auch während laufendem Training wieder das aktuelle offene Sample laden, - // damit nicht "Kein Bild geladen" angezeigt wird. + // Wichtig: + // Wenn gerade ein Video-Import über "Video ins Training übernehmen" läuft, + // darf loadNext() nicht danach ein zufälliges/letztes Sample darüberlegen. + if (videoImportStartedRef.current || videoImportInFlightKeyRef.current) { + return + } + await loadNext() } @@ -3161,14 +3410,27 @@ export default function TrainingTab(props: { ) await loadTrainingStatus() - await loadNext({ preserveNotice: true }) + + if (!loadNextImportedQueuedSample()) { + await loadNext({ + forceNew: true, + preserveNotice: true, + }) + } } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setSaving(false) } }, - [sample, correction, editingFeedback, loadNext, loadTrainingStatus] + [ + sample, + correction, + editingFeedback, + loadNext, + loadTrainingStatus, + loadNextImportedQueuedSample, + ] ) const skipCurrentSample = useCallback(async () => { @@ -3202,16 +3464,18 @@ export default function TrainingTab(props: { setBoxLabel('') setActiveBoxIndex(null) - await loadNext({ - forceNew: true, - preserveNotice: true, - }) + if (!loadNextImportedQueuedSample()) { + await loadNext({ + forceNew: true, + preserveNotice: true, + }) + } } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setSaving(false) } - }, [sample, loadNext]) + }, [sample, loadNext, loadNextImportedQueuedSample]) const startTraining = useCallback(async () => { shownTrainingCompletionRef.current = null @@ -3683,6 +3947,15 @@ export default function TrainingTab(props: { const frameBusy = loading || (!!imageSrc && !frameImageLoaded) + useEffect(() => { + if (loading || frameBusy) return + if (!loadingPreviewUrl) return + + setLoadingPreviewUrl('') + setLoadingPreviewLoaded(false) + setLoadingPreviewFailed(false) + }, [loading, frameBusy, loadingPreviewUrl]) + const showImageBoxes = !frameBusy && !trainingRunning const shownTrainingDurationMs = useMemo(() => { @@ -4387,8 +4660,115 @@ export default function TrainingTab(props: { ? 'touch-none' : 'touch-pan-y' + const loadingPreviewBackgroundUrl = + loadingPreviewUrl && loadingPreviewLoaded && !loadingPreviewFailed + ? loadingPreviewUrl + : '' + + const frameLayoutSize = useMemo(() => { + const width = Number(frameNaturalSize?.width) + const height = Number(frameNaturalSize?.height) + + if ( + Number.isFinite(width) && + Number.isFinite(height) && + width > 0 && + height > 0 + ) { + return { width, height } + } + + // Fallback, bis das echte Bildformat bekannt ist. + return { width: 1600, height: 900 } + }, [frameNaturalSize]) + + const imageAspectRatio = Math.max( + 0.25, + Math.min(4, frameLayoutSize.width / frameLayoutSize.height) + ) + + const imageStageLimits = imageExpanded + ? { + baseDvh: 52, + smDvh: 60, + lgDvh: 78, + lgPx: 820, + } + : { + baseDvh: 44, + smDvh: 52, + lgDvh: 64, + lgPx: 680, + } + + const imageStageStyle = { + aspectRatio: `${frameLayoutSize.width} / ${frameLayoutSize.height}`, + + '--image-stage-max-h': `${imageStageLimits.baseDvh}dvh`, + '--image-stage-max-h-sm': `${imageStageLimits.smDvh}dvh`, + '--image-stage-max-h-lg': `min(${imageStageLimits.lgDvh}dvh, ${imageStageLimits.lgPx}px)`, + + '--image-stage-w': `${imageStageLimits.baseDvh * imageAspectRatio}dvh`, + '--image-stage-w-sm': `${imageStageLimits.smDvh * imageAspectRatio}dvh`, + '--image-stage-w-lg': `min(${imageStageLimits.lgDvh * imageAspectRatio}dvh, ${Math.round( + imageStageLimits.lgPx * imageAspectRatio + )}px)`, + } as CSSProperties & Record + + const imageStageHeightClass = [ + 'max-h-[var(--image-stage-max-h)]', + 'sm:max-h-[var(--image-stage-max-h-sm)]', + 'lg:max-h-[var(--image-stage-max-h-lg)]', + 'w-[min(100%,var(--image-stage-w))]', + 'sm:w-[min(100%,var(--image-stage-w-sm))]', + 'lg:w-[min(100%,var(--image-stage-w-lg))]', + ].join(' ') + + const stageBusy = trainingRunning || frameBusy + + const stageOverlayMode: 'training' | 'analysis' = + trainingRunning ? 'training' : 'analysis' + + const stageOverlayText = trainingRunning + ? shownTrainingStep || 'Aktuelles Bild wird geladen…' + : analysisStep || 'Bild wird geladen…' + + const stageOverlayProgress = trainingRunning + ? shownTrainingProgress + : loading + ? analysisProgress + : 100 + return (
+ {loadingPreviewUrl ? ( + { + const img = e.currentTarget + + if (img.naturalWidth > 0 && img.naturalHeight > 0) { + setFrameNaturalSize({ + width: img.naturalWidth, + height: img.naturalHeight, + }) + } + + setLoadingPreviewLoaded(true) + setLoadingPreviewFailed(false) + }} + onError={() => { + setLoadingPreviewLoaded(false) + setLoadingPreviewFailed(true) + }} + /> + ) : null} +
@@ -4499,29 +4879,23 @@ export default function TrainingTab(props: {
{imageSrc ? ( -
+
{ + width={frameLayoutSize.width} + height={frameLayoutSize.height} + onLoad={(e) => { + const img = e.currentTarget + + if (img.naturalWidth > 0 && img.naturalHeight > 0) { + setFrameNaturalSize({ + width: img.naturalWidth, + height: img.naturalHeight, + }) + } + setFrameImageLoaded(true) window.requestAnimationFrame(updateImageLayerStyle) }} @@ -4546,8 +4931,7 @@ export default function TrainingTab(props: { onContextMenu={(e) => e.preventDefault()} onDragStart={(e) => e.preventDefault()} className={[ - 'block rounded-md h-auto min-h-0 max-h-[52dvh] max-w-full object-contain sm:max-h-[60dvh]', - imageExpanded ? 'lg:max-h-full' : 'lg:max-h-[72dvh]', + 'block h-auto min-h-0 max-h-full max-w-full rounded-md object-contain', 'select-none', imageTouchClass, '[-webkit-user-drag:none] [-webkit-touch-callout:none]', @@ -4627,9 +5011,9 @@ export default function TrainingTab(props: { 'dark:bg-amber-400 dark:text-black dark:ring-black/15', ].join(' ') : [ - 'bg-white/95 text-gray-950 ring-black/10 hover:bg-gray-50', - 'dark:bg-white/95 dark:text-gray-950 dark:ring-black/10 dark:hover:bg-gray-50', - ].join(' '), + 'bg-white/95 text-gray-950 ring-black/10 hover:bg-gray-50', + 'dark:bg-white/95 dark:text-gray-950 dark:ring-black/10 dark:hover:bg-gray-50', + ].join(' '), isDraft ? 'cursor-default' : 'cursor-move', ].join(' ')} disabled={Boolean(isDraft) || uiLocked} @@ -4734,7 +5118,7 @@ export default function TrainingTab(props: { }} >
- - {trainingRunning ? ( - - ) : frameBusy ? ( - - ) : null}
- ) : trainingRunning ? ( - - ) : loading ? ( - ) : ( -
Kein Bild geladen
+
+ Kein Bild geladen +
)} + + {stageBusy ? ( + + ) : null}