This commit is contained in:
Linrador 2026-06-24 17:29:24 +02:00
parent 518a8d5162
commit 885a22e206
6 changed files with 577 additions and 18 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -104,6 +104,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler) api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler)
api.HandleFunc("/api/training/skip", trainingSkipHandler) api.HandleFunc("/api/training/skip", trainingSkipHandler)
api.HandleFunc("/api/training/import-video", trainingImportVideoHandler) 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/training/video-preview", trainingVideoPreviewHandler)
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler) api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)

View File

@ -1232,13 +1232,36 @@ type TrainingImportVideoRequest struct {
} }
type TrainingImportVideoResponse struct { type TrainingImportVideoResponse struct {
OK bool `json:"ok"` OK bool `json:"ok"`
Count int `json:"count"` Accepted bool `json:"accepted,omitempty"`
Sample *TrainingSample `json:"sample,omitempty"` Running bool `json:"running,omitempty"`
Samples []TrainingSample `json:"samples,omitempty"` RequestID string `json:"requestId,omitempty"`
Errors []string `json:"errors,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 { func trainingCleanImportVideoCount(count int) int {
if count <= 0 { if count <= 0 {
return trainingImportVideoDefaultFrameCount return trainingImportVideoDefaultFrameCount
@ -1512,6 +1535,295 @@ func trainingFrameSecondsForVideo(duration float64, count int) []float64 {
return out 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) { func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
@ -1524,6 +1836,17 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) {
return 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) outPath := strings.TrimSpace(req.Output)
if outPath == "" { if outPath == "" {
trainingWriteError(w, http.StatusBadRequest, "output missing") trainingWriteError(w, http.StatusBadRequest, "output missing")

View File

@ -2751,6 +2751,8 @@ const FEEDBACK_PAGE_SIZE = 10
const TRAINING_INFO_DISMISSED_STORAGE_KEY = 'training:info-dismissed-key' const TRAINING_INFO_DISMISSED_STORAGE_KEY = 'training:info-dismissed-key'
const TRAINING_IMAGE_EXPANDED_STORAGE_KEY = 'training:image-expanded' 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: { export default function TrainingTab(props: {
active?: boolean active?: boolean
@ -2766,6 +2768,7 @@ export default function TrainingTab(props: {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [analysisProgress, setAnalysisProgress] = useState(0) const [analysisProgress, setAnalysisProgress] = useState(0)
const [analysisStep, setAnalysisStep] = useState('') const [analysisStep, setAnalysisStep] = useState('')
const [analysisSourceFile, setAnalysisSourceFile] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [training, setTraining] = useState(false) const [training, setTraining] = useState(false)
const [trainingStatus, setTrainingStatus] = useState<TrainingStatus | null>(null) const [trainingStatus, setTrainingStatus] = useState<TrainingStatus | null>(null)
@ -2794,6 +2797,7 @@ export default function TrainingTab(props: {
const [importedSampleQueue, setImportedSampleQueue] = useState<QueuedTrainingSample[]>([]) const [importedSampleQueue, setImportedSampleQueue] = useState<QueuedTrainingSample[]>([])
const importedSampleQueueRef = useRef<QueuedTrainingSample[]>([]) const importedSampleQueueRef = useRef<QueuedTrainingSample[]>([])
const feedbackEditReturnSampleRef = useRef<QueuedTrainingSample | null>(null)
const [feedbackModalOpen, setFeedbackModalOpen] = useState(false) const [feedbackModalOpen, setFeedbackModalOpen] = useState(false)
const [feedbackItems, setFeedbackItems] = useState<TrainingAnnotation[]>([]) const [feedbackItems, setFeedbackItems] = useState<TrainingAnnotation[]>([])
@ -2972,6 +2976,21 @@ export default function TrainingTab(props: {
]) ])
const editFeedbackItem = useCallback((item: TrainingAnnotation) => { 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 nextSample = annotationToTrainingSample(item)
const nextCorrection = annotationToCorrectionState(item) const nextCorrection = annotationToCorrectionState(item)
const nextManualCorrection = !item.accepted const nextManualCorrection = !item.accepted
@ -3001,7 +3020,7 @@ export default function TrainingTab(props: {
behavior: 'smooth', behavior: 'smooth',
}) })
}) })
}, []) }, [editingFeedback])
const getImageContentRect = useCallback((): ImageContentRect | null => { const getImageContentRect = useCallback((): ImageContentRect | null => {
const imgEl = frameImageRef.current const imgEl = frameImageRef.current
@ -3738,6 +3757,7 @@ export default function TrainingTab(props: {
setLoadingPreviewFallbackUrl(previewUrl) setLoadingPreviewFallbackUrl(previewUrl)
setLoadingPreviewCandidate(previewUrl) setLoadingPreviewCandidate(previewUrl)
setLoading(true) setLoading(true)
setAnalysisSourceFile('')
setAnalysisProgress(8) setAnalysisProgress(8)
setAnalysisStep( setAnalysisStep(
opts?.refreshPrediction opts?.refreshPrediction
@ -3793,7 +3813,15 @@ export default function TrainingTab(props: {
loadTrainingSampleIntoTab(data as TrainingSample) loadTrainingSampleIntoTab(data as TrainingSample)
} catch (e) { } catch (e) {
if (isCurrentRequest()) { 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 { } finally {
if (!isCurrentRequest()) { if (!isCurrentRequest()) {
@ -3810,6 +3838,7 @@ export default function TrainingTab(props: {
activeAnalysisRequestIdRef.current = null activeAnalysisRequestIdRef.current = null
setLoading(false) setLoading(false)
setAnalysisSourceFile('')
setAnalysisProgress(0) setAnalysisProgress(0)
setAnalysisStep('') setAnalysisStep('')
}, 500) }, 500)
@ -3838,6 +3867,64 @@ export default function TrainingTab(props: {
applyTrainingStatus(data) applyTrainingStatus(data)
}, [applyTrainingStatus]) }, [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 importVideoIntoTraining = useCallback(async (raw: any) => {
const output = String(raw?.output || '').trim() const output = String(raw?.output || '').trim()
if (!output) return false if (!output) return false
@ -3868,6 +3955,7 @@ export default function TrainingTab(props: {
loadingRef.current = true loadingRef.current = true
setLoading(true) setLoading(true)
setAnalysisSourceFile(detail.sourceFile || detail.output.split(/[\\/]/).pop() || '')
setAnalysisProgress(5) setAnalysisProgress(5)
setAnalysisStep('Video wird ins Training übernommen…') setAnalysisStep('Video wird ins Training übernommen…')
setError(null) setError(null)
@ -3875,7 +3963,11 @@ export default function TrainingTab(props: {
try { try {
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 { } catch {
// ignore // ignore
} }
@ -3898,6 +3990,18 @@ export default function TrainingTab(props: {
throw new Error(backendText(data, `HTTP ${res.status}`)) 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) const samples: TrainingSample[] = Array.isArray(data.samples)
? data.samples ? data.samples
: data.sample : data.sample
@ -3935,7 +4039,20 @@ export default function TrainingTab(props: {
return true return true
} catch (e) { } 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 return false
} finally { } finally {
setAnalysisProgress(100) setAnalysisProgress(100)
@ -3948,6 +4065,7 @@ export default function TrainingTab(props: {
activeAnalysisRequestIdRef.current = null activeAnalysisRequestIdRef.current = null
loadingRef.current = false loadingRef.current = false
setLoading(false) setLoading(false)
setAnalysisSourceFile('')
setAnalysisProgress(0) setAnalysisProgress(0)
setAnalysisStep('') setAnalysisStep('')
} }
@ -3961,6 +4079,7 @@ export default function TrainingTab(props: {
loadPriorityTrainingSamples, loadPriorityTrainingSamples,
loadTrainingStatus, loadTrainingStatus,
setLoadingPreviewCandidate, setLoadingPreviewCandidate,
waitForVideoImportResult,
]) ])
const loadTrainingStats = useCallback(async () => { const loadTrainingStats = useCallback(async () => {
@ -4160,6 +4279,16 @@ export default function TrainingTab(props: {
'' ''
).trim() ).trim()
const sourceFile = String(
data?.sourceFile ||
data?.analysis?.sourceFile ||
''
).trim()
if (sourceFile) {
setAnalysisSourceFile(sourceFile)
}
const previewUrl = String( const previewUrl = String(
data?.previewUrl || data?.previewUrl ||
data?.analysis?.previewUrl || data?.analysis?.previewUrl ||
@ -4359,8 +4488,67 @@ export default function TrainingTab(props: {
window.addEventListener('training:import-video', onImportVideo as EventListener) window.addEventListener('training:import-video', onImportVideo as EventListener)
let resumedActiveImport = false
try { 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) { if (raw) {
const detail = JSON.parse(raw) const detail = JSON.parse(raw)
@ -4373,7 +4561,7 @@ export default function TrainingTab(props: {
return () => { return () => {
window.removeEventListener('training:import-video', onImportVideo as EventListener) window.removeEventListener('training:import-video', onImportVideo as EventListener)
} }
}, [importVideoIntoTraining]) }, [importVideoIntoTraining, waitForVideoImportResult])
useEffect(() => { useEffect(() => {
if (!tabActive || initializedRef.current) return if (!tabActive || initializedRef.current) return
@ -4632,6 +4820,21 @@ export default function TrainingTab(props: {
await loadTrainingStatus() 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()) { if (!loadNextImportedQueuedSample()) {
await loadNext({ await loadNext({
forceNew: true, forceNew: true,
@ -4657,6 +4860,7 @@ export default function TrainingTab(props: {
editingFeedback, editingFeedback,
loadNext, loadNext,
loadTrainingStatus, loadTrainingStatus,
loadQueuedTrainingSample,
loadNextImportedQueuedSample, loadNextImportedQueuedSample,
] ]
) )
@ -4664,6 +4868,21 @@ export default function TrainingTab(props: {
const skipCurrentSample = useCallback(async () => { const skipCurrentSample = useCallback(async () => {
if (!sample) return 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 const skippedSampleId = sample.sampleId
setSaving(true) setSaving(true)
@ -4706,7 +4925,13 @@ export default function TrainingTab(props: {
} finally { } finally {
setSaving(false) setSaving(false)
} }
}, [sample, loadNext, loadNextImportedQueuedSample]) }, [
sample,
editingFeedback,
loadNext,
loadQueuedTrainingSample,
loadNextImportedQueuedSample,
])
const startTraining = useCallback(async () => { const startTraining = useCallback(async () => {
shownTrainingCompletionRef.current = null shownTrainingCompletionRef.current = null
@ -6382,9 +6607,13 @@ export default function TrainingTab(props: {
const stageOverlayMode: 'training' | 'analysis' = const stageOverlayMode: 'training' | 'analysis' =
trainingRunning ? 'training' : 'analysis' trainingRunning ? 'training' : 'analysis'
const analysisOverlayText = analysisSourceFile
? `${analysisSourceFile} · ${analysisStep || 'Bild wird geladen…'}`
: analysisStep || 'Bild wird geladen…'
const stageOverlayText = trainingRunning const stageOverlayText = trainingRunning
? shownTrainingStep || 'Aktuelles Bild wird geladen…' ? shownTrainingStep || 'Aktuelles Bild wird geladen…'
: analysisStep || 'Bild wird geladen…' : analysisOverlayText
const stageOverlayProgress = trainingRunning const stageOverlayProgress = trainingRunning
? shownTrainingProgress ? shownTrainingProgress
@ -7391,14 +7620,20 @@ export default function TrainingTab(props: {
onClick={() => void skipCurrentSample()} onClick={() => void skipCurrentSample()}
className="w-full justify-center px-2 text-xs sm:text-sm" className="w-full justify-center px-2 text-xs sm:text-sm"
title={ title={
trainingSampleMode === 'uncertain' editingFeedback
? 'Dieses Bild löschen und eine andere unsichere Prediction laden.' ? 'Feedback-Bearbeitung abbrechen und zum vorherigen Trainingsbild zurückkehren.'
: 'Dieses Bild löschen und ein anderes laden.' : trainingSampleMode === 'uncertain'
? 'Dieses Bild löschen und eine andere unsichere Prediction laden.'
: 'Dieses Bild löschen und ein anderes laden.'
} }
> >
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<ForwardIcon className="h-3.5 w-3.5" aria-hidden="true" /> {editingFeedback ? (
Überspringen <XCircleIcon className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<ForwardIcon className="h-3.5 w-3.5" aria-hidden="true" />
)}
{editingFeedback ? 'Bearbeitung abbrechen' : 'Überspringen'}
</span> </span>
</Button> </Button>