bugfixes and ui optimizations

This commit is contained in:
Linrador 2026-05-15 11:14:06 +02:00
parent 091f143384
commit fd3442500c
6 changed files with 310 additions and 38 deletions

1
backend/build.bat Normal file
View File

@ -0,0 +1 @@
go build -ldflags="-H=windowsgui" -o nsfwapp.exe

View File

@ -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>__<original-file.mp4>
//
// 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)
}

View File

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

View File

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

View File

@ -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({
</p>
{msg ? (
<p className="mt-0.5 text-sm leading-5 text-gray-600 dark:text-gray-300 break-words">
<p
className="mt-0.5 text-sm leading-5 text-gray-600 dark:text-gray-300 break-words"
style={toastMessageClampStyle}
title={msgWasShortened ? fullMsg : undefined}
>
{msg}
</p>
) : null}
@ -674,7 +703,11 @@ export function ToastProvider({
</p>
{msg ? (
<p className="mt-0.5 text-sm leading-5 text-gray-600 dark:text-gray-300 break-words">
<p
className="mt-0.5 text-sm leading-5 text-gray-600 dark:text-gray-300 break-words"
style={toastMessageClampStyle}
title={msgWasShortened ? fullMsg : undefined}
>
{msg}
</p>
) : null}

View File

@ -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 (
<div className="absolute inset-0 z-[500] flex items-center justify-center overflow-hidden rounded-md bg-black text-center text-white">
<div
className={[
'absolute inset-0 z-[500] flex items-center justify-center overflow-hidden rounded-md bg-black text-center text-white',
'transition-opacity duration-300 ease-out will-change-opacity motion-reduce:transition-none',
visible ? 'opacity-100' : 'pointer-events-none opacity-0',
].join(' ')}
>
<div className="absolute inset-1 overflow-hidden rounded-md sm:inset-2">
{hasBackground ? (
<img
key={props.backgroundUrl}
src={props.backgroundUrl}
alt=""
aria-hidden="true"
draggable={false}
className="absolute inset-0 z-0 h-full w-full scale-105 object-cover opacity-80 blur-[1px]"
className={[
'absolute inset-0 z-0 h-full w-full scale-105 object-cover blur-[1px]',
'transition-opacity duration-500 ease-out will-change-opacity motion-reduce:transition-none',
backgroundVisible ? 'opacity-80' : 'opacity-0',
].join(' ')}
/>
) : 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<HTMLDivElement | null>(null)
const frameImageRef = useRef<HTMLImageElement | null>(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 (
<div className="min-h-full lg:h-full lg:min-h-0 lg:overflow-hidden">
{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: {
</div>
)}
{stageBusy ? (
{renderStageOverlay ? (
<TrainingStageOverlay
mode={stageOverlayMode}
text={stageOverlayText}
progress={stageOverlayProgress}
visible={stageOverlayIsVisible}
backgroundUrl={
stageOverlayMode === 'analysis'
? loadingPreviewBackgroundUrl
@ -5374,9 +5447,9 @@ export default function TrainingTab(props: {
<div
className={[
'z-10 grid grid-cols-2 gap-2',
'z-10 grid shrink-0 grid-cols-2 gap-2',
imageExpanded
? 'mt-3 shrink-0'
? 'mt-3'
: 'sticky bottom-0 mt-3 sm:static sm:mt-4',
].join(' ')}
>
@ -5461,7 +5534,12 @@ export default function TrainingTab(props: {
</div>
</div>
<div className="mt-2 hidden text-center text-xs text-gray-500 dark:text-gray-400 lg:block">
<div
className={[
'mt-2 hidden text-center text-xs text-gray-500 dark:text-gray-400',
imageExpanded ? 'lg:hidden' : 'lg:block',
].join(' ')}
>
Prüfe das Bild. Wenn die Erkennung stimmt: Passt so. Wenn nicht: korrigieren und speichern.
</div>
</section>