bugfixes
This commit is contained in:
parent
96b81fc89d
commit
cf5888d9e5
@ -65,8 +65,8 @@ const (
|
||||
// Sprite-Modus ist aktuell deaktiviert. Analyse läuft über Video-Frames.
|
||||
analyzeMaxSpriteCandidates = 24
|
||||
|
||||
// Video-Modus: schnelle Vorschau. Für bessere Trefferquote später 24.
|
||||
// neu, falls du später alle N Sekunden willst
|
||||
// Video-Modus: extrahiert 1 Frame alle N Sekunden.
|
||||
// 1 = jeder Sekunde, 3 = alle 3 Sekunden, 5 = alle 5 Sekunden.
|
||||
analyzeVideoFrameIntervalSeconds = 3
|
||||
|
||||
// AI-Server nicht mit tausenden Pfaden auf einmal fluten.
|
||||
@ -668,12 +668,18 @@ func trainingPredictFramePathsBatchForAnalyze(
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return nil, ctxErr
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var parsed analyzeBatchPredictResp
|
||||
if err := json.NewDecoder(res.Body).Decode(&parsed); err != nil {
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return nil, ctxErr
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -1785,15 +1791,23 @@ func analyzeVideoFromFramesForGoal(
|
||||
"Analyse 0%",
|
||||
)
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
publishAnalysisError(startedAtMs, file, "Analyse abgebrochen", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
durationSec, _ := durationSecondsForAnalyze(ctx, outPath)
|
||||
if err := ctx.Err(); err != nil {
|
||||
publishAnalysisError(startedAtMs, file, "Analyse abgebrochen", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if durationSec <= 0 {
|
||||
err := appErrorf("videolänge konnte nicht bestimmt werden")
|
||||
publishAnalysisError(startedAtMs, file, "Analyse fehlgeschlagen", 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) {
|
||||
if total <= 0 {
|
||||
total = 1
|
||||
@ -1814,7 +1828,6 @@ func analyzeVideoFromFramesForGoal(
|
||||
label := fmt.Sprintf("Analyse %d%%", p)
|
||||
|
||||
if extractPhase {
|
||||
// p läuft global 0..50, publishAnalyzeExtractProgress erwartet aber 0..1.
|
||||
ratio := float64(p) / 50.0
|
||||
if ratio < 0 {
|
||||
ratio = 0
|
||||
@ -1830,9 +1843,6 @@ func analyzeVideoFromFramesForGoal(
|
||||
label,
|
||||
)
|
||||
} 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
|
||||
inferenceTotal := total
|
||||
|
||||
@ -1854,6 +1864,16 @@ func analyzeVideoFromFramesForGoal(
|
||||
*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
|
||||
|
||||
samples, cleanup, err := extractVideoFramesBatch(
|
||||
@ -1887,6 +1907,14 @@ func analyzeVideoFromFramesForGoal(
|
||||
},
|
||||
)
|
||||
|
||||
if cleanup != nil {
|
||||
defer cleanup()
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return failCancelled()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
publishAnalysisError(startedAtMs, file, "Frames konnten nicht extrahiert werden", err)
|
||||
return nil, nil, err
|
||||
@ -1910,8 +1938,16 @@ func analyzeVideoFromFramesForGoal(
|
||||
)
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return failCancelled()
|
||||
}
|
||||
|
||||
paths := make([]string, 0, len(samples))
|
||||
for _, sample := range samples {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return failCancelled()
|
||||
}
|
||||
|
||||
paths = append(paths, sample.Path)
|
||||
}
|
||||
|
||||
@ -1925,6 +1961,10 @@ func analyzeVideoFromFramesForGoal(
|
||||
"Analyse 50%",
|
||||
)
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return failCancelled()
|
||||
}
|
||||
|
||||
// Schneller AI-Server-Batch-Pfad für nsfw, highlights und all.
|
||||
// Wichtig: ensureAnalyzeAllGoalsForVideoCtx ruft goal="all" auf.
|
||||
// Ohne diesen Block fällt "all" auf die sehr langsame Einzelbild-Analyse zurück.
|
||||
@ -1937,6 +1977,10 @@ func analyzeVideoFromFramesForGoal(
|
||||
detectorOnly := false
|
||||
|
||||
for startIdx := 0; startIdx < len(samples); startIdx += analyzeFramePredictBatchSize {
|
||||
if ctx.Err() != nil {
|
||||
return failCancelled()
|
||||
}
|
||||
|
||||
endIdx := startIdx + analyzeFramePredictBatchSize
|
||||
if endIdx > len(samples) {
|
||||
endIdx = len(samples)
|
||||
@ -1948,6 +1992,10 @@ func analyzeVideoFromFramesForGoal(
|
||||
detectorOnly,
|
||||
)
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return failCancelled()
|
||||
}
|
||||
|
||||
if batchErr != nil || len(predictions) < endIdx-startIdx {
|
||||
appLogln("⚠️ video batch analyse fehlgeschlagen, fallback auf einzelbild-analyse:", batchErr)
|
||||
|
||||
@ -1968,6 +2016,10 @@ func analyzeVideoFromFramesForGoal(
|
||||
}
|
||||
|
||||
for i := 0; i < endIdx-startIdx; i++ {
|
||||
if ctx.Err() != nil {
|
||||
return failCancelled()
|
||||
}
|
||||
|
||||
sample := samples[startIdx+i]
|
||||
pred := predictions[i]
|
||||
|
||||
@ -1999,6 +2051,10 @@ func analyzeVideoFromFramesForGoal(
|
||||
}
|
||||
|
||||
if batchOK {
|
||||
if ctx.Err() != nil {
|
||||
return failCancelled()
|
||||
}
|
||||
|
||||
if lastInferencePercent < 100 {
|
||||
publishPercentRange(
|
||||
&lastInferencePercent,
|
||||
@ -2012,8 +2068,6 @@ func analyzeVideoFromFramesForGoal(
|
||||
cleanNSFWHits := mergeAnalyzeHits(nsfwHits)
|
||||
cleanHighlightHits := mergeAnalyzeHits(highlightHits)
|
||||
|
||||
cleanup()
|
||||
|
||||
publishAnalysisFinished(startedAtMs, total, file, "Analyse abgeschlossen")
|
||||
return cleanNSFWHits, cleanHighlightHits, nil
|
||||
}
|
||||
@ -2022,12 +2076,21 @@ func analyzeVideoFromFramesForGoal(
|
||||
// Fallback: langsame Einzelbild-Analyse.
|
||||
// Dieser Pfad sollte nur laufen, wenn der AI-Server-Batch fehlschlägt.
|
||||
for i, sample := range samples {
|
||||
if ctx.Err() != nil {
|
||||
return failCancelled()
|
||||
}
|
||||
|
||||
t := sample.Time
|
||||
|
||||
switch goal {
|
||||
case "nsfw":
|
||||
res, err := classifyFramePathForAnalyze(ctx, sample.Path)
|
||||
if err == nil {
|
||||
res, frameErr := classifyFramePathForAnalyze(ctx, sample.Path)
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return failCancelled()
|
||||
}
|
||||
|
||||
if frameErr == nil {
|
||||
bestLabel, bestScore := pickBestNSFWResult(res.Results)
|
||||
if bestLabel != "" && bestScore >= nsfwThresholdForLabel(bestLabel) {
|
||||
nsfwHits = append(nsfwHits, analyzeHit{
|
||||
@ -2042,10 +2105,20 @@ func analyzeVideoFromFramesForGoal(
|
||||
|
||||
case "highlights":
|
||||
pred := predictFramePathForAnalyze(ctx, sample.Path)
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return failCancelled()
|
||||
}
|
||||
|
||||
highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, t)
|
||||
|
||||
default:
|
||||
pred := predictFramePathForAnalyze(ctx, sample.Path)
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return failCancelled()
|
||||
}
|
||||
|
||||
nsfwHits = appendNSFWHitFromPrediction(nsfwHits, pred, t)
|
||||
highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, t)
|
||||
}
|
||||
@ -2066,6 +2139,10 @@ func analyzeVideoFromFramesForGoal(
|
||||
)
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return failCancelled()
|
||||
}
|
||||
|
||||
if lastInferencePercent < 100 {
|
||||
publishPercentRange(
|
||||
&lastInferencePercent,
|
||||
@ -2079,8 +2156,6 @@ func analyzeVideoFromFramesForGoal(
|
||||
cleanNSFWHits := mergeAnalyzeHits(nsfwHits)
|
||||
cleanHighlightHits := mergeAnalyzeHits(highlightHits)
|
||||
|
||||
cleanup()
|
||||
|
||||
publishAnalysisFinished(startedAtMs, total, file, "Analyse abgeschlossen")
|
||||
|
||||
return cleanNSFWHits, cleanHighlightHits, nil
|
||||
|
||||
@ -492,6 +492,45 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
|
||||
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) {
|
||||
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
@ -520,13 +559,7 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
regenerateAssetsMu.Lock()
|
||||
job := regenerateAssetsJobs[file]
|
||||
regenerateAssetsMu.Unlock()
|
||||
|
||||
if job != nil && job.Cancel != nil {
|
||||
job.Cancel()
|
||||
}
|
||||
cancelRegenerateAssetsJob(file)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
|
||||
@ -933,7 +933,7 @@ function sortTrainingLabels(input: Partial<TrainingLabels> | null | undefined):
|
||||
function TrainingOverlay(props: { step: string; progress: number }) {
|
||||
return (
|
||||
<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">
|
||||
<LoadingSpinner
|
||||
@ -973,7 +973,7 @@ function LoadingImageOverlay(props: {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<LoadingSpinner
|
||||
@ -2062,6 +2062,8 @@ export default function TrainingTab(props: {
|
||||
const wasTrainingRunningRef = useRef(false)
|
||||
const shownTrainingCompletionRef = useRef<string | null>(null)
|
||||
|
||||
const [frameImageLoaded, setFrameImageLoaded] = useState(false)
|
||||
|
||||
const imageBoxRef = 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}`
|
||||
}, [sample, imageReloadKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageSrc) {
|
||||
setFrameImageLoaded(false)
|
||||
return
|
||||
}
|
||||
|
||||
setFrameImageLoaded(false)
|
||||
}, [imageSrc])
|
||||
|
||||
const canStartTraining = Boolean(trainingStatus?.canTrain)
|
||||
const feedbackCount = trainingStatus?.feedbackCount ?? 0
|
||||
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 job = trainingStatus?.training
|
||||
@ -3428,7 +3441,7 @@ export default function TrainingTab(props: {
|
||||
return (
|
||||
<div
|
||||
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',
|
||||
].join(' ')}
|
||||
>
|
||||
@ -3580,14 +3593,17 @@ export default function TrainingTab(props: {
|
||||
|
||||
const detectorBoxesPanel = (opts?: {
|
||||
compact?: boolean
|
||||
stretch?: boolean
|
||||
maxHeightClassName?: string
|
||||
}) => {
|
||||
const compact = Boolean(opts?.compact)
|
||||
const stretch = Boolean(opts?.stretch)
|
||||
|
||||
return (
|
||||
<div
|
||||
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',
|
||||
].join(' ')}
|
||||
>
|
||||
@ -3612,11 +3628,11 @@ export default function TrainingTab(props: {
|
||||
<div
|
||||
ref={detectorBoxesScrollRef}
|
||||
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
|
||||
? 'max-h-40 space-y-1'
|
||||
: [
|
||||
'max-h-32 space-y-1',
|
||||
'space-y-1',
|
||||
opts?.maxHeightClassName || 'lg:max-h-[32dvh]',
|
||||
].join(' '),
|
||||
].join(' ')}
|
||||
@ -3657,7 +3673,7 @@ export default function TrainingTab(props: {
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<Icon
|
||||
className={[
|
||||
'shrink-0',
|
||||
'relative z-0 shrink-0',
|
||||
compact ? 'h-3 w-3' : 'h-3.5 w-3.5',
|
||||
isActive
|
||||
? 'text-blue-600 dark:text-blue-300'
|
||||
@ -3695,7 +3711,7 @@ export default function TrainingTab(props: {
|
||||
type="button"
|
||||
disabled={uiLocked}
|
||||
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',
|
||||
'text-red-600 hover:bg-red-50 hover:text-red-700',
|
||||
'focus:outline-none focus:ring-2 focus:ring-red-500/30',
|
||||
@ -3711,7 +3727,7 @@ export default function TrainingTab(props: {
|
||||
>
|
||||
<TrashIcon
|
||||
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',
|
||||
].join(' ')}
|
||||
aria-hidden="true"
|
||||
@ -3738,7 +3754,7 @@ export default function TrainingTab(props: {
|
||||
<Button
|
||||
size="md"
|
||||
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}
|
||||
onClick={clearBoxes}
|
||||
>
|
||||
@ -3802,9 +3818,12 @@ export default function TrainingTab(props: {
|
||||
Bestätige oder korrigiere die Analyse. Jede Antwort wird als Trainingsdatenpunkt gespeichert.
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex min-h-0 flex-1 flex-col gap-3">
|
||||
<div className="min-h-0">
|
||||
{detectorBoxesPanel({ maxHeightClassName: 'lg:max-h-[28dvh]' })}
|
||||
<div className="mt-3 flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{detectorBoxesPanel({
|
||||
stretch: true,
|
||||
maxHeightClassName: 'lg:max-h-none',
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
@ -3815,7 +3834,7 @@ export default function TrainingTab(props: {
|
||||
|
||||
{/* 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">
|
||||
<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 ? (
|
||||
<div className="relative flex h-full w-full items-center justify-center">
|
||||
<div
|
||||
@ -3836,10 +3855,12 @@ export default function TrainingTab(props: {
|
||||
src={imageSrc}
|
||||
alt="Training Frame"
|
||||
draggable={false}
|
||||
onLoad={() => setFrameImageLoaded(true)}
|
||||
onError={() => setFrameImageLoaded(true)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
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',
|
||||
imageTouchClass,
|
||||
'[-webkit-user-drag:none] [-webkit-touch-callout:none]',
|
||||
@ -4228,10 +4249,10 @@ export default function TrainingTab(props: {
|
||||
step={shownTrainingStep}
|
||||
progress={shownTrainingProgress}
|
||||
/>
|
||||
) : loading ? (
|
||||
) : frameBusy ? (
|
||||
<LoadingImageOverlay
|
||||
text={analysisStep}
|
||||
progress={analysisProgress}
|
||||
text={analysisStep || 'Frame wird geladen…'}
|
||||
progress={loading ? analysisProgress : 100}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@ -4250,13 +4271,14 @@ export default function TrainingTab(props: {
|
||||
)}
|
||||
</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
|
||||
size="md"
|
||||
variant={hasManualCorrection ? 'primary' : 'soft'}
|
||||
color={hasManualCorrection ? undefined : 'emerald'}
|
||||
disabled={
|
||||
uiLocked ||
|
||||
frameBusy ||
|
||||
!sample ||
|
||||
(!hasManualCorrection && !sample.prediction.modelAvailable)
|
||||
}
|
||||
@ -4288,7 +4310,7 @@ export default function TrainingTab(props: {
|
||||
<Button
|
||||
size="md"
|
||||
variant="secondary"
|
||||
disabled={uiLocked || !sample}
|
||||
disabled={uiLocked || frameBusy || !sample}
|
||||
onClick={() => void loadNext({ forceNew: true })}
|
||||
className="w-full justify-center px-2 text-xs sm:text-sm"
|
||||
title="Dieses Bild nicht bewerten und ein anderes laden."
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user