This commit is contained in:
Linrador 2026-05-05 15:06:59 +02:00
parent 96b81fc89d
commit cf5888d9e5
3 changed files with 172 additions and 42 deletions

View File

@ -65,8 +65,8 @@ const (
// Sprite-Modus ist aktuell deaktiviert. Analyse läuft über Video-Frames. // Sprite-Modus ist aktuell deaktiviert. Analyse läuft über Video-Frames.
analyzeMaxSpriteCandidates = 24 analyzeMaxSpriteCandidates = 24
// Video-Modus: schnelle Vorschau. Für bessere Trefferquote später 24. // Video-Modus: extrahiert 1 Frame alle N Sekunden.
// neu, falls du später alle N Sekunden willst // 1 = jeder Sekunde, 3 = alle 3 Sekunden, 5 = alle 5 Sekunden.
analyzeVideoFrameIntervalSeconds = 3 analyzeVideoFrameIntervalSeconds = 3
// AI-Server nicht mit tausenden Pfaden auf einmal fluten. // AI-Server nicht mit tausenden Pfaden auf einmal fluten.
@ -668,12 +668,18 @@ func trainingPredictFramePathsBatchForAnalyze(
res, err := client.Do(req) res, err := client.Do(req)
if err != nil { if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
return nil, err return nil, err
} }
defer res.Body.Close() defer res.Body.Close()
var parsed analyzeBatchPredictResp var parsed analyzeBatchPredictResp
if err := json.NewDecoder(res.Body).Decode(&parsed); err != nil { if err := json.NewDecoder(res.Body).Decode(&parsed); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
return nil, err return nil, err
} }
@ -1785,15 +1791,23 @@ func analyzeVideoFromFramesForGoal(
"Analyse 0%", "Analyse 0%",
) )
if err := ctx.Err(); err != nil {
publishAnalysisError(startedAtMs, file, "Analyse abgebrochen", err)
return nil, nil, err
}
durationSec, _ := durationSecondsForAnalyze(ctx, outPath) durationSec, _ := durationSecondsForAnalyze(ctx, outPath)
if err := ctx.Err(); err != nil {
publishAnalysisError(startedAtMs, file, "Analyse abgebrochen", err)
return nil, nil, err
}
if durationSec <= 0 { if durationSec <= 0 {
err := appErrorf("videolänge konnte nicht bestimmt werden") err := appErrorf("videolänge konnte nicht bestimmt werden")
publishAnalysisError(startedAtMs, file, "Analyse fehlgeschlagen", err) publishAnalysisError(startedAtMs, file, "Analyse fehlgeschlagen", err)
return nil, nil, err return nil, nil, err
} }
// Hilfsfunktion: garantiert, dass keine Prozentpunkte übersprungen werden.
// Beispiel: letzter Stand 12, neuer Stand 17 -> sendet 13,14,15,16,17.
publishPercentRange := func(lastPercent *int, nextPercent int, current int, total int, extractPhase bool) { publishPercentRange := func(lastPercent *int, nextPercent int, current int, total int, extractPhase bool) {
if total <= 0 { if total <= 0 {
total = 1 total = 1
@ -1814,7 +1828,6 @@ func analyzeVideoFromFramesForGoal(
label := fmt.Sprintf("Analyse %d%%", p) label := fmt.Sprintf("Analyse %d%%", p)
if extractPhase { if extractPhase {
// p läuft global 0..50, publishAnalyzeExtractProgress erwartet aber 0..1.
ratio := float64(p) / 50.0 ratio := float64(p) / 50.0
if ratio < 0 { if ratio < 0 {
ratio = 0 ratio = 0
@ -1830,9 +1843,6 @@ func analyzeVideoFromFramesForGoal(
label, label,
) )
} else { } else {
// Für die Inferenzphase current/total so wählen, dass der globale
// Fortschritt in publishAnalyzeInferenceProgress wieder exakt p ergibt:
// current/total = (p - 50) / 50
inferenceCurrent := current inferenceCurrent := current
inferenceTotal := total inferenceTotal := total
@ -1854,6 +1864,16 @@ func analyzeVideoFromFramesForGoal(
*lastPercent = nextPercent *lastPercent = nextPercent
} }
failCancelled := func() ([]analyzeHit, []analyzeHit, error) {
err := ctx.Err()
if err == nil {
err = context.Canceled
}
publishAnalysisError(startedAtMs, file, "Analyse abgebrochen", err)
return nil, nil, err
}
lastExtractPercent := 0 lastExtractPercent := 0
samples, cleanup, err := extractVideoFramesBatch( samples, cleanup, err := extractVideoFramesBatch(
@ -1887,6 +1907,14 @@ func analyzeVideoFromFramesForGoal(
}, },
) )
if cleanup != nil {
defer cleanup()
}
if ctx.Err() != nil {
return failCancelled()
}
if err != nil { if err != nil {
publishAnalysisError(startedAtMs, file, "Frames konnten nicht extrahiert werden", err) publishAnalysisError(startedAtMs, file, "Frames konnten nicht extrahiert werden", err)
return nil, nil, err return nil, nil, err
@ -1910,8 +1938,16 @@ func analyzeVideoFromFramesForGoal(
) )
} }
if ctx.Err() != nil {
return failCancelled()
}
paths := make([]string, 0, len(samples)) paths := make([]string, 0, len(samples))
for _, sample := range samples { for _, sample := range samples {
if err := ctx.Err(); err != nil {
return failCancelled()
}
paths = append(paths, sample.Path) paths = append(paths, sample.Path)
} }
@ -1925,6 +1961,10 @@ func analyzeVideoFromFramesForGoal(
"Analyse 50%", "Analyse 50%",
) )
if ctx.Err() != nil {
return failCancelled()
}
// Schneller AI-Server-Batch-Pfad für nsfw, highlights und all. // Schneller AI-Server-Batch-Pfad für nsfw, highlights und all.
// Wichtig: ensureAnalyzeAllGoalsForVideoCtx ruft goal="all" auf. // Wichtig: ensureAnalyzeAllGoalsForVideoCtx ruft goal="all" auf.
// Ohne diesen Block fällt "all" auf die sehr langsame Einzelbild-Analyse zurück. // Ohne diesen Block fällt "all" auf die sehr langsame Einzelbild-Analyse zurück.
@ -1937,6 +1977,10 @@ func analyzeVideoFromFramesForGoal(
detectorOnly := false detectorOnly := false
for startIdx := 0; startIdx < len(samples); startIdx += analyzeFramePredictBatchSize { for startIdx := 0; startIdx < len(samples); startIdx += analyzeFramePredictBatchSize {
if ctx.Err() != nil {
return failCancelled()
}
endIdx := startIdx + analyzeFramePredictBatchSize endIdx := startIdx + analyzeFramePredictBatchSize
if endIdx > len(samples) { if endIdx > len(samples) {
endIdx = len(samples) endIdx = len(samples)
@ -1948,6 +1992,10 @@ func analyzeVideoFromFramesForGoal(
detectorOnly, detectorOnly,
) )
if ctx.Err() != nil {
return failCancelled()
}
if batchErr != nil || len(predictions) < endIdx-startIdx { if batchErr != nil || len(predictions) < endIdx-startIdx {
appLogln("⚠️ video batch analyse fehlgeschlagen, fallback auf einzelbild-analyse:", batchErr) appLogln("⚠️ video batch analyse fehlgeschlagen, fallback auf einzelbild-analyse:", batchErr)
@ -1968,6 +2016,10 @@ func analyzeVideoFromFramesForGoal(
} }
for i := 0; i < endIdx-startIdx; i++ { for i := 0; i < endIdx-startIdx; i++ {
if ctx.Err() != nil {
return failCancelled()
}
sample := samples[startIdx+i] sample := samples[startIdx+i]
pred := predictions[i] pred := predictions[i]
@ -1999,6 +2051,10 @@ func analyzeVideoFromFramesForGoal(
} }
if batchOK { if batchOK {
if ctx.Err() != nil {
return failCancelled()
}
if lastInferencePercent < 100 { if lastInferencePercent < 100 {
publishPercentRange( publishPercentRange(
&lastInferencePercent, &lastInferencePercent,
@ -2012,8 +2068,6 @@ func analyzeVideoFromFramesForGoal(
cleanNSFWHits := mergeAnalyzeHits(nsfwHits) cleanNSFWHits := mergeAnalyzeHits(nsfwHits)
cleanHighlightHits := mergeAnalyzeHits(highlightHits) cleanHighlightHits := mergeAnalyzeHits(highlightHits)
cleanup()
publishAnalysisFinished(startedAtMs, total, file, "Analyse abgeschlossen") publishAnalysisFinished(startedAtMs, total, file, "Analyse abgeschlossen")
return cleanNSFWHits, cleanHighlightHits, nil return cleanNSFWHits, cleanHighlightHits, nil
} }
@ -2022,12 +2076,21 @@ func analyzeVideoFromFramesForGoal(
// Fallback: langsame Einzelbild-Analyse. // Fallback: langsame Einzelbild-Analyse.
// Dieser Pfad sollte nur laufen, wenn der AI-Server-Batch fehlschlägt. // Dieser Pfad sollte nur laufen, wenn der AI-Server-Batch fehlschlägt.
for i, sample := range samples { for i, sample := range samples {
if ctx.Err() != nil {
return failCancelled()
}
t := sample.Time t := sample.Time
switch goal { switch goal {
case "nsfw": case "nsfw":
res, err := classifyFramePathForAnalyze(ctx, sample.Path) res, frameErr := classifyFramePathForAnalyze(ctx, sample.Path)
if err == nil {
if ctx.Err() != nil {
return failCancelled()
}
if frameErr == nil {
bestLabel, bestScore := pickBestNSFWResult(res.Results) bestLabel, bestScore := pickBestNSFWResult(res.Results)
if bestLabel != "" && bestScore >= nsfwThresholdForLabel(bestLabel) { if bestLabel != "" && bestScore >= nsfwThresholdForLabel(bestLabel) {
nsfwHits = append(nsfwHits, analyzeHit{ nsfwHits = append(nsfwHits, analyzeHit{
@ -2042,10 +2105,20 @@ func analyzeVideoFromFramesForGoal(
case "highlights": case "highlights":
pred := predictFramePathForAnalyze(ctx, sample.Path) pred := predictFramePathForAnalyze(ctx, sample.Path)
if ctx.Err() != nil {
return failCancelled()
}
highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, t) highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, t)
default: default:
pred := predictFramePathForAnalyze(ctx, sample.Path) pred := predictFramePathForAnalyze(ctx, sample.Path)
if ctx.Err() != nil {
return failCancelled()
}
nsfwHits = appendNSFWHitFromPrediction(nsfwHits, pred, t) nsfwHits = appendNSFWHitFromPrediction(nsfwHits, pred, t)
highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, t) highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, t)
} }
@ -2066,6 +2139,10 @@ func analyzeVideoFromFramesForGoal(
) )
} }
if ctx.Err() != nil {
return failCancelled()
}
if lastInferencePercent < 100 { if lastInferencePercent < 100 {
publishPercentRange( publishPercentRange(
&lastInferencePercent, &lastInferencePercent,
@ -2079,8 +2156,6 @@ func analyzeVideoFromFramesForGoal(
cleanNSFWHits := mergeAnalyzeHits(nsfwHits) cleanNSFWHits := mergeAnalyzeHits(nsfwHits)
cleanHighlightHits := mergeAnalyzeHits(highlightHits) cleanHighlightHits := mergeAnalyzeHits(highlightHits)
cleanup()
publishAnalysisFinished(startedAtMs, total, file, "Analyse abgeschlossen") publishAnalysisFinished(startedAtMs, total, file, "Analyse abgeschlossen")
return cleanNSFWHits, cleanHighlightHits, nil return cleanNSFWHits, cleanHighlightHits, nil

View File

@ -492,6 +492,45 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
removeRegenerateAssetsJobLater(file, 3*time.Second) removeRegenerateAssetsJobLater(file, 3*time.Second)
} }
func cancelRegenerateAssetsJob(file string) bool {
file = strings.TrimSpace(file)
if file == "" {
return false
}
regenerateAssetsMu.Lock()
job := regenerateAssetsJobs[file]
if job == nil {
regenerateAssetsMu.Unlock()
return false
}
id := job.AssetID
cancel := job.Cancel
if job.State == "queued" || job.State == "running" {
job.State = "cancelled"
job.Error = "Abgebrochen"
job.EndedAt = time.Now()
}
regenerateAssetsMu.Unlock()
if cancel != nil {
cancel()
}
setRegenerateAssetsTaskError(file, "Abgebrochen")
publishFinishedPostworkPhase(file, id, "postwork", "meta", "missing", "", "")
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "missing", "", "")
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "missing", "", "")
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "missing", "", "")
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "", "")
removeRegenerateAssetsJobLater(file, 2*time.Second)
return true
}
func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) { func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost && r.Method != http.MethodDelete { if r.Method != http.MethodPost && r.Method != http.MethodDelete {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@ -520,13 +559,7 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
return return
} }
regenerateAssetsMu.Lock() cancelRegenerateAssetsJob(file)
job := regenerateAssetsJobs[file]
regenerateAssetsMu.Unlock()
if job != nil && job.Cancel != nil {
job.Cancel()
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{

View File

@ -933,7 +933,7 @@ function sortTrainingLabels(input: Partial<TrainingLabels> | null | undefined):
function TrainingOverlay(props: { step: string; progress: number }) { function TrainingOverlay(props: { step: string; progress: number }) {
return ( return (
<div className="absolute inset-0 z-20 flex items-center justify-center text-center text-white"> <div className="absolute inset-0 z-20 flex items-center justify-center text-center text-white">
<div className="absolute -inset-2 bg-black/45 backdrop-blur-[8px] shadow-[inset_0_0_72px_30px_rgba(0,0,0,0.75)]" /> <div className="absolute inset-0 rounded-lg bg-black/45 backdrop-blur-[8px] shadow-[inset_0_0_72px_30px_rgba(0,0,0,0.75)]" />
<div className="relative z-10 flex flex-col items-center justify-center"> <div className="relative z-10 flex flex-col items-center justify-center">
<LoadingSpinner <LoadingSpinner
@ -973,7 +973,7 @@ function LoadingImageOverlay(props: {
return ( return (
<div className="absolute inset-0 z-20 flex items-center justify-center text-center text-white"> <div className="absolute inset-0 z-20 flex items-center justify-center text-center text-white">
<div className="absolute -inset-2 rounded-xl bg-black/45 backdrop-blur-[8px] shadow-[inset_0_0_72px_30px_rgba(0,0,0,0.75)]" /> <div className="absolute inset-0 rounded-lg bg-black/45 backdrop-blur-[8px] shadow-[inset_0_0_72px_30px_rgba(0,0,0,0.75)]" />
<div className="relative z-10 flex flex-col items-center justify-center"> <div className="relative z-10 flex flex-col items-center justify-center">
<LoadingSpinner <LoadingSpinner
@ -2062,6 +2062,8 @@ export default function TrainingTab(props: {
const wasTrainingRunningRef = useRef(false) const wasTrainingRunningRef = useRef(false)
const shownTrainingCompletionRef = useRef<string | null>(null) const shownTrainingCompletionRef = useRef<string | null>(null)
const [frameImageLoaded, setFrameImageLoaded] = useState(false)
const imageBoxRef = useRef<HTMLDivElement | null>(null) const imageBoxRef = useRef<HTMLDivElement | null>(null)
const detectorBoxesScrollRef = useRef<HTMLDivElement | null>(null) const detectorBoxesScrollRef = useRef<HTMLDivElement | null>(null)
@ -2231,6 +2233,15 @@ export default function TrainingTab(props: {
return `${sample.frameUrl}&t=${encodeURIComponent(sample.sampleId)}&r=${imageReloadKey}` return `${sample.frameUrl}&t=${encodeURIComponent(sample.sampleId)}&r=${imageReloadKey}`
}, [sample, imageReloadKey]) }, [sample, imageReloadKey])
useEffect(() => {
if (!imageSrc) {
setFrameImageLoaded(false)
return
}
setFrameImageLoaded(false)
}, [imageSrc])
const canStartTraining = Boolean(trainingStatus?.canTrain) const canStartTraining = Boolean(trainingStatus?.canTrain)
const feedbackCount = trainingStatus?.feedbackCount ?? 0 const feedbackCount = trainingStatus?.feedbackCount ?? 0
const requiredCount = trainingStatus?.requiredCount ?? 5 const requiredCount = trainingStatus?.requiredCount ?? 5
@ -3150,7 +3161,9 @@ export default function TrainingTab(props: {
}) })
}, []) }, [])
const showImageBoxes = !loading && !trainingRunning const frameBusy = loading || (!!imageSrc && !frameImageLoaded)
const showImageBoxes = !frameBusy && !trainingRunning
const shownTrainingDurationMs = useMemo(() => { const shownTrainingDurationMs = useMemo(() => {
const job = trainingStatus?.training const job = trainingStatus?.training
@ -3428,7 +3441,7 @@ export default function TrainingTab(props: {
return ( return (
<div <div
className={[ className={[
'overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900/70', 'relative z-0 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900/70',
compact ? 'p-2' : 'p-3', compact ? 'p-2' : 'p-3',
].join(' ')} ].join(' ')}
> >
@ -3580,14 +3593,17 @@ export default function TrainingTab(props: {
const detectorBoxesPanel = (opts?: { const detectorBoxesPanel = (opts?: {
compact?: boolean compact?: boolean
stretch?: boolean
maxHeightClassName?: string maxHeightClassName?: string
}) => { }) => {
const compact = Boolean(opts?.compact) const compact = Boolean(opts?.compact)
const stretch = Boolean(opts?.stretch)
return ( return (
<div <div
className={[ className={[
'rounded-lg bg-gray-50 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10', 'relative z-0 flex min-h-0 flex-col overflow-hidden rounded-lg bg-gray-50 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10',
stretch ? 'h-full max-h-full flex-1' : 'max-h-full',
compact ? 'p-1.5' : 'p-2', compact ? 'p-1.5' : 'p-2',
].join(' ')} ].join(' ')}
> >
@ -3612,11 +3628,11 @@ export default function TrainingTab(props: {
<div <div
ref={detectorBoxesScrollRef} ref={detectorBoxesScrollRef}
className={[ className={[
'mt-2 min-h-6 overflow-y-auto pr-1 overscroll-contain scroll-smooth', 'mt-2 min-h-0 flex-1 overflow-y-auto pr-1 overscroll-contain scroll-smooth',
compact compact
? 'max-h-40 space-y-1' ? 'max-h-40 space-y-1'
: [ : [
'max-h-32 space-y-1', 'space-y-1',
opts?.maxHeightClassName || 'lg:max-h-[32dvh]', opts?.maxHeightClassName || 'lg:max-h-[32dvh]',
].join(' '), ].join(' '),
].join(' ')} ].join(' ')}
@ -3657,7 +3673,7 @@ export default function TrainingTab(props: {
<span className="flex min-w-0 items-center gap-1.5"> <span className="flex min-w-0 items-center gap-1.5">
<Icon <Icon
className={[ className={[
'shrink-0', 'relative z-0 shrink-0',
compact ? 'h-3 w-3' : 'h-3.5 w-3.5', compact ? 'h-3 w-3' : 'h-3.5 w-3.5',
isActive isActive
? 'text-blue-600 dark:text-blue-300' ? 'text-blue-600 dark:text-blue-300'
@ -3695,7 +3711,7 @@ export default function TrainingTab(props: {
type="button" type="button"
disabled={uiLocked} disabled={uiLocked}
className={[ className={[
'group/delete shrink-0 inline-flex items-center justify-center rounded-md transition', 'group/delete relative z-0 shrink-0 inline-flex items-center justify-center rounded-md transition',
compact ? 'h-6 w-6' : 'h-7 w-7', compact ? 'h-6 w-6' : 'h-7 w-7',
'text-red-600 hover:bg-red-50 hover:text-red-700', 'text-red-600 hover:bg-red-50 hover:text-red-700',
'focus:outline-none focus:ring-2 focus:ring-red-500/30', 'focus:outline-none focus:ring-2 focus:ring-red-500/30',
@ -3711,7 +3727,7 @@ export default function TrainingTab(props: {
> >
<TrashIcon <TrashIcon
className={[ className={[
'transition group-hover/delete:scale-110', 'relative z-0 transition group-hover/delete:scale-110',
compact ? 'h-3.5 w-3.5' : 'h-4 w-4', compact ? 'h-3.5 w-3.5' : 'h-4 w-4',
].join(' ')} ].join(' ')}
aria-hidden="true" aria-hidden="true"
@ -3738,7 +3754,7 @@ export default function TrainingTab(props: {
<Button <Button
size="md" size="md"
variant="secondary" variant="secondary"
className={compact ? 'mt-1.5 w-full text-xs' : 'mt-2 w-full'} className={compact ? 'mt-1.5 w-full text-xs' : 'mt-2 w-full shrink-0'}
disabled={uiLocked} disabled={uiLocked}
onClick={clearBoxes} onClick={clearBoxes}
> >
@ -3802,9 +3818,12 @@ export default function TrainingTab(props: {
Bestätige oder korrigiere die Analyse. Jede Antwort wird als Trainingsdatenpunkt gespeichert. Bestätige oder korrigiere die Analyse. Jede Antwort wird als Trainingsdatenpunkt gespeichert.
</div> </div>
<div className="mt-3 flex min-h-0 flex-1 flex-col gap-3"> <div className="mt-3 flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
<div className="min-h-0"> <div className="min-h-0 flex-1 overflow-hidden">
{detectorBoxesPanel({ maxHeightClassName: 'lg:max-h-[28dvh]' })} {detectorBoxesPanel({
stretch: true,
maxHeightClassName: 'lg:max-h-none',
})}
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
@ -3815,7 +3834,7 @@ export default function TrainingTab(props: {
{/* Mitte */} {/* Mitte */}
<section className="min-w-0 rounded-xl border border-gray-200 bg-white p-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60 sm:p-3 lg:self-start"> <section className="min-w-0 rounded-xl border border-gray-200 bg-white p-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60 sm:p-3 lg:self-start">
<div className="relative flex min-h-[180px] flex-1 items-center justify-center overflow-visible rounded-xl bg-black p-2 sm:p-3 lg:min-h-[300px]"> <div className="relative flex min-h-[180px] flex-1 items-center justify-center overflow-visible rounded-lg bg-black p-2 sm:p-3 lg:min-h-[300px]">
{imageSrc ? ( {imageSrc ? (
<div className="relative flex h-full w-full items-center justify-center"> <div className="relative flex h-full w-full items-center justify-center">
<div <div
@ -3836,10 +3855,12 @@ export default function TrainingTab(props: {
src={imageSrc} src={imageSrc}
alt="Training Frame" alt="Training Frame"
draggable={false} draggable={false}
onLoad={() => setFrameImageLoaded(true)}
onError={() => setFrameImageLoaded(true)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
onDragStart={(e) => e.preventDefault()} onDragStart={(e) => e.preventDefault()}
className={[ className={[
'block max-h-[52dvh] max-w-full object-contain sm:max-h-[60dvh] lg:max-h-[72dvh]', 'block rounded-lg max-h-[52dvh] max-w-full object-contain sm:max-h-[60dvh] lg:max-h-[72dvh]',
'select-none', 'select-none',
imageTouchClass, imageTouchClass,
'[-webkit-user-drag:none] [-webkit-touch-callout:none]', '[-webkit-user-drag:none] [-webkit-touch-callout:none]',
@ -4228,10 +4249,10 @@ export default function TrainingTab(props: {
step={shownTrainingStep} step={shownTrainingStep}
progress={shownTrainingProgress} progress={shownTrainingProgress}
/> />
) : loading ? ( ) : frameBusy ? (
<LoadingImageOverlay <LoadingImageOverlay
text={analysisStep} text={analysisStep || 'Frame wird geladen…'}
progress={analysisProgress} progress={loading ? analysisProgress : 100}
/> />
) : null} ) : null}
</div> </div>
@ -4250,13 +4271,14 @@ export default function TrainingTab(props: {
)} )}
</div> </div>
<div className="sticky bottom-0 z-40 mt-2 grid grid-cols-2 gap-2 sm:static"> <div className="sticky bottom-0 z-40 mt-3 grid grid-cols-2 gap-2 sm:static sm:mt-4">
<Button <Button
size="md" size="md"
variant={hasManualCorrection ? 'primary' : 'soft'} variant={hasManualCorrection ? 'primary' : 'soft'}
color={hasManualCorrection ? undefined : 'emerald'} color={hasManualCorrection ? undefined : 'emerald'}
disabled={ disabled={
uiLocked || uiLocked ||
frameBusy ||
!sample || !sample ||
(!hasManualCorrection && !sample.prediction.modelAvailable) (!hasManualCorrection && !sample.prediction.modelAvailable)
} }
@ -4288,7 +4310,7 @@ export default function TrainingTab(props: {
<Button <Button
size="md" size="md"
variant="secondary" variant="secondary"
disabled={uiLocked || !sample} disabled={uiLocked || frameBusy || !sample}
onClick={() => void loadNext({ forceNew: true })} onClick={() => void loadNext({ forceNew: true })}
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="Dieses Bild nicht bewerten und ein anderes laden." title="Dieses Bild nicht bewerten und ein anderes laden."