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: {
-
+
Prüfe das Bild. Wenn die Erkennung stimmt: „Passt so“. Wenn nicht: korrigieren und speichern.