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.
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

View File

@ -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{

View File

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