added autodelete for low rated downloads + bugfixes

This commit is contained in:
Linrador 2026-06-08 12:06:04 +02:00
parent 7df52ba9ef
commit d95b65817f
14 changed files with 722 additions and 56 deletions

View File

@ -1007,6 +1007,8 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
autoDeleteLowRatedDownloadAfterAnalysis(ctx, outPath, rating)
appLogf( appLogf(
"✅ [analyze] done file=%q hits=%d segments=%d rating=%.1f stars=%d", "✅ [analyze] done file=%q hits=%d segments=%d rating=%.1f stars=%d",
file, file,

View File

@ -14,20 +14,22 @@ import (
) )
const ( const (
// Weniger Frames als vorher, aber Qualität pro Frame bleibt gut genug // 24 x 15 = 360 Sprite-Bilder
// für Analyse + UI. // Gesamtgröße bei 320x180: 7680 x 2700 px
previewSpriteCols = 20 previewSpriteCols = 24
previewSpriteRows = 12 previewSpriteRows = 15
previewSpriteFrameCount = previewSpriteCols * previewSpriteRows previewSpriteFrameCount = previewSpriteCols * previewSpriteRows
// Nicht kleiner machen, solange die Analyse dieselben Sprites nutzt. previewSpriteCellW = 384
// 320x180 ist für Positionen/Body/Object-Erkennung deutlich sicherer previewSpriteCellH = 216
// als 240x135.
previewSpriteCellW = 320
previewSpriteCellH = 180
// 4 ist kleiner als 3, aber noch gut genug für Analyse. // ffmpeg mjpeg:
previewSpriteJpegQ = "4" // kleiner = bessere Qualität, größere Datei.
// 2 = sehr gut, 3 = guter Kompromiss, 4 = kleiner/schwächer.
previewSpriteJpegQ = "3"
// Besser als fast_bilinear, aber langsamer.
previewSpriteScaleFlags = "lanczos"
) )
func fixedPreviewSpriteLayout() (cols, rows, count, cellW, cellH int) { func fixedPreviewSpriteLayout() (cols, rows, count, cellW, cellH int) {
@ -120,12 +122,13 @@ func generatePreviewSpriteJPG(
// robustere Filterkette // robustere Filterkette
vf := fmt.Sprintf( vf := fmt.Sprintf(
"fps=fps=1/%f:start_time=0:round=up,"+ "fps=fps=1/%f:start_time=0:round=up,"+
"scale=%d:%d:force_original_aspect_ratio=decrease:flags=fast_bilinear,"+ "scale=%d:%d:force_original_aspect_ratio=decrease:flags=%s,"+
"pad=%d:%d:(ow-iw)/2:(oh-ih)/2:black,"+ "pad=%d:%d:(ow-iw)/2:(oh-ih)/2:black,"+
"setsar=1,"+ "setsar=1,"+
"tile=%dx%d:margin=0:padding=0", "tile=%dx%d:margin=0:padding=0",
stepSec, stepSec,
cellW, cellH, cellW, cellH,
previewSpriteScaleFlags,
cellW, cellH, cellW, cellH,
cols, rows, cols, rows,
) )

View File

@ -14,6 +14,8 @@
"autoDeleteSmallDownloads": true, "autoDeleteSmallDownloads": true,
"autoDeleteSmallDownloadsBelowMB": 300, "autoDeleteSmallDownloadsBelowMB": 300,
"autoDeleteSmallDownloadsKeepFavorites": true, "autoDeleteSmallDownloadsKeepFavorites": true,
"autoDeleteLowRatedDownloads": false,
"autoDeleteLowRatedDownloadsMaxStars": 3,
"lowDiskPauseBelowGB": 5, "lowDiskPauseBelowGB": 5,
"blurPreviews": false, "blurPreviews": false,
"teaserPlayback": "all", "teaserPlayback": "all",

View File

@ -34,6 +34,12 @@ type RecorderSettings struct {
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"` AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"` AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
AutoDeleteSmallDownloadsKeepFavorites bool `json:"autoDeleteSmallDownloadsKeepFavorites"` AutoDeleteSmallDownloadsKeepFavorites bool `json:"autoDeleteSmallDownloadsKeepFavorites"`
// Wenn aktiv, werden Downloads nach erfolgreicher Analyse gelöscht,
// wenn ihr Rating <= AutoDeleteLowRatedDownloadsMaxStars ist.
AutoDeleteLowRatedDownloads bool `json:"autoDeleteLowRatedDownloads"`
AutoDeleteLowRatedDownloadsMaxStars int `json:"autoDeleteLowRatedDownloadsMaxStars"`
LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"` LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"`
BlurPreviews bool `json:"blurPreviews"` BlurPreviews bool `json:"blurPreviews"`
@ -78,6 +84,10 @@ var (
AutoDeleteSmallDownloads: false, AutoDeleteSmallDownloads: false,
AutoDeleteSmallDownloadsBelowMB: 50, AutoDeleteSmallDownloadsBelowMB: 50,
AutoDeleteSmallDownloadsKeepFavorites: true, AutoDeleteSmallDownloadsKeepFavorites: true,
AutoDeleteLowRatedDownloads: false,
AutoDeleteLowRatedDownloadsMaxStars: 3,
LowDiskPauseBelowGB: 5, LowDiskPauseBelowGB: 5,
BlurPreviews: false, BlurPreviews: false,
@ -150,6 +160,14 @@ func loadSettings() {
if s.AutoDeleteSmallDownloadsBelowMB > 100_000 { if s.AutoDeleteSmallDownloadsBelowMB > 100_000 {
s.AutoDeleteSmallDownloadsBelowMB = 100_000 s.AutoDeleteSmallDownloadsBelowMB = 100_000
} }
if s.AutoDeleteLowRatedDownloadsMaxStars < 1 {
s.AutoDeleteLowRatedDownloadsMaxStars = 3
}
if s.AutoDeleteLowRatedDownloadsMaxStars > 5 {
s.AutoDeleteLowRatedDownloadsMaxStars = 5
}
if s.LowDiskPauseBelowGB < 1 { if s.LowDiskPauseBelowGB < 1 {
s.LowDiskPauseBelowGB = 1 s.LowDiskPauseBelowGB = 1
} }
@ -253,6 +271,10 @@ type RecorderSettingsPublic struct {
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"` AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"` AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
AutoDeleteSmallDownloadsKeepFavorites bool `json:"autoDeleteSmallDownloadsKeepFavorites"` AutoDeleteSmallDownloadsKeepFavorites bool `json:"autoDeleteSmallDownloadsKeepFavorites"`
AutoDeleteLowRatedDownloads bool `json:"autoDeleteLowRatedDownloads"`
AutoDeleteLowRatedDownloadsMaxStars int `json:"autoDeleteLowRatedDownloadsMaxStars"`
LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"` LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"`
BlurPreviews bool `json:"blurPreviews"` BlurPreviews bool `json:"blurPreviews"`
@ -293,6 +315,10 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
AutoDeleteSmallDownloads: s.AutoDeleteSmallDownloads, AutoDeleteSmallDownloads: s.AutoDeleteSmallDownloads,
AutoDeleteSmallDownloadsBelowMB: s.AutoDeleteSmallDownloadsBelowMB, AutoDeleteSmallDownloadsBelowMB: s.AutoDeleteSmallDownloadsBelowMB,
AutoDeleteSmallDownloadsKeepFavorites: s.AutoDeleteSmallDownloadsKeepFavorites, AutoDeleteSmallDownloadsKeepFavorites: s.AutoDeleteSmallDownloadsKeepFavorites,
AutoDeleteLowRatedDownloads: s.AutoDeleteLowRatedDownloads,
AutoDeleteLowRatedDownloadsMaxStars: s.AutoDeleteLowRatedDownloadsMaxStars,
LowDiskPauseBelowGB: s.LowDiskPauseBelowGB, LowDiskPauseBelowGB: s.LowDiskPauseBelowGB,
BlurPreviews: s.BlurPreviews, BlurPreviews: s.BlurPreviews,
@ -368,6 +394,12 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
if in.AutoDeleteSmallDownloadsBelowMB > 100_000 { if in.AutoDeleteSmallDownloadsBelowMB > 100_000 {
in.AutoDeleteSmallDownloadsBelowMB = 100_000 in.AutoDeleteSmallDownloadsBelowMB = 100_000
} }
if in.AutoDeleteLowRatedDownloadsMaxStars < 1 {
in.AutoDeleteLowRatedDownloadsMaxStars = 3
}
if in.AutoDeleteLowRatedDownloadsMaxStars > 5 {
in.AutoDeleteLowRatedDownloadsMaxStars = 5
}
if in.LowDiskPauseBelowGB < 1 { if in.LowDiskPauseBelowGB < 1 {
in.LowDiskPauseBelowGB = 1 in.LowDiskPauseBelowGB = 1
} }
@ -463,6 +495,8 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
next.AutoDeleteSmallDownloads = in.AutoDeleteSmallDownloads next.AutoDeleteSmallDownloads = in.AutoDeleteSmallDownloads
next.AutoDeleteSmallDownloadsBelowMB = in.AutoDeleteSmallDownloadsBelowMB next.AutoDeleteSmallDownloadsBelowMB = in.AutoDeleteSmallDownloadsBelowMB
next.AutoDeleteSmallDownloadsKeepFavorites = in.AutoDeleteSmallDownloadsKeepFavorites next.AutoDeleteSmallDownloadsKeepFavorites = in.AutoDeleteSmallDownloadsKeepFavorites
next.AutoDeleteLowRatedDownloads = in.AutoDeleteLowRatedDownloads
next.AutoDeleteLowRatedDownloadsMaxStars = in.AutoDeleteLowRatedDownloadsMaxStars
next.LowDiskPauseBelowGB = in.LowDiskPauseBelowGB next.LowDiskPauseBelowGB = in.LowDiskPauseBelowGB
next.BlurPreviews = in.BlurPreviews next.BlurPreviews = in.BlurPreviews

View File

@ -942,6 +942,9 @@ func runGenerateMissingAssetsJob(jobID string, ctx context.Context) {
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen") publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen")
} else if assetsTaskTruthForVideo(id, it.path).AnalyzeReady { } else if assetsTaskTruthForVideo(id, it.path).AnalyzeReady {
publishAssetsTaskPhase(it.name, "enrich", "analyze", "done", "Analyse") publishAssetsTaskPhase(it.name, "enrich", "analyze", "done", "Analyse")
// Nach erfolgreicher Hintergrund-Analyse optional nach Rating löschen.
autoDeleteLowRatedDownloadAfterAnalysis(fileCtx, it.path, nil)
} else { } else {
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen") publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen")
} }

View File

@ -28,6 +28,7 @@ type cleanupResp struct {
ScannedFiles int `json:"scannedFiles"` ScannedFiles int `json:"scannedFiles"`
DeletedFiles int `json:"deletedFiles"` DeletedFiles int `json:"deletedFiles"`
DeletedBrokenFiles int `json:"deletedBrokenFiles"` DeletedBrokenFiles int `json:"deletedBrokenFiles"`
DeletedLowRatedFiles int `json:"deletedLowRatedFiles"`
SkippedFiles int `json:"skippedFiles"` SkippedFiles int `json:"skippedFiles"`
DeletedBytes int64 `json:"deletedBytes"` DeletedBytes int64 `json:"deletedBytes"`
DeletedBytesHuman string `json:"deletedBytesHuman"` DeletedBytesHuman string `json:"deletedBytesHuman"`
@ -162,6 +163,8 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
} }
mb := int(s.AutoDeleteSmallDownloadsBelowMB) mb := int(s.AutoDeleteSmallDownloadsBelowMB)
deleteLowRated := s.AutoDeleteLowRatedDownloads
maxStars := clampAutoDeleteRatingStars(s.AutoDeleteLowRatedDownloadsMaxStars)
var req cleanupReq var req cleanupReq
if r.Body != nil { if r.Body != nil {
@ -200,7 +203,7 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
publishTaskState() publishTaskState()
go func(jobID string, ctx context.Context, doneAbs string, recordAbs string, mb int) { go func(jobID string, ctx context.Context, doneAbs string, recordAbs string, mb int, deleteLowRated bool, maxStars int) {
if err := acquireExclusiveTask(ctx); err != nil { if err := acquireExclusiveTask(ctx); err != nil {
finishCleanupJob(jobID, "", err) finishCleanupJob(jobID, "", err)
return return
@ -216,9 +219,9 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
resp := cleanupResp{} resp := cleanupResp{}
if mb > 0 { if mb > 0 || deleteLowRated {
threshold := int64(mb) * 1024 * 1024 threshold := int64(mb) * 1024 * 1024
if err := cleanupSmallFiles(ctx, jobID, doneAbs, threshold, &resp); err != nil { if err := cleanupSmallFiles(ctx, jobID, doneAbs, threshold, deleteLowRated, maxStars, &resp); err != nil {
finishCleanupJob(jobID, "", err) finishCleanupJob(jobID, "", err)
return return
} }
@ -281,7 +284,7 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
cleanupProgressText(resp), cleanupProgressText(resp),
nil, nil,
) )
}(jobID, ctx, doneAbs, recordAbs, mb) }(jobID, ctx, doneAbs, recordAbs, mb, deleteLowRated, maxStars)
writeJSON(w, http.StatusOK, st) writeJSON(w, http.StatusOK, st)
return return
@ -319,6 +322,10 @@ func cleanupProgressText(resp cleanupResp) string {
parts = append(parts, fmt.Sprintf("defekt: %d", resp.DeletedBrokenFiles)) parts = append(parts, fmt.Sprintf("defekt: %d", resp.DeletedBrokenFiles))
} }
if resp.DeletedLowRatedFiles > 0 {
parts = append(parts, fmt.Sprintf("Rating: %d", resp.DeletedLowRatedFiles))
}
if resp.DeletedRecordOrphans > 0 { if resp.DeletedRecordOrphans > 0 {
parts = append(parts, fmt.Sprintf("Records: %d", resp.DeletedRecordOrphans)) parts = append(parts, fmt.Sprintf("Records: %d", resp.DeletedRecordOrphans))
} }
@ -635,7 +642,154 @@ func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs
return nil return nil
} }
func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, threshold int64, resp *cleanupResp) error { func clampAutoDeleteRatingStars(v int) int {
if v < 1 {
return 3
}
if v > 5 {
return 5
}
return v
}
func ratingStarsForVideoPath(videoPath string) (int, bool, error) {
videoPath = strings.TrimSpace(videoPath)
if videoPath == "" {
return 0, false, nil
}
base := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath))
assetID := stripHotPrefix(base)
if strings.TrimSpace(assetID) == "" {
return 0, false, nil
}
metaPath, err := generatedMetaFile(assetID)
if err != nil {
return 0, false, err
}
m, ok := readVideoMetaLoose(metaPath)
if !ok || m == nil {
return 0, false, nil
}
ai := pickAnalysisAIForGoal(m.Analysis, "highlights")
if ai == nil || ai.Rating == nil {
return 0, false, nil
}
stars := ai.Rating.Stars
if stars <= 0 && ai.Rating.Score > 0 {
stars = starsFromHighlightScore(ai.Rating.Score)
}
if stars < 1 || stars > 5 {
return 0, false, nil
}
return stars, true, nil
}
func deleteDoneVideoAndGenerated(videoPath string) (int64, error) {
videoPath = filepath.Clean(strings.TrimSpace(videoPath))
if videoPath == "" {
return 0, nil
}
ext := strings.ToLower(filepath.Ext(videoPath))
if ext != ".mp4" && ext != ".ts" {
return 0, nil
}
fi, err := os.Stat(videoPath)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
if fi == nil || fi.IsDir() {
return 0, nil
}
name := filepath.Base(videoPath)
base := strings.TrimSuffix(name, filepath.Ext(name))
assetID := stripHotPrefix(base)
// Stoppt ggf. laufende Asset-/Enrich-Tasks für genau diese Datei,
// damit Windows-Dateihandles nicht blockieren.
releaseFileForMutation(name)
if err := removeWithRetry(videoPath); err != nil && !os.IsNotExist(err) {
return 0, err
}
if strings.TrimSpace(assetID) != "" {
removeGeneratedForID(assetID)
}
purgeDurationCacheForPath(videoPath)
return fi.Size(), nil
}
func autoDeleteLowRatedDownloadAfterAnalysis(ctx context.Context, videoPath string, rating *aiRatingMeta) {
select {
case <-ctx.Done():
return
default:
}
s := getSettings()
if !s.AutoDeleteLowRatedDownloads {
return
}
maxStars := clampAutoDeleteRatingStars(s.AutoDeleteLowRatedDownloadsMaxStars)
stars := 0
if rating != nil {
stars = rating.Stars
if stars <= 0 && rating.Score > 0 {
stars = starsFromHighlightScore(rating.Score)
}
}
// Falls kein Rating direkt übergeben wurde, aus der gespeicherten meta.json lesen.
// Das brauchen wir für Asset-/Regenerate-Jobs, weil dort die Analyse erst geprüft
// und danach gelöscht werden soll.
if stars < 1 || stars > 5 {
metaStars, ok, err := ratingStarsForVideoPath(videoPath)
if err != nil {
appLogln("⚠️ [rating-delete] rating aus meta konnte nicht gelesen werden:", filepath.Base(videoPath), err)
return
}
if ok {
stars = metaStars
}
}
if stars < 1 || stars > 5 || stars > maxStars {
return
}
deletedBytes, err := deleteDoneVideoAndGenerated(videoPath)
if err != nil {
appLogln("⚠️ [rating-delete] konnte Download nicht löschen:", filepath.Base(videoPath), err)
return
}
appLogf(
"🗑️ [rating-delete] gelöscht nach Analyse: %s stars=%d threshold<=%d bytes=%s",
filepath.Base(videoPath),
stars,
maxStars,
formatBytesSI(deletedBytes),
)
notifyDoneChanged()
}
func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, threshold int64, deleteLowRated bool, maxStars int, resp *cleanupResp) error {
isCandidate := func(name string) bool { isCandidate := func(name string) bool {
low := strings.ToLower(name) low := strings.ToLower(name)
if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") { if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") {
@ -751,9 +905,24 @@ func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, thresh
} }
deleteReason := "" deleteReason := ""
if c.size < threshold { if threshold > 0 && c.size < threshold {
deleteReason = "small" deleteReason = "small"
} else { } else if deleteLowRated {
stars, ok, err := ratingStarsForVideoPath(c.path)
if err != nil {
appLogln("⚠️ cleanup rating check failed:", filepath.Base(c.path), err)
} else if ok && stars <= maxStars {
deleteReason = "rating"
appLogf(
"🧹 cleanup deleting low-rated video: %s stars=%d threshold<=%d",
filepath.Base(c.path),
stars,
maxStars,
)
}
}
if deleteReason == "" {
broken, why := cleanupVideoLooksBroken(ctx, c.path) broken, why := cleanupVideoLooksBroken(ctx, c.path)
if broken { if broken {
deleteReason = "broken" deleteReason = "broken"
@ -762,26 +931,21 @@ func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, thresh
} }
if deleteReason != "" { if deleteReason != "" {
base := strings.TrimSuffix(filepath.Base(c.path), filepath.Ext(c.path)) deletedBytes, derr := deleteDoneVideoAndGenerated(c.path)
id := stripHotPrefix(base) if derr == nil {
// Stoppt ggf. laufende Asset-/Enrich-Tasks für genau diese Datei,
// damit Windows-Dateihandles nicht blockieren.
releaseFileForMutation(c.name)
if derr := removeWithRetry(c.path); derr == nil || os.IsNotExist(derr) {
resp.DeletedFiles++ resp.DeletedFiles++
if deletedBytes > 0 {
resp.DeletedBytes += deletedBytes
} else {
resp.DeletedBytes += c.size resp.DeletedBytes += c.size
}
if deleteReason == "broken" { switch deleteReason {
case "broken":
resp.DeletedBrokenFiles++ resp.DeletedBrokenFiles++
case "rating":
resp.DeletedLowRatedFiles++
} }
if strings.TrimSpace(id) != "" {
removeGeneratedForID(id)
}
purgeDurationCacheForPath(c.path)
} else { } else {
resp.ErrorCount++ resp.ErrorCount++
} }

View File

@ -511,6 +511,9 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "Analyse", "") publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "Analyse", "")
// Nach erfolgreicher Regenerate-Analyse optional nach Rating löschen.
autoDeleteLowRatedDownloadAfterAnalysis(ctx, videoPath, nil)
finishRegenerateAssetsJob(file, "done", "") finishRegenerateAssetsJob(file, "done", "")
setRegenerateAssetsTaskDone(file, "Fertig") setRegenerateAssetsTaskDone(file, "Fertig")
removeRegenerateAssetsJobLater(file, 3*time.Second) removeRegenerateAssetsJobLater(file, 3*time.Second)

View File

@ -0,0 +1,34 @@
'use client'
import LoadingSpinner from './LoadingSpinner'
export default function DeletingPreviewOverlay({
label = 'Wird gelöscht',
className = 'rounded-t-lg',
}: {
label?: string
className?: string
}) {
return (
<div
className={[
'pointer-events-none absolute inset-0 z-[95] grid place-items-center bg-black/60 text-white backdrop-blur-[2px]',
className,
].join(' ')}
aria-live="polite"
aria-label={label}
>
<div className="flex flex-col items-center gap-2 rounded-xl bg-black/35 px-4 py-3 shadow-lg ring-1 ring-white/15">
<LoadingSpinner
size="md"
className="text-white"
srLabel={label}
/>
<div className="text-xs font-semibold tracking-wide text-white/95">
{label}
</div>
</div>
</div>
)
}

View File

@ -46,6 +46,7 @@ import SpritePreloader from './SpritePreloader'
import FinishedDownloadsDesktopCard from './FinishedDownloadsDesktopCard' import FinishedDownloadsDesktopCard from './FinishedDownloadsDesktopCard'
import PreviewRatingOverlay from './PreviewRatingOverlay' import PreviewRatingOverlay from './PreviewRatingOverlay'
import PreviewMetaOverlay from './PreviewMetaOverlay' import PreviewMetaOverlay from './PreviewMetaOverlay'
import DeletingPreviewOverlay from './DeletingPreviewOverlay'
type SwipeRefs = { type SwipeRefs = {
@ -837,6 +838,10 @@ function FinishedDownloadsCardsView({
/> />
</div> </div>
) : null} ) : null}
{deletingKeys.has(k) ? (
<DeletingPreviewOverlay />
) : null}
</div> </div>
</div> </div>
@ -1277,6 +1282,16 @@ function FinishedDownloadsCardsView({
renderRole: 'top', renderRole: 'top',
}) })
const selectionModeActive = selectionStore.hasAnySelection()
const canSpeedHold =
!inlineActive &&
!busy &&
(
allowTeaserAnimation ||
(selectionModeActive && teaserState.mode !== 'still')
)
return ( return (
<div <div
key={`${k}__top`} key={`${k}__top`}
@ -1295,7 +1310,7 @@ function FinishedDownloadsCardsView({
doubleTapMs={360} doubleTapMs={360}
doubleTapMaxMovePx={48} doubleTapMaxMovePx={48}
pressDelayMs={220} pressDelayMs={220}
enablePressHold={!inlineActive && mobileTopTeaserEnabled && allowTeaserAnimation && !busy} enablePressHold={canSpeedHold}
onDoubleTap={async () => { onDoubleTap={async () => {
if (isHot) return if (isHot) return
await onToggleHot?.(j) await onToggleHot?.(j)
@ -1331,6 +1346,11 @@ function FinishedDownloadsCardsView({
}} }}
onPressStart={(info) => { onPressStart={(info) => {
if (info.pointerType === 'mouse' && info.button !== 0) return if (info.pointerType === 'mouse' && info.button !== 0) return
if (!canSpeedHold) return
// Wichtig im Auswahl-Modus:
// Falls der Teaser noch nicht läuft, beim Gedrückthalten aktivieren.
setMobileTopTeaserEnabled(true)
setSpeedHoldKey(k) setSpeedHoldKey(k)
}} }}
onPressEnd={() => { onPressEnd={() => {

View File

@ -32,6 +32,7 @@ import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
import SpritePreloader from './SpritePreloader' import SpritePreloader from './SpritePreloader'
import PreviewRatingOverlay from './PreviewRatingOverlay' import PreviewRatingOverlay from './PreviewRatingOverlay'
import PreviewMetaOverlay from './PreviewMetaOverlay' import PreviewMetaOverlay from './PreviewMetaOverlay'
import DeletingPreviewOverlay from './DeletingPreviewOverlay'
function normalizeDurationSeconds(value: unknown): number | undefined { function normalizeDurationSeconds(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
@ -548,6 +549,10 @@ function FinishedDownloadsGalleryCardInner({
/> />
</div> </div>
) : null} ) : null}
{isDeleting ? (
<DeletingPreviewOverlay />
) : null}
</div> </div>
</div> </div>

View File

@ -1314,7 +1314,7 @@ export function readRatingSegments(metaRaw: unknown): RatingSegment[] {
const spriteMeta = readPreviewSpriteMeta(metaRaw) const spriteMeta = readPreviewSpriteMeta(metaRaw)
const durationSeconds = readMetaDurationSeconds(metaRaw) const durationSeconds = readMetaDurationSeconds(metaRaw)
return rawSegments const segments = rawSegments
.map((raw, index): RatingSegment | null => { .map((raw, index): RatingSegment | null => {
if (!isPlainObject(raw)) return null if (!isPlainObject(raw)) return null
@ -1358,6 +1358,32 @@ export function readRatingSegments(metaRaw: unknown): RatingSegment[] {
} }
}) })
.filter(isRatingSegment) .filter(isRatingSegment)
// Priorität 1:
// Sobald Positionen erkannt wurden, zeigen wir als Segmente nur Positionen.
const positionSegments = segments.filter((segment) => (
segmentVisualKind(segment) === 'position'
))
if (positionSegments.length > 0) {
return positionSegments
}
// Priorität 2:
// Wenn keine Positionen erkannt wurden, fallback auf Kleidung / Körperteile.
const bodyOrClothingSegments = segments.filter((segment) => {
const kind = segmentVisualKind(segment)
return kind === 'clothing' || kind === 'body'
})
if (bodyOrClothingSegments.length > 0) {
return bodyOrClothingSegments
}
// Fallback:
// Falls wirklich weder Position noch Kleidung/Körperteil vorhanden ist,
// lassen wir die ursprünglichen Segmente stehen, damit die Liste nicht leer wird.
return segments
} }
type SegmentVisualKind = 'position' | 'toy' | 'clothing' | 'body' | 'default' type SegmentVisualKind = 'position' | 'toy' | 'clothing' | 'body' | 'default'

View File

@ -28,6 +28,8 @@ type RecorderSettings = {
autoDeleteSmallDownloads?: boolean autoDeleteSmallDownloads?: boolean
autoDeleteSmallDownloadsBelowMB?: number autoDeleteSmallDownloadsBelowMB?: number
autoDeleteSmallDownloadsKeepFavorites?: boolean autoDeleteSmallDownloadsKeepFavorites?: boolean
autoDeleteLowRatedDownloads?: boolean
autoDeleteLowRatedDownloadsMaxStars?: number
blurPreviews?: boolean blurPreviews?: boolean
teaserPlayback?: 'still' | 'hover' | 'all' teaserPlayback?: 'still' | 'hover' | 'all'
teaserAudio?: boolean teaserAudio?: boolean
@ -224,10 +226,12 @@ function SettingsSection(props: {
type="button" type="button"
onClick={toggleOpen} onClick={toggleOpen}
className={[ className={[
'flex w-full items-start justify-between gap-4 bg-white/95 p-4 text-left transition hover:bg-gray-50', 'flex w-full items-start justify-between gap-4 p-4 text-left transition',
'focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500', 'focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500',
'dark:bg-gray-950/70 dark:hover:bg-white/5 dark:focus-visible:ring-indigo-400', 'dark:focus-visible:ring-indigo-400',
open ? 'rounded-t-2xl rounded-b-none' : 'rounded-2xl', open
? 'rounded-t-2xl rounded-b-none bg-gray-50 hover:bg-gray-100/80 dark:bg-white/5 dark:hover:bg-white/10'
: 'rounded-2xl bg-white hover:bg-gray-50 dark:bg-gray-950/70 dark:hover:bg-white/5',
].join(' ')} ].join(' ')}
aria-expanded={open} aria-expanded={open}
> >
@ -290,6 +294,8 @@ const DEFAULTS: RecorderSettings = {
autoDeleteSmallDownloads: true, autoDeleteSmallDownloads: true,
autoDeleteSmallDownloadsBelowMB: 200, autoDeleteSmallDownloadsBelowMB: 200,
autoDeleteSmallDownloadsKeepFavorites: true, autoDeleteSmallDownloadsKeepFavorites: true,
autoDeleteLowRatedDownloads: false,
autoDeleteLowRatedDownloadsMaxStars: 3,
blurPreviews: false, blurPreviews: false,
teaserPlayback: 'hover', teaserPlayback: 'hover',
teaserAudio: false, teaserAudio: false,
@ -501,6 +507,131 @@ function mapCleanupJobToTask(job: any): TaskItem {
} }
} }
const SETTINGS_RATING_STAR_PATH =
'M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 9.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z'
function settingsRatingTone(stars: number) {
switch (stars) {
case 1:
return 'text-yellow-300 opacity-55'
case 2:
return 'text-yellow-300 opacity-65'
case 3:
return 'text-yellow-300 opacity-80'
case 4:
return 'text-yellow-300 opacity-95'
default:
return 'text-yellow-200 opacity-100'
}
}
function RatingThresholdStar({
stars,
filled,
}: {
stars: number
filled: boolean
}) {
return (
<span className="relative inline-grid h-7 w-7 shrink-0 place-items-center leading-none">
<svg
viewBox="0 0 20 20"
aria-hidden="true"
className={[
'h-7 w-7 shrink-0 transition duration-150',
filled
? `fill-current ${settingsRatingTone(stars)}`
: 'fill-transparent text-gray-300 dark:text-white/25',
].join(' ')}
style={{
filter: filled
? 'drop-shadow(0 1px 2px rgba(0,0,0,0.75)) drop-shadow(0 0 3px rgba(0,0,0,0.35))'
: 'drop-shadow(0 1px 1px rgba(0,0,0,0.22))',
}}
>
<path
d={SETTINGS_RATING_STAR_PATH}
stroke={filled ? 'rgba(0,0,0,0.75)' : 'currentColor'}
strokeWidth="1.1"
paintOrder="stroke fill"
/>
<text
x="10"
y="11.35"
textAnchor="middle"
dominantBaseline="middle"
fontSize="6.4"
fontWeight="900"
className={filled ? 'fill-white' : 'fill-gray-500 dark:fill-white/55'}
style={{
paintOrder: 'stroke fill',
stroke: filled ? 'rgba(0,0,0,0.9)' : 'transparent',
strokeWidth: filled ? 0.75 : 0,
}}
>
{stars}
</text>
</svg>
</span>
)
}
function RatingThresholdStars({
value,
disabled,
onChange,
}: {
value: number | undefined
disabled?: boolean
onChange: (stars: number) => void
}) {
const selected = Math.max(1, Math.min(5, Math.round(Number(value ?? 3))))
return (
<div className="text-right">
<div
role="radiogroup"
aria-label="Sterne-Grenze"
className={[
'flex flex-wrap items-center justify-end gap-1',
disabled ? 'pointer-events-none opacity-50' : '',
].join(' ')}
>
{[1, 2, 3, 4, 5].map((stars) => {
const filled = stars <= selected
const checked = stars === selected
return (
<button
key={stars}
type="button"
role="radio"
aria-checked={checked}
disabled={disabled}
title={`Bis einschließlich ${stars} Stern${stars === 1 ? '' : 'e'} löschen`}
onClick={() => onChange(stars)}
className={[
'rounded-md p-0.5 transition',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-950',
disabled
? 'cursor-not-allowed'
: 'hover:scale-110 active:scale-95',
].join(' ')}
>
<RatingThresholdStar stars={stars} filled={filled} />
</button>
)
})}
</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Löscht bis einschließlich {selected} Stern{selected === 1 ? '' : 'e'}.
</div>
</div>
)
}
export default function RecorderSettings({ onAssetsGenerated }: Props) { export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [value, setValue] = useState<RecorderSettings>(DEFAULTS) const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@ -688,6 +819,10 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
autoDeleteSmallDownloadsBelowMB: data.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB, autoDeleteSmallDownloadsBelowMB: data.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB,
autoDeleteSmallDownloadsKeepFavorites: autoDeleteSmallDownloadsKeepFavorites:
(data as any).autoDeleteSmallDownloadsKeepFavorites ?? DEFAULTS.autoDeleteSmallDownloadsKeepFavorites, (data as any).autoDeleteSmallDownloadsKeepFavorites ?? DEFAULTS.autoDeleteSmallDownloadsKeepFavorites,
autoDeleteLowRatedDownloads:
(data as any).autoDeleteLowRatedDownloads ?? DEFAULTS.autoDeleteLowRatedDownloads,
autoDeleteLowRatedDownloadsMaxStars:
(data as any).autoDeleteLowRatedDownloadsMaxStars ?? DEFAULTS.autoDeleteLowRatedDownloadsMaxStars,
blurPreviews: data.blurPreviews ?? DEFAULTS.blurPreviews, blurPreviews: data.blurPreviews ?? DEFAULTS.blurPreviews,
teaserPlayback: (data as any).teaserPlayback ?? DEFAULTS.teaserPlayback, teaserPlayback: (data as any).teaserPlayback ?? DEFAULTS.teaserPlayback,
teaserAudio: (data as any).teaserAudio ?? DEFAULTS.teaserAudio, teaserAudio: (data as any).teaserAudio ?? DEFAULTS.teaserAudio,
@ -1084,6 +1219,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
Math.min(100_000, Math.floor(Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB))) Math.min(100_000, Math.floor(Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB)))
) )
const autoDeleteSmallDownloadsKeepFavorites = !!value.autoDeleteSmallDownloadsKeepFavorites const autoDeleteSmallDownloadsKeepFavorites = !!value.autoDeleteSmallDownloadsKeepFavorites
const autoDeleteLowRatedDownloads = !!value.autoDeleteLowRatedDownloads
const autoDeleteLowRatedDownloadsMaxStars = Math.max(
1,
Math.min(5, Math.floor(Number(value.autoDeleteLowRatedDownloadsMaxStars ?? DEFAULTS.autoDeleteLowRatedDownloadsMaxStars ?? 3)))
)
const blurPreviews = !!value.blurPreviews const blurPreviews = !!value.blurPreviews
const teaserPlayback = const teaserPlayback =
value.teaserPlayback === 'still' || value.teaserPlayback === 'all' || value.teaserPlayback === 'hover' value.teaserPlayback === 'still' || value.teaserPlayback === 'all' || value.teaserPlayback === 'hover'
@ -1126,6 +1266,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
autoDeleteSmallDownloads, autoDeleteSmallDownloads,
autoDeleteSmallDownloadsBelowMB, autoDeleteSmallDownloadsBelowMB,
autoDeleteSmallDownloadsKeepFavorites, autoDeleteSmallDownloadsKeepFavorites,
autoDeleteLowRatedDownloads,
autoDeleteLowRatedDownloadsMaxStars,
blurPreviews, blurPreviews,
teaserPlayback, teaserPlayback,
teaserAudio, teaserAudio,
@ -1201,22 +1343,35 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const mb = Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB ?? 0) const mb = Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB ?? 0)
const doneDir = (value.doneDir || DEFAULTS.doneDir).trim() const doneDir = (value.doneDir || DEFAULTS.doneDir).trim()
const deleteLowRated = !!value.autoDeleteLowRatedDownloads
const maxStars = Math.max(
1,
Math.min(5, Math.floor(Number(value.autoDeleteLowRatedDownloadsMaxStars ?? DEFAULTS.autoDeleteLowRatedDownloadsMaxStars ?? 3)))
)
if (!doneDir) { if (!doneDir) {
setErr('doneDir ist leer.') setErr('doneDir ist leer.')
return null return null
} }
if (!mb || mb <= 0) { if ((!mb || mb <= 0) && !deleteLowRated) {
setErr('Mindestgröße ist 0 es würde nichts gelöscht.') setErr('Mindestgröße ist 0 und Rating-Löschen ist deaktiviert es würde nichts gelöscht.')
return null return null
} }
const ratingLine = deleteLowRated
? `• Rating ≤ ${maxStars} Stern${maxStars === 1 ? '' : 'e'}\n`
: ''
const sizeLine = mb > 0
? `• Dateien < ${mb} MB in "${doneDir}"\n`
: ''
const ok = window.confirm( const ok = window.confirm(
`Aufräumen:\n` + `Aufräumen:\n` +
`• Löscht Dateien in "${doneDir}" < ${mb} MB (Ordner "keep" wird übersprungen)\n` + sizeLine +
`• Löscht fehlerhafte/defekte MP4/TS-Dateien, wenn sie nicht lesbar sind\n` + ratingLine +
`• Räumt Record-Reste auf: .muxing.ts, verwaiste .ts und verwaiste/0-KB .audio.m4s\n` + `Entfernt defekte Video-Dateien\n` +
`• Entfernt verwaiste Previews/Thumbs/Generated-Assets ohne passende Datei\n\n` + `Löscht Record-Reste und verwaiste Assets\n\n` +
`Fortfahren?` `Fortfahren?`
) )
if (!ok) return null if (!ok) return null
@ -1790,7 +1945,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
<Task <Task
title="Aufräumen" title="Aufräumen"
description='Löscht Dateien im doneDir kleiner als die Mindestgröße (Ordner "keep" wird übersprungen) und entfernt verwaiste Assets.' description='Löscht Dateien im doneDir kleiner als die Mindestgröße, optional Downloads bis zur gewählten Sterne-Bewertung, und entfernt verwaiste Assets.'
startLabel="Aufräumen" startLabel="Aufräumen"
startingLabel="Läuft…" startingLabel="Läuft…"
onTrigger={cleanupSmallDone} onTrigger={cleanupSmallDone}
@ -2148,6 +2303,51 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
</div> </div>
</div> </div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<LabeledSwitch
checked={!!value.autoDeleteLowRatedDownloads}
onChange={(checked) =>
setValue((v) => ({
...v,
autoDeleteLowRatedDownloads: checked,
autoDeleteLowRatedDownloadsMaxStars:
v.autoDeleteLowRatedDownloadsMaxStars ?? 3,
}))
}
label="Downloads nach Analyse nach Rating löschen"
description="Löscht fertige Downloads automatisch nach der Analyse, wenn das Rating höchstens der gewählten Sternezahl entspricht."
/>
<div
className={
'mt-2 grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center ' +
(!value.autoDeleteLowRatedDownloads ? 'opacity-50 pointer-events-none' : '')
}
>
<div className="sm:col-span-5">
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">
Sterne-Grenze
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Bis einschließlich dieser Bewertung wird gelöscht.
</div>
</div>
<div className="sm:col-span-7">
<RatingThresholdStars
value={value.autoDeleteLowRatedDownloadsMaxStars}
disabled={!value.autoDeleteLowRatedDownloads}
onChange={(stars) =>
setValue((v) => ({
...v,
autoDeleteLowRatedDownloadsMaxStars: stars,
}))
}
/>
</div>
</div>
</div>
<div className="mt-5 rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5"> <div className="mt-5 rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<LabeledSwitch <LabeledSwitch
checked={!!value.trainingRecognitionEnabled} checked={!!value.trainingRecognitionEnabled}

View File

@ -1983,6 +1983,8 @@ function annotationToCorrectionState(item: TrainingAnnotation): CorrectionState
const FEEDBACK_PAGE_SIZE = 10 const FEEDBACK_PAGE_SIZE = 10
const TRAINING_INFO_DISMISSED_STORAGE_KEY = 'training:info-dismissed-key'
export default function TrainingTab(props: { export default function TrainingTab(props: {
onTrainingRunningChange?: (running: boolean) => void onTrainingRunningChange?: (running: boolean) => void
onImageExpandedChange?: (expanded: boolean) => void onImageExpandedChange?: (expanded: boolean) => void
@ -2009,6 +2011,15 @@ export default function TrainingTab(props: {
const [trainingStatsError, setTrainingStatsError] = useState<string | null>(null) const [trainingStatsError, setTrainingStatsError] = useState<string | null>(null)
const wasTrainingRunningRef = useRef(false) const wasTrainingRunningRef = useRef(false)
const shownTrainingCompletionRef = useRef<string | null>(null) const shownTrainingCompletionRef = useRef<string | null>(null)
const [dismissedTrainingInfoKey, setDismissedTrainingInfoKey] = useState(() => {
if (typeof window === 'undefined') return ''
try {
return window.localStorage.getItem(TRAINING_INFO_DISMISSED_STORAGE_KEY) || ''
} catch {
return ''
}
})
const [importedSampleQueue, setImportedSampleQueue] = useState<TrainingSample[]>([]) const [importedSampleQueue, setImportedSampleQueue] = useState<TrainingSample[]>([])
const importedSampleQueueRef = useRef<TrainingSample[]>([]) const importedSampleQueueRef = useRef<TrainingSample[]>([])
@ -2530,6 +2541,68 @@ export default function TrainingTab(props: {
const shownTrainingStep = trainingRunning const shownTrainingStep = trainingRunning
? trainingStatus?.training?.step || trainingStep || 'Training läuft…' ? trainingStatus?.training?.step || trainingStep || 'Training läuft…'
: trainingStep : trainingStep
const drawingCursorClass =
boxInteraction?.type === 'move'
? '[@media_(hover:hover)_and_(pointer:fine)]:cursor-grabbing'
: drawingBox || (boxLabel && !uiLocked)
? '[@media_(hover:hover)_and_(pointer:fine)]:cursor-crosshair'
: ''
const trainingInfoJob = trainingStatus?.training ?? null
const trainingInfoKey = useMemo(() => {
const job = trainingInfoJob
if (!job || job.running) return ''
const finishedAt = String(job.finishedAt || '').trim()
const startedAt = String(job.startedAt || '').trim()
const message = String(job.message || job.error || '').trim()
if (finishedAt) return finishedAt
if (startedAt && message) return `${startedAt}:${message}`
return ''
}, [
trainingInfoJob?.running,
trainingInfoJob?.finishedAt,
trainingInfoJob?.startedAt,
trainingInfoJob?.message,
trainingInfoJob?.error,
])
const showTrainingInfo =
Boolean(trainingInfoKey) &&
!trainingRunning &&
dismissedTrainingInfoKey !== trainingInfoKey
const trainingInfoMessage = String(
trainingInfoJob?.message ||
trainingInfoJob?.error ||
'Training abgeschlossen.'
).trim()
const trainingInfoDurationMs = trainingDurationMs(trainingInfoJob)
const trainingInfoLooksPartial =
Boolean(trainingInfoJob?.error) ||
/übersprungen|fehlgeschlagen|abgebrochen/i.test(trainingInfoMessage)
const dismissTrainingInfo = useCallback(() => {
if (!trainingInfoKey) return
setDismissedTrainingInfoKey(trainingInfoKey)
try {
window.localStorage.setItem(
TRAINING_INFO_DISMISSED_STORAGE_KEY,
trainingInfoKey
)
} catch {
// ignore
}
}, [trainingInfoKey])
const analysisConfidence = useMemo(() => { const analysisConfidence = useMemo(() => {
return currentAnalysisConfidence(sample?.prediction) return currentAnalysisConfidence(sample?.prediction)
}, [sample?.prediction]) }, [sample?.prediction])
@ -3519,6 +3592,13 @@ export default function TrainingTab(props: {
const startTraining = useCallback(async () => { const startTraining = useCallback(async () => {
shownTrainingCompletionRef.current = null shownTrainingCompletionRef.current = null
setDismissedTrainingInfoKey('')
try {
window.localStorage.removeItem(TRAINING_INFO_DISMISSED_STORAGE_KEY)
} catch {
// ignore
}
setTraining(true) setTraining(true)
setTrainingProgress(5) setTrainingProgress(5)
@ -4302,6 +4382,98 @@ export default function TrainingTab(props: {
</div> </div>
) : null} ) : null}
{showTrainingInfo ? (
<div
className={[
'mt-3 rounded-xl p-3 ring-1',
trainingInfoLooksPartial
? 'bg-amber-50 text-amber-950 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-100 dark:ring-amber-400/30'
: 'bg-emerald-50 text-emerald-950 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-100 dark:ring-emerald-400/30',
].join(' ')}
>
<div className="flex items-start gap-3">
<div
className={[
'mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ring-1',
trainingInfoLooksPartial
? 'bg-amber-100 text-amber-700 ring-amber-200 dark:bg-amber-500/15 dark:text-amber-200 dark:ring-amber-400/30'
: 'bg-emerald-100 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-200 dark:ring-emerald-400/30',
].join(' ')}
>
{trainingInfoLooksPartial ? (
<XCircleIcon className="h-4 w-4" aria-hidden="true" />
) : (
<CheckIcon className="h-4 w-4" aria-hidden="true" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-xs font-bold">
Training-Info
</div>
<div className="mt-1 text-[11px] leading-snug opacity-90">
{trainingInfoMessage}
</div>
</div>
<button
type="button"
onClick={dismissTrainingInfo}
className="shrink-0 rounded-md px-1.5 py-0.5 text-xs font-bold opacity-60 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10"
aria-label="Training-Info ausblenden"
title="Bis zum nächsten Training ausblenden"
>
×
</button>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[10px]">
<div className="rounded-lg bg-white/70 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="font-semibold opacity-60">
Dauer
</div>
<div className="mt-0.5 font-bold">
{trainingInfoDurationMs > 0
? formatDuration(trainingInfoDurationMs)
: '—'}
</div>
</div>
<div className="rounded-lg bg-white/70 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="font-semibold opacity-60">
Feedback
</div>
<div className="mt-0.5 font-bold">
{feedbackCount}/{requiredCount}
</div>
</div>
<div className="rounded-lg bg-white/70 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="font-semibold opacity-60">
YOLO Train
</div>
<div className="mt-0.5 font-bold">
{trainingStatus?.detector?.trainCount ?? 0}/{trainingStatus?.detector?.requiredTrain ?? 20}
</div>
</div>
<div className="rounded-lg bg-white/70 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="font-semibold opacity-60">
YOLO Val
</div>
<div className="mt-0.5 font-bold">
{trainingStatus?.detector?.valCount ?? 0}/{trainingStatus?.detector?.requiredVal ?? 3}
</div>
</div>
</div>
</div>
</div>
</div>
) : null}
<div className="mt-3 grid grid-cols-2 gap-2"> <div className="mt-3 grid grid-cols-2 gap-2">
<Button <Button
size="md" size="md"
@ -4980,6 +5152,7 @@ export default function TrainingTab(props: {
? 'relative z-50 flex h-full w-full min-h-0 select-none items-center justify-center overscroll-contain transition' ? 'relative z-50 flex h-full w-full min-h-0 select-none items-center justify-center overscroll-contain transition'
: 'relative z-50 inline-flex min-h-0 max-h-full max-w-full select-none overscroll-contain transition', : 'relative z-50 inline-flex min-h-0 max-h-full max-w-full select-none overscroll-contain transition',
imageTouchClass, imageTouchClass,
drawingCursorClass,
'[-webkit-touch-callout:none] [-webkit-user-select:none] [user-select:none]', '[-webkit-touch-callout:none] [-webkit-user-select:none] [user-select:none]',
trainingRunning || loading ? 'pointer-events-none' : '', trainingRunning || loading ? 'pointer-events-none' : '',
].join(' ')} ].join(' ')}

View File

@ -114,7 +114,4 @@ body {
a:hover { a:hover {
color: #747bff; color: #747bff;
} }
button {
background-color: #f9f9f9;
}
} }