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
|
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,
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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++
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
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 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={() => {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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(' ')}
|
||||||
|
|||||||
@ -114,7 +114,4 @@ body {
|
|||||||
a:hover {
|
a:hover {
|
||||||
color: #747bff;
|
color: #747bff;
|
||||||
}
|
}
|
||||||
button {
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user