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 6265ae8..7369f49 100644 Binary files a/backend/__pycache__/ai_server.cpython-314.pyc and b/backend/__pycache__/ai_server.cpython-314.pyc differ diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json index 0e71787..c602d34 100644 --- a/backend/recorder_settings.json +++ b/backend/recorder_settings.json @@ -11,7 +11,7 @@ "useMyFreeCamsWatcher": true, "autoDeleteSmallDownloads": true, "autoDeleteSmallDownloadsBelowMB": 300, - "autoDeleteSmallDownloadsKeepFavorites": true, + "autoDeleteSmallDownloadsKeepFavorites": false, "lowDiskPauseBelowGB": 5, "blurPreviews": false, "teaserPlayback": "all", diff --git a/backend/routes.go b/backend/routes.go index c29959a..b8244e8 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -97,6 +97,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler) api.HandleFunc("/api/training/skip", trainingSkipHandler) api.HandleFunc("/api/training/import-video", trainingImportVideoHandler) + api.HandleFunc("/api/training/video-preview", trainingVideoPreviewHandler) api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler) api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler) diff --git a/backend/training.go b/backend/training.go index e30ea8c..bf7bf49 100644 --- a/backend/training.go +++ b/backend/training.go @@ -13,6 +13,7 @@ import ( "math" "math/rand" "net/http" + "net/url" "os" "os/exec" "path/filepath" @@ -697,13 +698,33 @@ func trainingPublishAnalysisStep( total int, sourceFile string, message string, +) { + trainingPublishAnalysisStepWithPreview( + requestID, + startedAtMs, + current, + total, + sourceFile, + "", + message, + ) +} + +func trainingPublishAnalysisStepWithPreview( + requestID string, + startedAtMs int64, + current int, + total int, + sourceFile string, + previewURL string, + message string, ) { progress := 0.0 if total > 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}