added autodelete for low rated downloads + bugfixes
This commit is contained in:
parent
7df52ba9ef
commit
d95b65817f
@ -1007,6 +1007,8 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
autoDeleteLowRatedDownloadAfterAnalysis(ctx, outPath, rating)
|
||||
|
||||
appLogf(
|
||||
"✅ [analyze] done file=%q hits=%d segments=%d rating=%.1f stars=%d",
|
||||
file,
|
||||
|
||||
@ -14,20 +14,22 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// Weniger Frames als vorher, aber Qualität pro Frame bleibt gut genug
|
||||
// für Analyse + UI.
|
||||
previewSpriteCols = 20
|
||||
previewSpriteRows = 12
|
||||
// 24 x 15 = 360 Sprite-Bilder
|
||||
// Gesamtgröße bei 320x180: 7680 x 2700 px
|
||||
previewSpriteCols = 24
|
||||
previewSpriteRows = 15
|
||||
previewSpriteFrameCount = previewSpriteCols * previewSpriteRows
|
||||
|
||||
// Nicht kleiner machen, solange die Analyse dieselben Sprites nutzt.
|
||||
// 320x180 ist für Positionen/Body/Object-Erkennung deutlich sicherer
|
||||
// als 240x135.
|
||||
previewSpriteCellW = 320
|
||||
previewSpriteCellH = 180
|
||||
previewSpriteCellW = 384
|
||||
previewSpriteCellH = 216
|
||||
|
||||
// 4 ist kleiner als 3, aber noch gut genug für Analyse.
|
||||
previewSpriteJpegQ = "4"
|
||||
// ffmpeg mjpeg:
|
||||
// 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) {
|
||||
@ -120,12 +122,13 @@ func generatePreviewSpriteJPG(
|
||||
// robustere Filterkette
|
||||
vf := fmt.Sprintf(
|
||||
"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,"+
|
||||
"setsar=1,"+
|
||||
"tile=%dx%d:margin=0:padding=0",
|
||||
stepSec,
|
||||
cellW, cellH,
|
||||
previewSpriteScaleFlags,
|
||||
cellW, cellH,
|
||||
cols, rows,
|
||||
)
|
||||
|
||||
@ -14,6 +14,8 @@
|
||||
"autoDeleteSmallDownloads": true,
|
||||
"autoDeleteSmallDownloadsBelowMB": 300,
|
||||
"autoDeleteSmallDownloadsKeepFavorites": true,
|
||||
"autoDeleteLowRatedDownloads": false,
|
||||
"autoDeleteLowRatedDownloadsMaxStars": 3,
|
||||
"lowDiskPauseBelowGB": 5,
|
||||
"blurPreviews": false,
|
||||
"teaserPlayback": "all",
|
||||
|
||||
@ -34,7 +34,13 @@ type RecorderSettings struct {
|
||||
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
|
||||
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
|
||||
AutoDeleteSmallDownloadsKeepFavorites bool `json:"autoDeleteSmallDownloadsKeepFavorites"`
|
||||
LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"`
|
||||
|
||||
// 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"`
|
||||
|
||||
BlurPreviews bool `json:"blurPreviews"`
|
||||
TeaserPlayback string `json:"teaserPlayback"` // still | hover | all
|
||||
@ -78,7 +84,11 @@ var (
|
||||
AutoDeleteSmallDownloads: false,
|
||||
AutoDeleteSmallDownloadsBelowMB: 50,
|
||||
AutoDeleteSmallDownloadsKeepFavorites: true,
|
||||
LowDiskPauseBelowGB: 5,
|
||||
|
||||
AutoDeleteLowRatedDownloads: false,
|
||||
AutoDeleteLowRatedDownloadsMaxStars: 3,
|
||||
|
||||
LowDiskPauseBelowGB: 5,
|
||||
|
||||
BlurPreviews: false,
|
||||
TeaserPlayback: "hover",
|
||||
@ -150,6 +160,14 @@ func loadSettings() {
|
||||
if 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 {
|
||||
s.LowDiskPauseBelowGB = 1
|
||||
}
|
||||
@ -253,7 +271,11 @@ type RecorderSettingsPublic struct {
|
||||
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
|
||||
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
|
||||
AutoDeleteSmallDownloadsKeepFavorites bool `json:"autoDeleteSmallDownloadsKeepFavorites"`
|
||||
LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"`
|
||||
|
||||
AutoDeleteLowRatedDownloads bool `json:"autoDeleteLowRatedDownloads"`
|
||||
AutoDeleteLowRatedDownloadsMaxStars int `json:"autoDeleteLowRatedDownloadsMaxStars"`
|
||||
|
||||
LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"`
|
||||
|
||||
BlurPreviews bool `json:"blurPreviews"`
|
||||
TeaserPlayback string `json:"teaserPlayback"`
|
||||
@ -293,7 +315,11 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
|
||||
AutoDeleteSmallDownloads: s.AutoDeleteSmallDownloads,
|
||||
AutoDeleteSmallDownloadsBelowMB: s.AutoDeleteSmallDownloadsBelowMB,
|
||||
AutoDeleteSmallDownloadsKeepFavorites: s.AutoDeleteSmallDownloadsKeepFavorites,
|
||||
LowDiskPauseBelowGB: s.LowDiskPauseBelowGB,
|
||||
|
||||
AutoDeleteLowRatedDownloads: s.AutoDeleteLowRatedDownloads,
|
||||
AutoDeleteLowRatedDownloadsMaxStars: s.AutoDeleteLowRatedDownloadsMaxStars,
|
||||
|
||||
LowDiskPauseBelowGB: s.LowDiskPauseBelowGB,
|
||||
|
||||
BlurPreviews: s.BlurPreviews,
|
||||
TeaserPlayback: s.TeaserPlayback,
|
||||
@ -368,6 +394,12 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if 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 {
|
||||
in.LowDiskPauseBelowGB = 1
|
||||
}
|
||||
@ -463,6 +495,8 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
next.AutoDeleteSmallDownloads = in.AutoDeleteSmallDownloads
|
||||
next.AutoDeleteSmallDownloadsBelowMB = in.AutoDeleteSmallDownloadsBelowMB
|
||||
next.AutoDeleteSmallDownloadsKeepFavorites = in.AutoDeleteSmallDownloadsKeepFavorites
|
||||
next.AutoDeleteLowRatedDownloads = in.AutoDeleteLowRatedDownloads
|
||||
next.AutoDeleteLowRatedDownloadsMaxStars = in.AutoDeleteLowRatedDownloadsMaxStars
|
||||
next.LowDiskPauseBelowGB = in.LowDiskPauseBelowGB
|
||||
|
||||
next.BlurPreviews = in.BlurPreviews
|
||||
|
||||
@ -942,6 +942,9 @@ func runGenerateMissingAssetsJob(jobID string, ctx context.Context) {
|
||||
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen")
|
||||
} else if assetsTaskTruthForVideo(id, it.path).AnalyzeReady {
|
||||
publishAssetsTaskPhase(it.name, "enrich", "analyze", "done", "Analyse")
|
||||
|
||||
// Nach erfolgreicher Hintergrund-Analyse optional nach Rating löschen.
|
||||
autoDeleteLowRatedDownloadAfterAnalysis(fileCtx, it.path, nil)
|
||||
} else {
|
||||
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen")
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@ type cleanupResp struct {
|
||||
ScannedFiles int `json:"scannedFiles"`
|
||||
DeletedFiles int `json:"deletedFiles"`
|
||||
DeletedBrokenFiles int `json:"deletedBrokenFiles"`
|
||||
DeletedLowRatedFiles int `json:"deletedLowRatedFiles"`
|
||||
SkippedFiles int `json:"skippedFiles"`
|
||||
DeletedBytes int64 `json:"deletedBytes"`
|
||||
DeletedBytesHuman string `json:"deletedBytesHuman"`
|
||||
@ -162,6 +163,8 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
mb := int(s.AutoDeleteSmallDownloadsBelowMB)
|
||||
deleteLowRated := s.AutoDeleteLowRatedDownloads
|
||||
maxStars := clampAutoDeleteRatingStars(s.AutoDeleteLowRatedDownloadsMaxStars)
|
||||
|
||||
var req cleanupReq
|
||||
if r.Body != nil {
|
||||
@ -200,7 +203,7 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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 {
|
||||
finishCleanupJob(jobID, "", err)
|
||||
return
|
||||
@ -216,9 +219,9 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
resp := cleanupResp{}
|
||||
|
||||
if mb > 0 {
|
||||
if mb > 0 || deleteLowRated {
|
||||
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)
|
||||
return
|
||||
}
|
||||
@ -281,7 +284,7 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
cleanupProgressText(resp),
|
||||
nil,
|
||||
)
|
||||
}(jobID, ctx, doneAbs, recordAbs, mb)
|
||||
}(jobID, ctx, doneAbs, recordAbs, mb, deleteLowRated, maxStars)
|
||||
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
return
|
||||
@ -319,6 +322,10 @@ func cleanupProgressText(resp cleanupResp) string {
|
||||
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 {
|
||||
parts = append(parts, fmt.Sprintf("Records: %d", resp.DeletedRecordOrphans))
|
||||
}
|
||||
@ -635,7 +642,154 @@ func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs
|
||||
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 {
|
||||
low := strings.ToLower(name)
|
||||
if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") {
|
||||
@ -751,9 +905,24 @@ func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, thresh
|
||||
}
|
||||
deleteReason := ""
|
||||
|
||||
if c.size < threshold {
|
||||
if threshold > 0 && c.size < threshold {
|
||||
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)
|
||||
if broken {
|
||||
deleteReason = "broken"
|
||||
@ -762,26 +931,21 @@ func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, thresh
|
||||
}
|
||||
|
||||
if deleteReason != "" {
|
||||
base := strings.TrimSuffix(filepath.Base(c.path), filepath.Ext(c.path))
|
||||
id := stripHotPrefix(base)
|
||||
|
||||
// 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) {
|
||||
deletedBytes, derr := deleteDoneVideoAndGenerated(c.path)
|
||||
if derr == nil {
|
||||
resp.DeletedFiles++
|
||||
resp.DeletedBytes += c.size
|
||||
if deletedBytes > 0 {
|
||||
resp.DeletedBytes += deletedBytes
|
||||
} else {
|
||||
resp.DeletedBytes += c.size
|
||||
}
|
||||
|
||||
if deleteReason == "broken" {
|
||||
switch deleteReason {
|
||||
case "broken":
|
||||
resp.DeletedBrokenFiles++
|
||||
case "rating":
|
||||
resp.DeletedLowRatedFiles++
|
||||
}
|
||||
|
||||
if strings.TrimSpace(id) != "" {
|
||||
removeGeneratedForID(id)
|
||||
}
|
||||
|
||||
purgeDurationCacheForPath(c.path)
|
||||
} else {
|
||||
resp.ErrorCount++
|
||||
}
|
||||
|
||||
@ -511,6 +511,9 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
|
||||
|
||||
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "Analyse", "")
|
||||
|
||||
// Nach erfolgreicher Regenerate-Analyse optional nach Rating löschen.
|
||||
autoDeleteLowRatedDownloadAfterAnalysis(ctx, videoPath, nil)
|
||||
|
||||
finishRegenerateAssetsJob(file, "done", "")
|
||||
setRegenerateAssetsTaskDone(file, "Fertig")
|
||||
removeRegenerateAssetsJobLater(file, 3*time.Second)
|
||||
|
||||
34
frontend/src/components/ui/DeletingPreviewOverlay.tsx
Normal file
34
frontend/src/components/ui/DeletingPreviewOverlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -46,6 +46,7 @@ import SpritePreloader from './SpritePreloader'
|
||||
import FinishedDownloadsDesktopCard from './FinishedDownloadsDesktopCard'
|
||||
import PreviewRatingOverlay from './PreviewRatingOverlay'
|
||||
import PreviewMetaOverlay from './PreviewMetaOverlay'
|
||||
import DeletingPreviewOverlay from './DeletingPreviewOverlay'
|
||||
|
||||
|
||||
type SwipeRefs = {
|
||||
@ -837,6 +838,10 @@ function FinishedDownloadsCardsView({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{deletingKeys.has(k) ? (
|
||||
<DeletingPreviewOverlay />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1277,6 +1282,16 @@ function FinishedDownloadsCardsView({
|
||||
renderRole: 'top',
|
||||
})
|
||||
|
||||
const selectionModeActive = selectionStore.hasAnySelection()
|
||||
|
||||
const canSpeedHold =
|
||||
!inlineActive &&
|
||||
!busy &&
|
||||
(
|
||||
allowTeaserAnimation ||
|
||||
(selectionModeActive && teaserState.mode !== 'still')
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${k}__top`}
|
||||
@ -1295,7 +1310,7 @@ function FinishedDownloadsCardsView({
|
||||
doubleTapMs={360}
|
||||
doubleTapMaxMovePx={48}
|
||||
pressDelayMs={220}
|
||||
enablePressHold={!inlineActive && mobileTopTeaserEnabled && allowTeaserAnimation && !busy}
|
||||
enablePressHold={canSpeedHold}
|
||||
onDoubleTap={async () => {
|
||||
if (isHot) return
|
||||
await onToggleHot?.(j)
|
||||
@ -1331,6 +1346,11 @@ function FinishedDownloadsCardsView({
|
||||
}}
|
||||
onPressStart={(info) => {
|
||||
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)
|
||||
}}
|
||||
onPressEnd={() => {
|
||||
|
||||
@ -32,6 +32,7 @@ import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
||||
import SpritePreloader from './SpritePreloader'
|
||||
import PreviewRatingOverlay from './PreviewRatingOverlay'
|
||||
import PreviewMetaOverlay from './PreviewMetaOverlay'
|
||||
import DeletingPreviewOverlay from './DeletingPreviewOverlay'
|
||||
|
||||
function normalizeDurationSeconds(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
||||
@ -548,6 +549,10 @@ function FinishedDownloadsGalleryCardInner({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isDeleting ? (
|
||||
<DeletingPreviewOverlay />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1314,7 +1314,7 @@ export function readRatingSegments(metaRaw: unknown): RatingSegment[] {
|
||||
const spriteMeta = readPreviewSpriteMeta(metaRaw)
|
||||
const durationSeconds = readMetaDurationSeconds(metaRaw)
|
||||
|
||||
return rawSegments
|
||||
const segments = rawSegments
|
||||
.map((raw, index): RatingSegment | null => {
|
||||
if (!isPlainObject(raw)) return null
|
||||
|
||||
@ -1358,6 +1358,32 @@ export function readRatingSegments(metaRaw: unknown): RatingSegment[] {
|
||||
}
|
||||
})
|
||||
.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'
|
||||
|
||||
@ -28,6 +28,8 @@ type RecorderSettings = {
|
||||
autoDeleteSmallDownloads?: boolean
|
||||
autoDeleteSmallDownloadsBelowMB?: number
|
||||
autoDeleteSmallDownloadsKeepFavorites?: boolean
|
||||
autoDeleteLowRatedDownloads?: boolean
|
||||
autoDeleteLowRatedDownloadsMaxStars?: number
|
||||
blurPreviews?: boolean
|
||||
teaserPlayback?: 'still' | 'hover' | 'all'
|
||||
teaserAudio?: boolean
|
||||
@ -224,11 +226,13 @@ function SettingsSection(props: {
|
||||
type="button"
|
||||
onClick={toggleOpen}
|
||||
className={[
|
||||
'flex w-full items-start justify-between gap-4 bg-white/95 p-4 text-left transition hover:bg-gray-50',
|
||||
'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',
|
||||
open ? 'rounded-t-2xl rounded-b-none' : 'rounded-2xl',
|
||||
].join(' ')}
|
||||
'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',
|
||||
'dark:focus-visible:ring-indigo-400',
|
||||
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(' ')}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
@ -290,6 +294,8 @@ const DEFAULTS: RecorderSettings = {
|
||||
autoDeleteSmallDownloads: true,
|
||||
autoDeleteSmallDownloadsBelowMB: 200,
|
||||
autoDeleteSmallDownloadsKeepFavorites: true,
|
||||
autoDeleteLowRatedDownloads: false,
|
||||
autoDeleteLowRatedDownloadsMaxStars: 3,
|
||||
blurPreviews: false,
|
||||
teaserPlayback: 'hover',
|
||||
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) {
|
||||
const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@ -688,6 +819,10 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
autoDeleteSmallDownloadsBelowMB: data.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB,
|
||||
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,
|
||||
teaserPlayback: (data as any).teaserPlayback ?? DEFAULTS.teaserPlayback,
|
||||
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)))
|
||||
)
|
||||
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 teaserPlayback =
|
||||
value.teaserPlayback === 'still' || value.teaserPlayback === 'all' || value.teaserPlayback === 'hover'
|
||||
@ -1126,6 +1266,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
autoDeleteSmallDownloads,
|
||||
autoDeleteSmallDownloadsBelowMB,
|
||||
autoDeleteSmallDownloadsKeepFavorites,
|
||||
autoDeleteLowRatedDownloads,
|
||||
autoDeleteLowRatedDownloadsMaxStars,
|
||||
blurPreviews,
|
||||
teaserPlayback,
|
||||
teaserAudio,
|
||||
@ -1201,22 +1343,35 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
|
||||
const mb = Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB ?? 0)
|
||||
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) {
|
||||
setErr('doneDir ist leer.')
|
||||
return null
|
||||
}
|
||||
if (!mb || mb <= 0) {
|
||||
setErr('Mindestgröße ist 0 – es würde nichts gelöscht.')
|
||||
if ((!mb || mb <= 0) && !deleteLowRated) {
|
||||
setErr('Mindestgröße ist 0 und Rating-Löschen ist deaktiviert – es würde nichts gelöscht.')
|
||||
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(
|
||||
`Aufräumen:\n` +
|
||||
`• Löscht Dateien in "${doneDir}" < ${mb} MB (Ordner "keep" wird übersprungen)\n` +
|
||||
`• Löscht fehlerhafte/defekte MP4/TS-Dateien, wenn sie nicht lesbar sind\n` +
|
||||
`• Räumt Record-Reste auf: .muxing.ts, verwaiste .ts und verwaiste/0-KB .audio.m4s\n` +
|
||||
`• Entfernt verwaiste Previews/Thumbs/Generated-Assets ohne passende Datei\n\n` +
|
||||
sizeLine +
|
||||
ratingLine +
|
||||
`• Entfernt defekte Video-Dateien\n` +
|
||||
`• Löscht Record-Reste und verwaiste Assets\n\n` +
|
||||
`Fortfahren?`
|
||||
)
|
||||
if (!ok) return null
|
||||
@ -1790,7 +1945,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
|
||||
<Task
|
||||
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"
|
||||
startingLabel="Läuft…"
|
||||
onTrigger={cleanupSmallDone}
|
||||
@ -2148,6 +2303,51 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
</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">
|
||||
<LabeledSwitch
|
||||
checked={!!value.trainingRecognitionEnabled}
|
||||
|
||||
@ -1983,6 +1983,8 @@ function annotationToCorrectionState(item: TrainingAnnotation): CorrectionState
|
||||
|
||||
const FEEDBACK_PAGE_SIZE = 10
|
||||
|
||||
const TRAINING_INFO_DISMISSED_STORAGE_KEY = 'training:info-dismissed-key'
|
||||
|
||||
export default function TrainingTab(props: {
|
||||
onTrainingRunningChange?: (running: boolean) => void
|
||||
onImageExpandedChange?: (expanded: boolean) => void
|
||||
@ -2009,6 +2011,15 @@ export default function TrainingTab(props: {
|
||||
const [trainingStatsError, setTrainingStatsError] = useState<string | null>(null)
|
||||
const wasTrainingRunningRef = useRef(false)
|
||||
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 importedSampleQueueRef = useRef<TrainingSample[]>([])
|
||||
@ -2530,6 +2541,68 @@ export default function TrainingTab(props: {
|
||||
const shownTrainingStep = trainingRunning
|
||||
? trainingStatus?.training?.step || trainingStep || 'Training läuft…'
|
||||
: 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(() => {
|
||||
return currentAnalysisConfidence(sample?.prediction)
|
||||
}, [sample?.prediction])
|
||||
@ -3519,6 +3592,13 @@ export default function TrainingTab(props: {
|
||||
|
||||
const startTraining = useCallback(async () => {
|
||||
shownTrainingCompletionRef.current = null
|
||||
setDismissedTrainingInfoKey('')
|
||||
|
||||
try {
|
||||
window.localStorage.removeItem(TRAINING_INFO_DISMISSED_STORAGE_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
setTraining(true)
|
||||
setTrainingProgress(5)
|
||||
@ -4302,6 +4382,98 @@ export default function TrainingTab(props: {
|
||||
</div>
|
||||
) : 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">
|
||||
<Button
|
||||
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 inline-flex min-h-0 max-h-full max-w-full select-none overscroll-contain transition',
|
||||
imageTouchClass,
|
||||
drawingCursorClass,
|
||||
'[-webkit-touch-callout:none] [-webkit-user-select:none] [user-select:none]',
|
||||
trainingRunning || loading ? 'pointer-events-none' : '',
|
||||
].join(' ')}
|
||||
|
||||
@ -114,7 +114,4 @@ body {
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user