From fd3442500c3bae12618aab7ad70f46911d2ca4e9 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Fri, 15 May 2026 11:14:06 +0200 Subject: [PATCH] bugfixes and ui optimizations --- backend/build.bat | 1 + backend/record.go | 131 ++++++++++++++++++- backend/routes.go | 10 +- backend/training.go | 67 +++++++--- frontend/src/components/ui/ToastProvider.tsx | 39 +++++- frontend/src/components/ui/TrainingTab.tsx | 100 ++++++++++++-- 6 files changed, 310 insertions(+), 38 deletions(-) create mode 100644 backend/build.bat diff --git a/backend/build.bat b/backend/build.bat new file mode 100644 index 0000000..ec92206 --- /dev/null +++ b/backend/build.bat @@ -0,0 +1 @@ +go build -ldflags="-H=windowsgui" -o nsfwapp.exe \ No newline at end of file diff --git a/backend/record.go b/backend/record.go index df24977..4597536 100644 --- a/backend/record.go +++ b/backend/record.go @@ -2521,6 +2521,120 @@ func releaseFileForMutation(file string) { } } +func generatedAssetIDFromTrashFileName(name string) string { + name = strings.TrimSpace(filepath.Base(name)) + if name == "" { + return "" + } + + // Keine Meta-Dateien als Video behandeln. + if strings.EqualFold(name, "last.json") || strings.HasSuffix(strings.ToLower(name), ".json") { + return "" + } + + originalFile := "" + + // Neue Trash-Namen sehen so aus: + // __ + // + // Token kann theoretisch "_" enthalten, deshalb alle "__"-Positionen testen. + searchFrom := 0 + for { + idx := strings.Index(name[searchFrom:], "__") + if idx < 0 { + break + } + + pos := searchFrom + idx + candidate := strings.TrimSpace(name[pos+2:]) + + if isSafeBasename(candidate) && isAllowedVideoExt(candidate) { + originalFile = candidate + break + } + + searchFrom = pos + 2 + if searchFrom >= len(name) { + break + } + } + + // Legacy-Fallback: Trash-Datei heißt direkt wie das Video. + if originalFile == "" && isSafeBasename(name) && isAllowedVideoExt(name) { + originalFile = name + } + + if originalFile == "" { + return "" + } + + id := canonicalAssetIDFromName(originalFile) + if strings.TrimSpace(id) != "" { + return strings.TrimSpace(id) + } + + return strings.TrimSpace(stripHotPrefix( + strings.TrimSuffix(filepath.Base(originalFile), filepath.Ext(originalFile)), + )) +} + +func buildTrashGeneratedAssetIDMap(trashDir string, entries []os.DirEntry) map[string]string { + out := make(map[string]string, len(entries)) + + // 1) Zuerst Meta-Dateien lesen. Das ist am zuverlässigsten. + for _, e := range entries { + if e.IsDir() { + continue + } + + name := e.Name() + if !strings.HasSuffix(strings.ToLower(name), ".json") || strings.EqualFold(name, "last.json") { + continue + } + + b, err := os.ReadFile(filepath.Join(trashDir, name)) + if err != nil || len(b) == 0 { + continue + } + + var meta trashMeta + if err := json.Unmarshal(b, &meta); err != nil { + continue + } + + if !isSafeBasename(meta.TrashName) || !isSafeBasename(meta.File) || !isAllowedVideoExt(meta.File) { + continue + } + + id := canonicalAssetIDFromName(meta.File) + if strings.TrimSpace(id) == "" { + id = stripHotPrefix(strings.TrimSuffix(filepath.Base(meta.File), filepath.Ext(meta.File))) + } + + if strings.TrimSpace(id) != "" { + out[meta.TrashName] = strings.TrimSpace(id) + } + } + + // 2) Fallback über Dateinamen, falls Meta fehlt. + for _, e := range entries { + if e.IsDir() { + continue + } + + name := e.Name() + if _, exists := out[name]; exists { + continue + } + + if id := generatedAssetIDFromTrashFileName(name); id != "" { + out[name] = id + } + } + + return out +} + func cleanupTrashKeepOnlyLatest(trashDir, keepToken, keepTrashName string) { trashDir = filepath.Clean(strings.TrimSpace(trashDir)) keepToken = strings.TrimSpace(keepToken) @@ -2538,20 +2652,25 @@ func cleanupTrashKeepOnlyLatest(trashDir, keepToken, keepTrashName string) { return } + // Wichtig: + // Generated-Assets erst dann löschen, wenn der dazugehörige Trash-Eintrag + // endgültig aus .trash entfernt wird. + generatedIDByTrashName := buildTrashGeneratedAssetIDMap(trashDir, entries) + for _, e := range entries { name := e.Name() - // Das zuletzt gelöschte Video behalten + // Das zuletzt gelöschte Video behalten. if name == keepTrashName { continue } - // Token-Meta für Undo behalten + // Token-Meta für Undo behalten. if name == keepMetaName { continue } - // last.json behalten, damit Debug/Komfort weiterhin funktioniert + // last.json behalten, damit Debug/Komfort weiterhin funktioniert. if name == "last.json" { continue } @@ -2565,6 +2684,12 @@ func cleanupTrashKeepOnlyLatest(trashDir, keepToken, keepTrashName string) { continue } + // Nur wenn diese Trash-Datei jetzt endgültig gelöscht wird, + // werden die generated Assets entfernt. + if id := generatedIDByTrashName[name]; strings.TrimSpace(id) != "" { + removeGeneratedForID(id) + } + if err := removeWithRetry(full); err != nil { appLogln("⚠️ trash cleanup file failed:", full, err) } diff --git a/backend/routes.go b/backend/routes.go index b8244e8..f0ecf3b 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -56,15 +56,12 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { api.HandleFunc("/api/settings/cleanup", settingsCleanupHandler) api.HandleFunc("/api/pending-autostart", handlePendingAutoStart(auth)) + api.HandleFunc("/api/record", startRecordingFromRequest) api.HandleFunc("/api/record/status", recordStatus) api.HandleFunc("/api/record/stop", recordStop) api.HandleFunc("/api/record/stop-all", recordStopAll) api.HandleFunc("/api/record/postwork/remove", recordRemoveQueuedPostwork) - api.HandleFunc("/api/preview", recordPreview) - api.HandleFunc("/api/preview/live", recordPreviewLive) - api.HandleFunc("/api/preview-scrubber/", recordPreviewScrubberFrame) - api.HandleFunc("/api/preview-sprite/", recordPreviewSprite) api.HandleFunc("/api/record/list", recordList) api.HandleFunc("/api/record/done/meta", recordDoneMeta) api.HandleFunc("/api/record/video", recordVideo) @@ -83,6 +80,11 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { api.HandleFunc("/api/record/concurrency", recordConcurrentDownloadsStatus) api.HandleFunc("/api/record/release-file", handleReleaseFileTasks) + api.HandleFunc("/api/preview", recordPreview) + api.HandleFunc("/api/preview/live", recordPreviewLive) + api.HandleFunc("/api/preview-scrubber/", recordPreviewScrubberFrame) + api.HandleFunc("/api/preview-sprite/", recordPreviewSprite) + // Training / ML Annotation api.HandleFunc("/api/training/labels", trainingLabelsHandler) api.HandleFunc("/api/training/next", trainingNextHandler) diff --git a/backend/training.go b/backend/training.go index bf7bf49..e160909 100644 --- a/backend/training.go +++ b/backend/training.go @@ -1309,34 +1309,59 @@ func trainingFrameSecondsForVideo(duration float64, count int) []float64 { return []float64{0} } - out := make([]float64, 0, count) - + minSec := 0.5 maxSec := duration - 0.5 - if maxSec < 0 { - maxSec = duration + + if maxSec <= minSec { + return []float64{0} } - for i := 0; i < count; i++ { - ratio := float64(i+1) / float64(count+1) - sec := duration * ratio + out := make([]float64, 0, count) - if sec < 0.5 { - sec = 0.5 - } - if sec > maxSec { - sec = maxSec - } + // Lokaler RNG, damit jeder Import neue Frames bekommt. + rng := rand.New(rand.NewSource(time.Now().UnixNano())) - // Auf 0.1s runden, damit SampleIDs lesbarer/stabiler sind. + // Mindestabstand zwischen Frames, damit nicht mehrfach fast dieselbe Stelle kommt. + minDistance := 0.4 + + // Bei sehr kurzen Videos Abstand automatisch verkleinern. + availableRange := maxSec - minSec + if availableRange/float64(count) < minDistance { + minDistance = math.Max(0.1, availableRange/float64(count+1)) + } + + const maxAttempts = 4000 + + for attempts := 0; len(out) < count && attempts < maxAttempts; attempts++ { + sec := minSec + rng.Float64()*(maxSec-minSec) + + // Auf 0.1s runden, damit IDs/Logs lesbar bleiben. sec = math.Round(sec*10) / 10 - if len(out) > 0 && math.Abs(sec-out[len(out)-1]) < 0.2 { + tooClose := false + for _, existing := range out { + if math.Abs(sec-existing) < minDistance { + tooClose = true + break + } + } + + if tooClose { continue } out = append(out, sec) } + // Fallback, falls ein extrem kurzes Video nicht genug unterschiedliche Random-Punkte hergibt. + for len(out) < count { + sec := minSec + rng.Float64()*(maxSec-minSec) + sec = math.Round(sec*10) / 10 + out = append(out, sec) + } + + sort.Float64s(out) + if len(out) == 0 { out = append(out, 0) } @@ -1406,7 +1431,7 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) { seconds := trainingFrameSecondsForVideo(duration, req.Count) sourceFile := filepath.Base(outPath) - previewURL := trainingPreviewURLForVideoPath(outPath) + previewURL := "" requestID := strings.TrimSpace(req.AnalysisRequestID) if requestID == "" { @@ -1455,6 +1480,12 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) { continue } + // Ab hier existiert das erste echte extrahierte Bild. + // Dieses bleibt als Overlay-Background für Analyse/Speichern/weitere Frames. + if previewURL == "" { + previewURL = "/api/training/frame?id=" + url.QueryEscape(id) + } + trainingPublishAnalysisStepWithPreview( requestID, startedAtMs, @@ -3426,7 +3457,7 @@ func trainingCreateNextSampleWithProgressRange( } sourceFile := filepath.Base(videoPath) - previewURL := trainingPreviewURLForVideoPath(videoPath) + previewURL := "" publishStep = func(localStep int, sourceFile string, message string) { trainingPublishAnalysisStepWithPreview( @@ -3477,6 +3508,8 @@ func trainingCreateNextSampleWithProgressRange( } } + previewURL = "/api/training/frame?id=" + url.QueryEscape(id) + publishStep(3, sourceFile, "Bild wird analysiert…") prediction := trainingPredictFrame(framePath) diff --git a/frontend/src/components/ui/ToastProvider.tsx b/frontend/src/components/ui/ToastProvider.tsx index 3d4b27c..ae0d77f 100644 --- a/frontend/src/components/ui/ToastProvider.tsx +++ b/frontend/src/components/ui/ToastProvider.tsx @@ -53,6 +53,29 @@ const TOAST_LEAVE_MS = 220 const TOAST_SWIPE_DISMISS_PX = 96 const TOAST_SWIPE_LOCK_PX = 10 +const TOAST_MESSAGE_MAX_CHARS = 260 + +const toastMessageClampStyle: CSSProperties = { + display: '-webkit-box', + WebkitLineClamp: 3, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', +} + +function compactToastMessage(input?: string, max = TOAST_MESSAGE_MAX_CHARS) { + const text = String(input || '') + .replace(/\s+/g, ' ') + .trim() + + if (!text) return '' + + if (text.length <= max) { + return text + } + + return `${text.slice(0, max).trimEnd()}…` +} + function iconFor(type: ToastType) { switch (type) { case 'success': @@ -517,7 +540,9 @@ export function ToastProvider({ const { Icon, cls } = iconFor(t.type) const accents = accentFor(t.type) const title = (t.title || '').trim() || titleDefault(t.type) - const msg = (t.message || '').trim() + const fullMsg = (t.message || '').trim() + const msg = compactToastMessage(fullMsg) + const msgWasShortened = fullMsg.length > msg.length const img = (t.imageUrl || '').trim() const imgAlt = (t.imageAlt || title).trim() const isClickable = typeof t.onClick === 'function' @@ -622,7 +647,11 @@ export function ToastProvider({

{msg ? ( -

+

{msg}

) : null} @@ -674,7 +703,11 @@ export function ToastProvider({

{msg ? ( -

+

{msg}

) : null} diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx index 3b2cc17..22909b6 100644 --- a/frontend/src/components/ui/TrainingTab.tsx +++ b/frontend/src/components/ui/TrainingTab.tsx @@ -763,10 +763,29 @@ function TrainingStageOverlay(props: { text?: string progress?: number backgroundUrl?: string + visible?: boolean }) { const progress = clampPercent(props.progress ?? 0) const isTraining = props.mode === 'training' const hasBackground = !isTraining && Boolean(props.backgroundUrl) + const visible = props.visible ?? true + + const [backgroundVisible, setBackgroundVisible] = useState(false) + + useEffect(() => { + if (!props.backgroundUrl || isTraining) { + setBackgroundVisible(false) + return + } + + setBackgroundVisible(false) + + const frame = window.requestAnimationFrame(() => { + setBackgroundVisible(true) + }) + + return () => window.cancelAnimationFrame(frame) + }, [props.backgroundUrl, isTraining]) const title = isTraining ? 'Training läuft…' : 'Analyse läuft…' const fallbackText = isTraining @@ -774,15 +793,26 @@ function TrainingStageOverlay(props: { : 'Bild wird erstellt und analysiert. Bitte warten.' return ( -
+
{hasBackground ? ( ) : null} @@ -2016,6 +2046,9 @@ export default function TrainingTab(props: { const [loadingPreviewLoaded, setLoadingPreviewLoaded] = useState(false) const [loadingPreviewFailed, setLoadingPreviewFailed] = useState(false) + const [stageOverlayMounted, setStageOverlayMounted] = useState(false) + const [stageOverlayVisible, setStageOverlayVisible] = useState(false) + const imageBoxRef = useRef(null) const frameImageRef = useRef(null) @@ -2762,9 +2795,7 @@ export default function TrainingTab(props: { const output = String(raw?.output || '').trim() if (!output) return false - setLoadingPreviewCandidate( - `/api/training/video-preview?output=${encodeURIComponent(output)}` - ) + setLoadingPreviewCandidate('') const detail: PendingTrainingVideoImport = { jobId: String(raw?.jobId || '').trim(), @@ -3418,7 +3449,14 @@ export default function TrainingTab(props: { }) } } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + console.error('Feedback speichern fehlgeschlagen:', e) + + const raw = e instanceof Error ? e.message : String(e) + const short = raw.length > 220 + ? `${raw.slice(0, 220).trimEnd()}…` + : raw + + setError(`Feedback konnte nicht gespeichert werden. ${short}`) } finally { setSaving(false) } @@ -4693,12 +4731,19 @@ export default function TrainingTab(props: { smDvh: 60, lgDvh: 78, lgPx: 820, + + // Platz für: + // - Speichern/Überspringen + // - Bild vergrößern/Normal anzeigen + // - Abstände + bottomReservePx: 158, } : { baseDvh: 44, smDvh: 52, lgDvh: 64, lgPx: 680, + bottomReservePx: 138, } const imageStageStyle = { @@ -4706,7 +4751,7 @@ export default function TrainingTab(props: { '--image-stage-max-h': `${imageStageLimits.baseDvh}dvh`, '--image-stage-max-h-sm': `${imageStageLimits.smDvh}dvh`, - '--image-stage-max-h-lg': `min(${imageStageLimits.lgDvh}dvh, ${imageStageLimits.lgPx}px)`, + '--image-stage-max-h-lg': `min(calc(100dvh - ${imageStageLimits.bottomReservePx}px - env(safe-area-inset-bottom)), ${imageStageLimits.lgDvh}dvh, ${imageStageLimits.lgPx}px)`, '--image-stage-w': `${imageStageLimits.baseDvh * imageAspectRatio}dvh`, '--image-stage-w-sm': `${imageStageLimits.smDvh * imageAspectRatio}dvh`, @@ -4739,6 +4784,31 @@ export default function TrainingTab(props: { ? analysisProgress : 100 + const stageOverlayFadeMs = 300 + + useEffect(() => { + if (stageBusy) { + setStageOverlayMounted(true) + + const frame = window.requestAnimationFrame(() => { + setStageOverlayVisible(true) + }) + + return () => window.cancelAnimationFrame(frame) + } + + setStageOverlayVisible(false) + + const timer = window.setTimeout(() => { + setStageOverlayMounted(false) + }, stageOverlayFadeMs) + + return () => window.clearTimeout(timer) + }, [stageBusy]) + + const renderStageOverlay = stageBusy || stageOverlayMounted + const stageOverlayIsVisible = stageBusy && stageOverlayVisible + return (
{loadingPreviewUrl ? ( @@ -4933,6 +5003,8 @@ export default function TrainingTab(props: { className={[ 'block h-auto min-h-0 max-h-full max-w-full rounded-md object-contain', 'select-none', + 'transition-opacity duration-500 ease-out will-change-opacity motion-reduce:transition-none', + frameImageLoaded ? 'opacity-100' : 'opacity-0', imageTouchClass, '[-webkit-user-drag:none] [-webkit-touch-callout:none]', ].join(' ')} @@ -5358,11 +5430,12 @@ export default function TrainingTab(props: {
)} - {stageBusy ? ( + {renderStageOverlay ? ( @@ -5461,7 +5534,12 @@ export default function TrainingTab(props: {
-
+