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/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)

View File

@ -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")

View File

@ -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<TrainingStatus | null>(null)
@ -2794,6 +2797,7 @@ export default function TrainingTab(props: {
const [importedSampleQueue, setImportedSampleQueue] = useState<QueuedTrainingSample[]>([])
const importedSampleQueueRef = useRef<QueuedTrainingSample[]>([])
const feedbackEditReturnSampleRef = useRef<QueuedTrainingSample | null>(null)
const [feedbackModalOpen, setFeedbackModalOpen] = useState(false)
const [feedbackItems, setFeedbackItems] = useState<TrainingAnnotation[]>([])
@ -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.'
}
>
<span className="inline-flex items-center gap-1.5">
<ForwardIcon className="h-3.5 w-3.5" aria-hidden="true" />
Überspringen
{editingFeedback ? (
<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>
</Button>