diff --git a/backend/dist/nsfwapp-linux-amd64 b/backend/dist/nsfwapp-linux-amd64 index fe49989..4cd1bcd 100644 Binary files a/backend/dist/nsfwapp-linux-amd64 and b/backend/dist/nsfwapp-linux-amd64 differ diff --git a/backend/dist/nsfwapp.exe b/backend/dist/nsfwapp.exe index 4c59e62..b531f2a 100644 Binary files a/backend/dist/nsfwapp.exe and b/backend/dist/nsfwapp.exe differ diff --git a/backend/dist/nsfwapp_amd64.deb b/backend/dist/nsfwapp_amd64.deb index 7139c16..5a4fd0f 100644 Binary files a/backend/dist/nsfwapp_amd64.deb and b/backend/dist/nsfwapp_amd64.deb differ diff --git a/backend/routes.go b/backend/routes.go index 75a1922..078971a 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -104,6 +104,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/import-video/status", trainingImportVideoStatusHandler) api.HandleFunc("/api/training/video-preview", trainingVideoPreviewHandler) api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler) diff --git a/backend/training.go b/backend/training.go index 779e1c3..f3e3d42 100644 --- a/backend/training.go +++ b/backend/training.go @@ -1232,13 +1232,36 @@ type TrainingImportVideoRequest struct { } type TrainingImportVideoResponse struct { - OK bool `json:"ok"` - Count int `json:"count"` - Sample *TrainingSample `json:"sample,omitempty"` - Samples []TrainingSample `json:"samples,omitempty"` - Errors []string `json:"errors,omitempty"` + OK bool `json:"ok"` + Accepted bool `json:"accepted,omitempty"` + Running bool `json:"running,omitempty"` + RequestID string `json:"requestId,omitempty"` + Count int `json:"count"` + Sample *TrainingSample `json:"sample,omitempty"` + Samples []TrainingSample `json:"samples,omitempty"` + Errors []string `json:"errors,omitempty"` + Error string `json:"error,omitempty"` } +type trainingImportVideoJobState struct { + requestID string + startedAt time.Time + finishedAt time.Time + running bool + statusCode int + response *TrainingImportVideoResponse + errorText string +} + +var trainingImportVideoJobs = struct { + sync.Mutex + items map[string]*trainingImportVideoJobState +}{ + items: map[string]*trainingImportVideoJobState{}, +} + +const trainingImportVideoJobTTL = 2 * time.Hour + func trainingCleanImportVideoCount(count int) int { if count <= 0 { return trainingImportVideoDefaultFrameCount @@ -1512,6 +1535,295 @@ func trainingFrameSecondsForVideo(duration float64, count int) []float64 { return out } +func trainingRunImportVideoRequest(req TrainingImportVideoRequest) (TrainingImportVideoResponse, int, string) { + outPath := strings.TrimSpace(req.Output) + if outPath == "" { + msg := "output missing" + return TrainingImportVideoResponse{OK: false, Error: msg}, http.StatusBadRequest, msg + } + + if !trainingSupportedImportVideo(outPath) { + msg := "unsupported video type" + return TrainingImportVideoResponse{OK: false, Error: msg}, http.StatusBadRequest, msg + } + + fi, err := os.Stat(outPath) + if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { + if err == nil { + err = errors.New("video file missing or empty") + } + + msg := "video not found: " + err.Error() + return TrainingImportVideoResponse{OK: false, Error: msg}, http.StatusBadRequest, msg + } + + duration := trainingProbeDurationSeconds(outPath) + if duration <= 0 { + msg := "Videolaenge konnte nicht bestimmt werden" + return TrainingImportVideoResponse{OK: false, Error: msg}, http.StatusBadRequest, msg + } + + root, err := trainingRootDir() + if err != nil { + msg := err.Error() + return TrainingImportVideoResponse{OK: false, Error: msg}, http.StatusInternalServerError, msg + } + + if err := trainingEnsureDetectorDirs(root); err != nil { + msg := err.Error() + return TrainingImportVideoResponse{OK: false, Error: msg}, http.StatusInternalServerError, msg + } + + if err := os.MkdirAll(filepath.Join(root, "frames"), 0755); err != nil { + msg := err.Error() + return TrainingImportVideoResponse{OK: false, Error: msg}, http.StatusInternalServerError, msg + } + + if err := os.MkdirAll(filepath.Join(root, "samples"), 0755); err != nil { + msg := err.Error() + return TrainingImportVideoResponse{OK: false, Error: msg}, http.StatusInternalServerError, msg + } + + seconds := trainingFrameSecondsForVideo(duration, req.Count) + sourceFile := filepath.Base(outPath) + previewURL := "" + sourceFileWithFrame := func(index int) string { + return fmt.Sprintf("%s (%d / %d)", sourceFile, index+1, len(seconds)) + } + + requestID := strings.TrimSpace(req.AnalysisRequestID) + if requestID == "" { + requestID = trainingMakeSampleID(outPath, float64(time.Now().UnixNano())) + } + + totalSteps := len(seconds) * 3 + if totalSteps < 1 { + totalSteps = 1 + } + + startedAtMs := trainingPublishAnalysisStartedWithPreview( + requestID, + totalSteps, + sourceFile, + previewURL, + "Video wird ins Training uebernommen...", + ) + + var sourceSizeBytes int64 + if st, err := os.Stat(outPath); err == nil && st != nil && !st.IsDir() { + sourceSizeBytes = st.Size() + } + + samples := make([]TrainingSample, 0, len(seconds)) + errs := []string{} + + for i, second := range seconds { + stepBase := i * 3 + + trainingPublishAnalysisStepWithPreview( + requestID, + startedAtMs, + stepBase+1, + totalSteps, + sourceFileWithFrame(i), + previewURL, + fmt.Sprintf("Frame %d/%d wird extrahiert...", i+1, len(seconds)), + ) + + id := trainingMakeSampleID(outPath, second) + framePath := filepath.Join(root, "frames", id+".jpg") + + if err := trainingExtractFrame(outPath, framePath, second); err != nil { + errs = append(errs, fmt.Sprintf("Frame bei %.1fs: %v", second, err)) + continue + } + + previewURL = "/api/training/frame?id=" + url.QueryEscape(id) + + trainingPublishAnalysisStepWithPreview( + requestID, + startedAtMs, + stepBase+2, + totalSteps, + sourceFileWithFrame(i), + previewURL, + fmt.Sprintf("Frame %d/%d wird analysiert...", i+1, len(seconds)), + ) + + prediction := trainingPredictFrame(framePath) + + sample := &TrainingSample{ + SampleID: id, + FrameURL: "/api/training/frame?id=" + id, + SourceFile: sourceFile, + SourcePath: outPath, + SourceSizeBytes: sourceSizeBytes, + Second: second, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + Prediction: prediction, + } + + trainingPublishAnalysisStepWithPreview( + requestID, + startedAtMs, + stepBase+3, + totalSteps, + sourceFileWithFrame(i), + previewURL, + fmt.Sprintf("Frame %d/%d wird gespeichert...", i+1, len(seconds)), + ) + + if err := trainingWriteSample(root, sample); err != nil { + _ = os.Remove(framePath) + errs = append(errs, fmt.Sprintf("Frame bei %.1fs speichern: %v", second, err)) + continue + } + + samples = append(samples, *sample) + } + + if len(samples) == 0 { + msg := "keine Trainingsframes erzeugt" + if len(errs) > 0 { + msg += ": " + strings.Join(errs, "; ") + } + + err := errors.New(msg) + trainingPublishAnalysisError( + requestID, + startedAtMs, + sourceFile, + "Video konnte nicht ins Training uebernommen werden.", + err, + ) + + return TrainingImportVideoResponse{OK: false, RequestID: requestID, Error: msg}, http.StatusInternalServerError, msg + } + + trainingPublishAnalysisFinished( + requestID, + startedAtMs, + totalSteps, + sourceFile, + fmt.Sprintf("%d Frames ins Training uebernommen.", len(samples)), + ) + + return TrainingImportVideoResponse{ + OK: true, + RequestID: requestID, + Count: len(samples), + Sample: &samples[0], + Samples: samples, + Errors: errs, + }, http.StatusOK, "" +} + +func trainingPruneImportVideoJobsLocked(now time.Time) { + for requestID, job := range trainingImportVideoJobs.items { + if job == nil || job.running { + continue + } + if !job.finishedAt.IsZero() && now.Sub(job.finishedAt) > trainingImportVideoJobTTL { + delete(trainingImportVideoJobs.items, requestID) + } + } +} + +func trainingStartImportVideoJob(req TrainingImportVideoRequest) { + requestID := strings.TrimSpace(req.AnalysisRequestID) + if requestID == "" { + requestID = trainingMakeSampleID(req.Output, float64(time.Now().UnixNano())) + req.AnalysisRequestID = requestID + } + + now := time.Now().UTC() + + trainingImportVideoJobs.Lock() + trainingPruneImportVideoJobsLocked(now) + if _, exists := trainingImportVideoJobs.items[requestID]; exists { + trainingImportVideoJobs.Unlock() + return + } + + trainingImportVideoJobs.items[requestID] = &trainingImportVideoJobState{ + requestID: requestID, + startedAt: now, + running: true, + statusCode: http.StatusAccepted, + } + trainingImportVideoJobs.Unlock() + + go func() { + resp, statusCode, errorText := trainingRunImportVideoRequest(req) + resp.RequestID = requestID + + trainingImportVideoJobs.Lock() + if job := trainingImportVideoJobs.items[requestID]; job != nil { + job.running = false + job.finishedAt = time.Now().UTC() + job.statusCode = statusCode + job.response = &resp + job.errorText = strings.TrimSpace(errorText) + } + trainingImportVideoJobs.Unlock() + }() +} + +func trainingImportVideoJobResponse(requestID string) (TrainingImportVideoResponse, int) { + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return TrainingImportVideoResponse{OK: false, Error: "requestId missing"}, http.StatusBadRequest + } + + trainingImportVideoJobs.Lock() + job := trainingImportVideoJobs.items[requestID] + trainingImportVideoJobs.Unlock() + + if job == nil { + return TrainingImportVideoResponse{OK: false, RequestID: requestID, Error: "Import-Job nicht gefunden."}, http.StatusNotFound + } + + if job.running { + return TrainingImportVideoResponse{ + OK: true, + Accepted: true, + Running: true, + RequestID: requestID, + }, http.StatusAccepted + } + + if job.response != nil { + resp := *job.response + resp.Running = false + resp.RequestID = requestID + if resp.Error == "" { + resp.Error = job.errorText + } + statusCode := job.statusCode + if statusCode < 100 { + statusCode = http.StatusOK + } + return resp, statusCode + } + + return TrainingImportVideoResponse{OK: false, RequestID: requestID, Error: job.errorText}, http.StatusInternalServerError +} + +func trainingImportVideoStatusHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + requestID := strings.TrimSpace(r.URL.Query().Get("requestId")) + if requestID == "" { + requestID = strings.TrimSpace(r.URL.Query().Get("id")) + } + + resp, statusCode := trainingImportVideoJobResponse(requestID) + trainingWriteJSON(w, statusCode, resp) +} + func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") @@ -1524,6 +1836,17 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) { return } + analysisRequestID := strings.TrimSpace(req.AnalysisRequestID) + if analysisRequestID == "" { + analysisRequestID = trainingMakeSampleID(req.Output, float64(time.Now().UnixNano())) + req.AnalysisRequestID = analysisRequestID + } + + trainingStartImportVideoJob(req) + resp, statusCode := trainingImportVideoJobResponse(analysisRequestID) + trainingWriteJSON(w, statusCode, resp) + return + outPath := strings.TrimSpace(req.Output) if outPath == "" { trainingWriteError(w, http.StatusBadRequest, "output missing") diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx index cfe0c6a..d23dfa1 100644 --- a/frontend/src/components/ui/TrainingTab.tsx +++ b/frontend/src/components/ui/TrainingTab.tsx @@ -2751,6 +2751,8 @@ const FEEDBACK_PAGE_SIZE = 10 const TRAINING_INFO_DISMISSED_STORAGE_KEY = 'training:info-dismissed-key' const TRAINING_IMAGE_EXPANDED_STORAGE_KEY = 'training:image-expanded' +const TRAINING_PENDING_IMPORT_VIDEO_STORAGE_KEY = 'training:pending-import-video' +const TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY = 'training:active-import-video' export default function TrainingTab(props: { active?: boolean @@ -2766,6 +2768,7 @@ export default function TrainingTab(props: { const [loading, setLoading] = useState(false) const [analysisProgress, setAnalysisProgress] = useState(0) const [analysisStep, setAnalysisStep] = useState('') + const [analysisSourceFile, setAnalysisSourceFile] = useState('') const [saving, setSaving] = useState(false) const [training, setTraining] = useState(false) const [trainingStatus, setTrainingStatus] = useState(null) @@ -2794,6 +2797,7 @@ export default function TrainingTab(props: { const [importedSampleQueue, setImportedSampleQueue] = useState([]) const importedSampleQueueRef = useRef([]) + const feedbackEditReturnSampleRef = useRef(null) const [feedbackModalOpen, setFeedbackModalOpen] = useState(false) const [feedbackItems, setFeedbackItems] = useState([]) @@ -2972,6 +2976,21 @@ export default function TrainingTab(props: { ]) const editFeedbackItem = useCallback((item: TrainingAnnotation) => { + const currentSample = sampleRef.current + + if ( + !editingFeedback && + !feedbackEditReturnSampleRef.current && + currentSample && + currentSample.sampleId !== item.sampleId + ) { + feedbackEditReturnSampleRef.current = { + sample: currentSample, + correction: cloneCorrectionState(correctionRef.current), + manualCorrection: hasManualCorrectionRef.current, + } + } + const nextSample = annotationToTrainingSample(item) const nextCorrection = annotationToCorrectionState(item) const nextManualCorrection = !item.accepted @@ -3001,7 +3020,7 @@ export default function TrainingTab(props: { behavior: 'smooth', }) }) - }, []) + }, [editingFeedback]) const getImageContentRect = useCallback((): ImageContentRect | null => { const imgEl = frameImageRef.current @@ -3738,6 +3757,7 @@ export default function TrainingTab(props: { setLoadingPreviewFallbackUrl(previewUrl) setLoadingPreviewCandidate(previewUrl) setLoading(true) + setAnalysisSourceFile('') setAnalysisProgress(8) setAnalysisStep( opts?.refreshPrediction @@ -3793,7 +3813,15 @@ export default function TrainingTab(props: { loadTrainingSampleIntoTab(data as TrainingSample) } catch (e) { if (isCurrentRequest()) { - setError(e instanceof Error ? e.message : String(e)) + const msg = e instanceof Error ? e.message : String(e) + const mayStillRun = + /load failed|failed to fetch|networkerror|network error/i.test(msg) + + if (mayStillRun) { + setMessage('Analyse läuft im Backend weiter. Beim Zurückkehren wird das nächste offene Trainingsbild wieder geladen.') + } else { + setError(msg) + } } } finally { if (!isCurrentRequest()) { @@ -3810,6 +3838,7 @@ export default function TrainingTab(props: { activeAnalysisRequestIdRef.current = null setLoading(false) + setAnalysisSourceFile('') setAnalysisProgress(0) setAnalysisStep('') }, 500) @@ -3838,6 +3867,64 @@ export default function TrainingTab(props: { applyTrainingStatus(data) }, [applyTrainingStatus]) + const completeVideoImportFromData = useCallback(async (data: any) => { + 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 deferredCurrentSample = Boolean(sampleRef.current) + + loadPriorityTrainingSamples(samples, { + deferCurrentSampleToQueueEnd: deferredCurrentSample, + }) + setImageReloadKey((value) => value + 1) + + await loadTrainingStatus() + + const errorCount = Array.isArray(data?.errors) ? data.errors.length : 0 + const baseMessage = errorCount > 0 + ? `${samples.length} Frames ins Training übernommen, ${errorCount} Frames fehlgeschlagen.` + : `${samples.length} Frames ins Training übernommen.` + + setMessage( + deferredCurrentSample + ? `${baseMessage} Das aktuelle Bild wurde ans Ende der Queue gelegt.` + : baseMessage + ) + + return true + }, [loadPriorityTrainingSamples, loadTrainingStatus]) + + const waitForVideoImportResult = useCallback(async (requestId: string) => { + const id = String(requestId || '').trim() + if (!id) throw new Error('requestId fehlt.') + + for (;;) { + const res = await fetch( + `/api/training/import-video/status?requestId=${encodeURIComponent(id)}`, + { cache: 'no-store' } + ) + const data = await res.json().catch(() => null) + + if (res.status === 202 || data?.running) { + await new Promise((resolve) => window.setTimeout(resolve, 1200)) + continue + } + + if (!res.ok || !data?.ok) { + throw new Error(backendText(data, `HTTP ${res.status}`)) + } + + return completeVideoImportFromData(data) + } + }, [completeVideoImportFromData]) + const importVideoIntoTraining = useCallback(async (raw: any) => { const output = String(raw?.output || '').trim() if (!output) return false @@ -3868,6 +3955,7 @@ export default function TrainingTab(props: { loadingRef.current = true setLoading(true) + setAnalysisSourceFile(detail.sourceFile || detail.output.split(/[\\/]/).pop() || '') setAnalysisProgress(5) setAnalysisStep('Video wird ins Training übernommen…') setError(null) @@ -3875,7 +3963,11 @@ export default function TrainingTab(props: { try { try { - window.sessionStorage.removeItem('training:pending-import-video') + window.sessionStorage.removeItem(TRAINING_PENDING_IMPORT_VIDEO_STORAGE_KEY) + window.sessionStorage.setItem( + TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY, + JSON.stringify({ requestId, importKey, detail }) + ) } catch { // ignore } @@ -3898,6 +3990,18 @@ export default function TrainingTab(props: { throw new Error(backendText(data, `HTTP ${res.status}`)) } + if (res.status === 202 || data?.accepted || data?.running) { + await waitForVideoImportResult(String(data?.requestId || requestId)) + + try { + window.sessionStorage.removeItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY) + } catch { + // ignore + } + + return true + } + const samples: TrainingSample[] = Array.isArray(data.samples) ? data.samples : data.sample @@ -3935,7 +4039,20 @@ export default function TrainingTab(props: { return true } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + const msg = e instanceof Error ? e.message : String(e) + const mayStillRun = + /load failed|failed to fetch|networkerror|network error/i.test(msg) + + if (mayStillRun) { + setMessage('Video-Import läuft im Backend weiter und wird beim Zurückkehren wieder aufgenommen.') + } else { + try { + window.sessionStorage.removeItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY) + } catch { + // ignore + } + setError(msg) + } return false } finally { setAnalysisProgress(100) @@ -3948,6 +4065,7 @@ export default function TrainingTab(props: { activeAnalysisRequestIdRef.current = null loadingRef.current = false setLoading(false) + setAnalysisSourceFile('') setAnalysisProgress(0) setAnalysisStep('') } @@ -3961,6 +4079,7 @@ export default function TrainingTab(props: { loadPriorityTrainingSamples, loadTrainingStatus, setLoadingPreviewCandidate, + waitForVideoImportResult, ]) const loadTrainingStats = useCallback(async () => { @@ -4160,6 +4279,16 @@ export default function TrainingTab(props: { '' ).trim() + const sourceFile = String( + data?.sourceFile || + data?.analysis?.sourceFile || + '' + ).trim() + + if (sourceFile) { + setAnalysisSourceFile(sourceFile) + } + const previewUrl = String( data?.previewUrl || data?.analysis?.previewUrl || @@ -4359,8 +4488,67 @@ export default function TrainingTab(props: { window.addEventListener('training:import-video', onImportVideo as EventListener) + let resumedActiveImport = false + try { - const raw = window.sessionStorage.getItem('training:pending-import-video') + const activeRaw = window.sessionStorage.getItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY) + + if (activeRaw) { + const active = JSON.parse(activeRaw) + const requestId = String(active?.requestId || '').trim() + const importKey = String(active?.importKey || '').trim() + + if (requestId) { + if (importKey) { + videoImportInFlightKeyRef.current = importKey + } + + activeAnalysisRequestIdRef.current = requestId + loadingRef.current = true + setLoading(true) + setAnalysisSourceFile(String(active?.detail?.sourceFile || active?.detail?.output || '').split(/[\\/]/).pop() || '') + setAnalysisProgress(5) + setAnalysisStep('Video-Import wird fortgesetzt…') + setError(null) + + void waitForVideoImportResult(requestId) + .then(() => { + window.sessionStorage.removeItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY) + }) + .catch((e) => { + const msg = e instanceof Error ? e.message : String(e) + const mayStillRun = + /load failed|failed to fetch|networkerror|network error/i.test(msg) + + if (mayStillRun) { + setMessage('Video-Import läuft im Backend weiter und wird beim Zurückkehren wieder aufgenommen.') + } else { + window.sessionStorage.removeItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY) + setError(msg) + } + }) + .finally(() => { + if (activeAnalysisRequestIdRef.current === requestId) { + activeAnalysisRequestIdRef.current = null + loadingRef.current = false + setLoading(false) + setAnalysisSourceFile('') + setAnalysisProgress(0) + setAnalysisStep('') + } + + if (importKey && videoImportInFlightKeyRef.current === importKey) { + videoImportInFlightKeyRef.current = null + } + }) + + resumedActiveImport = true + } + } + + const raw = resumedActiveImport + ? '' + : window.sessionStorage.getItem(TRAINING_PENDING_IMPORT_VIDEO_STORAGE_KEY) if (raw) { const detail = JSON.parse(raw) @@ -4373,7 +4561,7 @@ export default function TrainingTab(props: { return () => { window.removeEventListener('training:import-video', onImportVideo as EventListener) } - }, [importVideoIntoTraining]) + }, [importVideoIntoTraining, waitForVideoImportResult]) useEffect(() => { if (!tabActive || initializedRef.current) return @@ -4632,6 +4820,21 @@ export default function TrainingTab(props: { await loadTrainingStatus() + if (wasEditingFeedback) { + const returnItem = feedbackEditReturnSampleRef.current + feedbackEditReturnSampleRef.current = null + + if (returnItem) { + loadQueuedTrainingSample(returnItem) + return + } + + if (!loadNextImportedQueuedSample()) { + await loadNext({ preserveNotice: true }) + } + return + } + if (!loadNextImportedQueuedSample()) { await loadNext({ forceNew: true, @@ -4657,6 +4860,7 @@ export default function TrainingTab(props: { editingFeedback, loadNext, loadTrainingStatus, + loadQueuedTrainingSample, loadNextImportedQueuedSample, ] ) @@ -4664,6 +4868,21 @@ export default function TrainingTab(props: { const skipCurrentSample = useCallback(async () => { if (!sample) return + if (editingFeedback) { + const returnItem = feedbackEditReturnSampleRef.current + feedbackEditReturnSampleRef.current = null + + setEditingFeedback(null) + setError(null) + setMessage('Feedback-Bearbeitung abgebrochen.') + + if (returnItem) { + loadQueuedTrainingSample(returnItem) + } + + return + } + const skippedSampleId = sample.sampleId setSaving(true) @@ -4706,7 +4925,13 @@ export default function TrainingTab(props: { } finally { setSaving(false) } - }, [sample, loadNext, loadNextImportedQueuedSample]) + }, [ + sample, + editingFeedback, + loadNext, + loadQueuedTrainingSample, + loadNextImportedQueuedSample, + ]) const startTraining = useCallback(async () => { shownTrainingCompletionRef.current = null @@ -6382,9 +6607,13 @@ export default function TrainingTab(props: { const stageOverlayMode: 'training' | 'analysis' = trainingRunning ? 'training' : 'analysis' + const analysisOverlayText = analysisSourceFile + ? `${analysisSourceFile} · ${analysisStep || 'Bild wird geladen…'}` + : analysisStep || 'Bild wird geladen…' + const stageOverlayText = trainingRunning ? shownTrainingStep || 'Aktuelles Bild wird geladen…' - : analysisStep || 'Bild wird geladen…' + : analysisOverlayText const stageOverlayProgress = trainingRunning ? shownTrainingProgress @@ -7391,14 +7620,20 @@ export default function TrainingTab(props: { onClick={() => void skipCurrentSample()} className="w-full justify-center px-2 text-xs sm:text-sm" title={ - trainingSampleMode === 'uncertain' - ? 'Dieses Bild löschen und eine andere unsichere Prediction laden.' - : 'Dieses Bild löschen und ein anderes laden.' + editingFeedback + ? 'Feedback-Bearbeitung abbrechen und zum vorherigen Trainingsbild zurückkehren.' + : trainingSampleMode === 'uncertain' + ? 'Dieses Bild löschen und eine andere unsichere Prediction laden.' + : 'Dieses Bild löschen und ein anderes laden.' } > -