diff --git a/backend/__pycache__/ai_server.cpython-314.pyc b/backend/__pycache__/ai_server.cpython-314.pyc index 0dfeb85..8556e63 100644 Binary files a/backend/__pycache__/ai_server.cpython-314.pyc and b/backend/__pycache__/ai_server.cpython-314.pyc differ diff --git a/backend/ai_server_process_other.go b/backend/ai_server_process_other.go new file mode 100644 index 0000000..1c6086a --- /dev/null +++ b/backend/ai_server_process_other.go @@ -0,0 +1,188 @@ +//go:build !windows + +package main + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "syscall" + "time" +) + +func prepareAIServerCommandForLifecycle(cmd *exec.Cmd) { + if cmd == nil { + return + } + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.Setpgid = true +} + +func terminateAIServerProcessTree(cmd *exec.Cmd) { + if cmd == nil || cmd.Process == nil || cmd.Process.Pid <= 0 { + return + } + + pid := cmd.Process.Pid + _ = syscall.Kill(-pid, syscall.SIGTERM) + time.Sleep(1200 * time.Millisecond) + _ = syscall.Kill(-pid, syscall.SIGKILL) + _ = cmd.Process.Kill() +} + +func stopStaleAIServerOnPort(port string) error { + if runtime.GOOS != "linux" { + return nil + } + + portNumber, err := strconv.Atoi(strings.TrimSpace(port)) + if err != nil || portNumber <= 0 || portNumber > 65535 { + return nil + } + + pids, err := linuxPIDsListeningOnPort(portNumber) + if err != nil { + return err + } + + currentPID := os.Getpid() + for _, pid := range pids { + if pid <= 0 || pid == currentPID { + continue + } + if !linuxPIDLooksLikeAIServer(pid) { + appLogf("⚠️ Listener auf AI-Port %d übersprungen, Prozess sieht nicht nach AI Server aus: pid=%d", portNumber, pid) + continue + } + appLogf("🧹 Alter AI Server auf Port %d wird beendet: pid=%d", portNumber, pid) + terminateStaleUnixPID(pid) + } + + if len(pids) > 0 { + time.Sleep(500 * time.Millisecond) + } + + return nil +} + +func linuxPIDsListeningOnPort(port int) ([]int, error) { + inodes := map[string]struct{}{} + for _, path := range []string{"/proc/net/tcp", "/proc/net/tcp6"} { + found, err := linuxSocketInodesListeningOnPort(path, port) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + for inode := range found { + inodes[inode] = struct{}{} + } + } + if len(inodes) == 0 { + return nil, nil + } + + entries, err := os.ReadDir("/proc") + if err != nil { + return nil, err + } + + pidSet := map[int]struct{}{} + for _, entry := range entries { + if !entry.IsDir() { + continue + } + pid, err := strconv.Atoi(entry.Name()) + if err != nil { + continue + } + + fdDir := filepath.Join("/proc", entry.Name(), "fd") + fds, err := os.ReadDir(fdDir) + if err != nil { + continue + } + for _, fd := range fds { + target, err := os.Readlink(filepath.Join(fdDir, fd.Name())) + if err != nil { + continue + } + if !strings.HasPrefix(target, "socket:[") || !strings.HasSuffix(target, "]") { + continue + } + inode := strings.TrimSuffix(strings.TrimPrefix(target, "socket:["), "]") + if _, ok := inodes[inode]; ok { + pidSet[pid] = struct{}{} + break + } + } + } + + pids := make([]int, 0, len(pidSet)) + for pid := range pidSet { + pids = append(pids, pid) + } + sort.Ints(pids) + return pids, nil +} + +func linuxSocketInodesListeningOnPort(path string, port int) (map[string]struct{}, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + inodes := map[string]struct{}{} + lines := strings.Split(string(data), "\n") + for _, line := range lines[1:] { + fields := strings.Fields(line) + if len(fields) < 10 || fields[3] != "0A" { + continue + } + + local := fields[1] + idx := strings.LastIndex(local, ":") + if idx < 0 || idx+1 >= len(local) { + continue + } + + localPort, err := strconv.ParseInt(local[idx+1:], 16, 32) + if err != nil || int(localPort) != port { + continue + } + + inode := strings.TrimSpace(fields[9]) + if inode != "" && inode != "0" { + inodes[inode] = struct{}{} + } + } + + return inodes, nil +} + +func terminateStaleUnixPID(pid int) { + proc, err := os.FindProcess(pid) + if err != nil || proc == nil { + return + } + + _ = proc.Signal(syscall.SIGTERM) + time.Sleep(900 * time.Millisecond) + _ = proc.Kill() +} + +func linuxPIDLooksLikeAIServer(pid int) bool { + data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "cmdline")) + if err != nil || len(data) == 0 { + return false + } + + cmdline := strings.ToLower(strings.ReplaceAll(string(data), "\x00", " ")) + return strings.Contains(cmdline, "ai_server:app") || + strings.Contains(cmdline, "ai_server.py") || + (strings.Contains(cmdline, "uvicorn") && strings.Contains(cmdline, "ai_server")) +} diff --git a/backend/ai_server_process_windows.go b/backend/ai_server_process_windows.go new file mode 100644 index 0000000..ee53d86 --- /dev/null +++ b/backend/ai_server_process_windows.go @@ -0,0 +1,128 @@ +//go:build windows + +package main + +import ( + "os" + "os/exec" + "strconv" + "strings" + "syscall" + "time" +) + +const windowsCreateNewProcessGroupForAIServer = 0x00000200 + +func prepareAIServerCommandForLifecycle(cmd *exec.Cmd) { + if cmd == nil { + return + } + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.CreationFlags |= windowsCreateNewProcessGroupForAIServer +} + +func terminateAIServerProcessTree(cmd *exec.Cmd) { + if cmd == nil || cmd.Process == nil || cmd.Process.Pid <= 0 { + return + } + + terminateWindowsPIDTree(cmd.Process.Pid) + _ = cmd.Process.Kill() +} + +func stopStaleAIServerOnPort(port string) error { + portNumber, err := strconv.Atoi(strings.TrimSpace(port)) + if err != nil || portNumber <= 0 || portNumber > 65535 { + return nil + } + + pids, err := windowsPIDsListeningOnPort(portNumber) + if err != nil { + return err + } + + currentPID := os.Getpid() + for _, pid := range pids { + if pid <= 0 || pid == currentPID { + continue + } + if !windowsPIDLooksLikeAIServer(pid) { + appLogf("⚠️ Listener auf AI-Port %d übersprungen, Prozess sieht nicht nach AI Server aus: pid=%d", portNumber, pid) + continue + } + appLogf("🧹 Alter AI Server auf Port %d wird beendet: pid=%d", portNumber, pid) + terminateWindowsPIDTree(pid) + } + + if len(pids) > 0 { + time.Sleep(500 * time.Millisecond) + } + + return nil +} + +func windowsPIDsListeningOnPort(port int) ([]int, error) { + ps := "Get-NetTCPConnection -LocalPort " + strconv.Itoa(port) + " -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique" + cmd := exec.Command( + "powershell", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", "Bypass", + "-Command", ps, + ) + hideCommandWindow(cmd) + + out, err := cmd.Output() + if err != nil { + return nil, err + } + + pidSet := map[int]struct{}{} + for _, line := range strings.Split(string(out), "\n") { + pid, err := strconv.Atoi(strings.TrimSpace(line)) + if err != nil || pid <= 0 { + continue + } + pidSet[pid] = struct{}{} + } + + pids := make([]int, 0, len(pidSet)) + for pid := range pidSet { + pids = append(pids, pid) + } + return pids, nil +} + +func terminateWindowsPIDTree(pid int) { + if pid <= 0 { + return + } + + cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F") + hideCommandWindow(cmd) + _ = cmd.Run() +} + +func windowsPIDLooksLikeAIServer(pid int) bool { + ps := "(Get-CimInstance Win32_Process -Filter \"ProcessId = " + strconv.Itoa(pid) + "\" -ErrorAction SilentlyContinue).CommandLine" + cmd := exec.Command( + "powershell", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", "Bypass", + "-Command", ps, + ) + hideCommandWindow(cmd) + + out, err := cmd.Output() + if err != nil { + return false + } + + cmdline := strings.ToLower(strings.TrimSpace(string(out))) + return strings.Contains(cmdline, "ai_server:app") || + strings.Contains(cmdline, "ai_server.py") || + (strings.Contains(cmdline, "uvicorn") && strings.Contains(cmdline, "ai_server")) +} diff --git a/backend/data/pending-autostart/admin.json b/backend/data/pending-autostart/admin.json deleted file mode 100644 index fc69ce2..0000000 --- a/backend/data/pending-autostart/admin.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "items": [] -} \ No newline at end of file diff --git a/backend/dist/nsfwapp-linux-amd64 b/backend/dist/nsfwapp-linux-amd64 index 4cd1bcd..e3e551c 100644 Binary files a/backend/dist/nsfwapp-linux-amd64 and b/backend/dist/nsfwapp-linux-amd64 differ diff --git a/backend/dist/nsfwapp.exe b/backend/dist/nsfwapp.exe index b531f2a..9eb33f6 100644 Binary files a/backend/dist/nsfwapp.exe and b/backend/dist/nsfwapp.exe differ diff --git a/backend/dist/nsfwapp_amd64.deb b/backend/dist/nsfwapp_amd64.deb index 5a4fd0f..e94277d 100644 Binary files a/backend/dist/nsfwapp_amd64.deb and b/backend/dist/nsfwapp_amd64.deb differ diff --git a/backend/embed.go b/backend/embed.go index 4654ab1..0a4b015 100644 --- a/backend/embed.go +++ b/backend/embed.go @@ -71,6 +71,16 @@ func trainingEmbeddedMLDir() (string, error) { return dir, nil } +func trainingMLCacheFilePath(name string) (string, error) { + dir := filepath.Join(os.TempDir(), "nsfwapp-ml") + + if err := os.MkdirAll(dir, 0755); err != nil { + return "", err + } + + return filepath.Join(dir, name), nil +} + func embeddedMLScriptExists(name string) bool { srcPath := filepath.ToSlash(filepath.Join("ml", name)) _, err := embeddedMLFiles.ReadFile(srcPath) diff --git a/backend/log.go b/backend/log.go index bbde88b..1a7b63e 100644 --- a/backend/log.go +++ b/backend/log.go @@ -147,6 +147,10 @@ func appLogLineSuppressed(line string) bool { return true } + if m == "not found" { + return true + } + if strings.Contains(m, "mfc status failed") && (strings.Contains(m, "user not found") || strings.Contains(m, "usernamelookup")) { return true diff --git a/backend/ml/train_detector_model.py b/backend/ml/train_detector_model.py index b7ecbff..03a2115 100644 --- a/backend/ml/train_detector_model.py +++ b/backend/ml/train_detector_model.py @@ -53,6 +53,57 @@ def safe_int(value, fallback): return fallback +def load_base_model(base): + base_text = str(base or "").strip() + base_path = Path(base_text).expanduser() + + is_explicit_path = base_path.is_absolute() or base_path.parent != Path(".") + base_exists = base_path.is_file() and base_path.stat().st_size > 0 + if not is_explicit_path or base_exists: + return YOLO(base_text), base_text + + base_path.parent.mkdir(parents=True, exist_ok=True) + model_name = base_path.name + model = YOLO(model_name) + + candidates = [] + ckpt_path = getattr(model, "ckpt_path", None) + if ckpt_path: + candidates.append(Path(str(ckpt_path)).expanduser()) + + candidates.append(Path.cwd() / model_name) + candidates.append(Path(model_name)) + + copied = False + for candidate in candidates: + try: + candidate = candidate.resolve() + target = base_path.resolve() + except Exception: + continue + + if not candidate.exists() or candidate == target: + continue + + try: + shutil.copy2(candidate, target) + copied = True + + if candidate.parent == Path.cwd().resolve(): + try: + candidate.unlink() + except Exception: + pass + break + except Exception: + continue + + if copied and base_path.exists(): + return YOLO(str(base_path)), str(base_path) + + return model, model_name + + def batch_sample_ids(batch): if not isinstance(batch, dict): return [] @@ -126,6 +177,8 @@ def main(): parser.add_argument("--imgsz", default="640") parser.add_argument("--device", default="auto") parser.add_argument("--workers", default="2") + parser.add_argument("--batch", default="0") + parser.add_argument("--threads", default="0") parser.add_argument("--patience", default="20") args = parser.parse_args() @@ -140,8 +193,17 @@ def main(): epochs = max(1, safe_int(args.epochs, 80)) imgsz = max(64, safe_int(args.imgsz, 640)) workers = max(0, safe_int(args.workers, 2)) + batch_size = max(0, safe_int(args.batch, 0)) + threads = max(0, safe_int(args.threads, 0)) patience = max(0, safe_int(args.patience, 20)) + if threads > 0: + torch.set_num_threads(threads) + try: + torch.set_num_interop_threads(max(1, min(threads, 4))) + except Exception: + pass + if str(args.device).lower() == "auto": train_device = 0 if torch.cuda.is_available() else "cpu" else: @@ -178,7 +240,7 @@ def main(): device=str(train_device), ) - model = YOLO(args.base) + model, base_used = load_base_model(args.base) best_epoch = 0 last_epoch = 0 @@ -273,23 +335,27 @@ def main(): device=str(train_device), ) - result = model.train( - trainer=progress_detection_trainer( + train_kwargs = { + "trainer": progress_detection_trainer( train_count, val_count, train_device, epochs, ), - data=str(yaml_path), - epochs=epochs, - imgsz=imgsz, - project=str(runs_dir), - name="detect", - exist_ok=True, - device=train_device, - workers=workers, - patience=patience, - ) + "data": str(yaml_path), + "epochs": epochs, + "imgsz": imgsz, + "project": str(runs_dir), + "name": "detect", + "exist_ok": True, + "device": train_device, + "workers": workers, + "patience": patience, + } + if batch_size > 0: + train_kwargs["batch"] = batch_size + + result = model.train(**train_kwargs) emit_progress( "detector", @@ -325,6 +391,9 @@ def main(): "valSamples": val_count, "epochs": epochs, "imgsz": imgsz, + "batchSize": batch_size, + "threads": threads, + "workers": workers, "device": str(train_device), "mAP50": round(best_map50, 4), "mAP5095": round(best_map5095, 4), diff --git a/backend/ml/train_pose_model.py b/backend/ml/train_pose_model.py index 8ba27c5..46e6853 100644 --- a/backend/ml/train_pose_model.py +++ b/backend/ml/train_pose_model.py @@ -164,6 +164,8 @@ def main(): parser.add_argument("--imgsz", default="640") parser.add_argument("--device", default="auto") parser.add_argument("--workers", default="2") + parser.add_argument("--batch", default="0") + parser.add_argument("--threads", default="0") parser.add_argument("--patience", default="20") args = parser.parse_args() @@ -178,8 +180,17 @@ def main(): epochs = max(1, safe_int(args.epochs, 80)) imgsz = max(64, safe_int(args.imgsz, 640)) workers = max(0, safe_int(args.workers, 2)) + batch_size = max(0, safe_int(args.batch, 0)) + threads = max(0, safe_int(args.threads, 0)) patience = max(0, safe_int(args.patience, 20)) + if threads > 0: + torch.set_num_threads(threads) + try: + torch.set_num_interop_threads(max(1, min(threads, 4))) + except Exception: + pass + if str(args.device).lower() == "auto": train_device = 0 if torch.cuda.is_available() else "cpu" else: @@ -310,23 +321,27 @@ def main(): device=str(train_device), ) - result = model.train( - trainer=progress_pose_trainer( + train_kwargs = { + "trainer": progress_pose_trainer( train_count, val_count, train_device, epochs, ), - data=str(yaml_path), - epochs=epochs, - imgsz=imgsz, - project=str(runs_dir), - name="pose", - exist_ok=True, - device=train_device, - workers=workers, - patience=patience, - ) + "data": str(yaml_path), + "epochs": epochs, + "imgsz": imgsz, + "project": str(runs_dir), + "name": "pose", + "exist_ok": True, + "device": train_device, + "workers": workers, + "patience": patience, + } + if batch_size > 0: + train_kwargs["batch"] = batch_size + + result = model.train(**train_kwargs) emit_progress( "pose", @@ -362,6 +377,9 @@ def main(): "valSamples": val_count, "epochs": epochs, "imgsz": imgsz, + "batchSize": batch_size, + "threads": threads, + "workers": workers, "device": str(train_device), "mAP50": round(best_map50, 4), "mAP5095": round(best_map5095, 4), diff --git a/backend/ml/train_videomae_model.py b/backend/ml/train_videomae_model.py index 34a2f3c..c5ad8a3 100644 --- a/backend/ml/train_videomae_model.py +++ b/backend/ml/train_videomae_model.py @@ -198,6 +198,7 @@ def main(): parser.add_argument("--lr", default="5e-5") parser.add_argument("--device", default="auto") parser.add_argument("--workers", default="0") + parser.add_argument("--threads", default="0") parser.add_argument("--num-frames", default="16") parser.add_argument("--freeze-backbone", action="store_true") args = parser.parse_args() @@ -210,9 +211,17 @@ def main(): epochs = max(1, safe_int(args.epochs, 8)) batch_size = max(1, safe_int(args.batch_size, 2)) workers = max(0, safe_int(args.workers, 0)) + threads = max(0, safe_int(args.threads, 0)) num_frames = max(2, safe_int(args.num_frames, 16)) lr = max(1e-7, safe_float(args.lr, 5e-5)) + if threads > 0: + torch.set_num_threads(threads) + try: + torch.set_num_interop_threads(max(1, min(threads, 4))) + except Exception: + pass + if str(args.device).lower() == "auto": device = torch.device("cuda" if torch.cuda.is_available() else "cpu") else: @@ -364,6 +373,9 @@ def main(): "bestEpoch": best_epoch, "trainSamples": len(train_entries), "valSamples": len(val_entries), + "batchSize": batch_size, + "workers": workers, + "threads": threads, "numFrames": num_frames, "baseModel": args.base, "device": str(device), diff --git a/backend/postwork.go b/backend/postwork.go index 32f16c3..64e9c4d 100644 --- a/backend/postwork.go +++ b/backend/postwork.go @@ -495,9 +495,11 @@ func waitUntilEnrichMayRun(ctx context.Context) error { for { waiting, running, _ := postWorkQ.Stats() + trainingRunning := trainingGetJobStatus().Running + // Auch waehrend eines Trainings pausieren, damit EnrichQ keine CPU stiehlt. // enrich erst starten, wenn keine primäre Postwork mehr läuft - if waiting == 0 && running == 0 { + if waiting == 0 && running == 0 && !trainingRunning { return nil } diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json index 8900b7c..a1e5ac2 100644 --- a/backend/recorder_settings.json +++ b/backend/recorder_settings.json @@ -1,4 +1,5 @@ { + "databaseType": "postgres", "databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable", "encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=", "recordDir": "C:\\Users\\Rother\\Desktop\\Privat\\records", @@ -29,5 +30,12 @@ "enrichPostworkEnabled": false, "trainingRecognitionEnabled": true, "trainingDetectorEpochs": 60, + "trainingPerformanceMode": "auto", + "trainingPowerSaveMode": false, + "trainingCpuThreads": 0, + "trainingWorkers": 2, + "trainingYoloBatchSize": 0, + "trainingLowPriority": false, + "trainingVideoMAEEnabled": true, "encryptedCookies": "p0aRfigWn3+TfypNAV7vPky13RW+gwhTlhNOGKKAED0FSMf0kdGV6LgIizzTeDMD5Ck+9SGGf6EfVg+RPnEr3ZjkkkjOTVGOJ9XNzpuczHqzi0ifmt0l9/lnNTaqh8oTzrk09uQC9YYe0CtsY6gjpYFAdVEPGimCPdULqn+8Jln1CSDiBGRGF0uZGxfmAZ1T/YQym/wjiCCdIRfcjTF4f+sz6PMBQOOnTA74if1OFNA6PxpGoXfHS7xvMeV6RX3J/qcTyMmFWr+uD2h1UKs7N9w4Y1sfw4heB10jzy/VL+9gWg2o4AKE5tGqOmlVYpv8KculMWfJHNxNpRonPNizPR4jO2jZYdr3AikuXTTeILyN7hb4MtO4fXblJ+9of1ABR6LvDe1dAXmbkNdqEqRo0k1WS6jaCdNkrleP8vGuH56H7jZMnEeJAcQxS99kVjFo0qMAEJ2ai8HPkdYURC3dWSu9uIbGmiqi6hgva2WM6jPG7TqZDyqUS7hjJwAmPa3aLPnsEWHqkcE=" } diff --git a/backend/routes.go b/backend/routes.go index 078971a..b3d7d2e 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -99,12 +99,14 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { api.HandleFunc("/api/training/train", trainingTrainHandler) api.HandleFunc("/api/training/cancel", trainingCancelHandler) api.HandleFunc("/api/training/status", trainingStatusHandler) + api.HandleFunc("/api/training/analysis/status", trainingAnalysisStatusHandler) api.HandleFunc("/api/training/stats", trainingStatsHandler) api.HandleFunc("/api/training/history", trainingHistoryHandler) api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler) api.HandleFunc("/api/training/skip", trainingSkipHandler) api.HandleFunc("/api/training/import-video", trainingImportVideoHandler) api.HandleFunc("/api/training/import-video/status", trainingImportVideoStatusHandler) + api.HandleFunc("/api/training/next/status", trainingNextStatusHandler) api.HandleFunc("/api/training/video-preview", trainingVideoPreviewHandler) api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler) diff --git a/backend/server.go b/backend/server.go index 88ad2e1..2a35e99 100644 --- a/backend/server.go +++ b/backend/server.go @@ -8,6 +8,7 @@ import ( "fmt" "net" "net/http" + neturl "net/url" "os" "os/exec" "os/signal" @@ -98,8 +99,28 @@ func buildStartupNotificationMessage() string { } type aiServerProcess struct { - cmd *exec.Cmd - done chan struct{} + cmd *exec.Cmd + done chan struct{} + mu sync.Mutex + stopping bool +} + +func (p *aiServerProcess) markStopping() { + if p == nil { + return + } + p.mu.Lock() + p.stopping = true + p.mu.Unlock() +} + +func (p *aiServerProcess) isStopping() bool { + if p == nil { + return false + } + p.mu.Lock() + defer p.mu.Unlock() + return p.stopping } // Globales Handle auf den laufenden AI-Server, damit er (z.B. nach einem neuen @@ -184,10 +205,12 @@ func aiServerURL() string { } func aiServerPortFromURL() string { - url := aiServerURL() + rawURL := aiServerURL() - if strings.Contains(url, ":8765") { - return "8765" + if parsed, err := neturl.Parse(rawURL); err == nil && parsed != nil { + if port := strings.TrimSpace(parsed.Port()); port != "" { + return port + } } // Einfacher Fallback. Wenn du später andere Ports willst, @@ -499,6 +522,10 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) { return nil, nil } + if err := stopStaleAIServerOnPort(aiServerPortFromURL()); err != nil { + appLogln("⚠️ Alter AI Server konnte nicht bereinigt werden:", err) + } + if err := ensureMLPythonSetup(ctx); err != nil { return nil, err } @@ -552,7 +579,7 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) { "--log-level", "error", ) - cmd := exec.CommandContext(ctx, pythonPath, args...) + cmd := exec.Command(pythonPath, args...) cmd.Dir = scriptDir env := os.Environ() @@ -612,6 +639,12 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) { hideCommandWindow(cmd) } + prepareAIServerCommandForLifecycle(cmd) + + if ctx.Err() != nil { + return nil, ctx.Err() + } + if err := cmd.Start(); err != nil { return nil, err } @@ -624,7 +657,7 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) { go func() { err := cmd.Wait() - if err != nil && ctx.Err() == nil { + if err != nil && ctx.Err() == nil && !proc.isStopping() { appLogf("⚠️ AI Server beendet: %v", err) } else { appLogln("🛑 AI Server beendet.") @@ -648,9 +681,8 @@ func (p *aiServerProcess) Stop() { return } - if p.cmd != nil && p.cmd.Process != nil { - _ = p.cmd.Process.Kill() - } + p.markStopping() + terminateAIServerProcessTree(p.cmd) // Auf das tatsächliche Prozessende warten, damit der Port frei ist, // bevor ggf. ein neuer Server gestartet wird. diff --git a/backend/settings.go b/backend/settings.go index 3e9a63d..5e79abf 100644 --- a/backend/settings.go +++ b/backend/settings.go @@ -56,8 +56,15 @@ type RecorderSettings struct { GenerateAssetsAnalyze bool `json:"generateAssetsAnalyze"` EnrichPostworkEnabled bool `json:"enrichPostworkEnabled"` - TrainingRecognitionEnabled bool `json:"trainingRecognitionEnabled"` - TrainingDetectorEpochs int `json:"trainingDetectorEpochs"` + TrainingRecognitionEnabled bool `json:"trainingRecognitionEnabled"` + TrainingDetectorEpochs int `json:"trainingDetectorEpochs"` + TrainingPerformanceMode string `json:"trainingPerformanceMode"` + TrainingPowerSaveMode bool `json:"trainingPowerSaveMode"` + TrainingCPUThreads int `json:"trainingCpuThreads"` + TrainingWorkers int `json:"trainingWorkers"` + TrainingYoloBatchSize int `json:"trainingYoloBatchSize"` + TrainingLowPriority bool `json:"trainingLowPriority"` + TrainingVideoMAEEnabled bool `json:"trainingVideoMAEEnabled"` // EncryptedCookies contains base64(nonce+ciphertext) of a JSON cookie map. EncryptedCookies string `json:"encryptedCookies"` @@ -107,6 +114,13 @@ var ( TrainingRecognitionEnabled: true, TrainingDetectorEpochs: 60, + TrainingPerformanceMode: trainingPerformanceAuto, + TrainingPowerSaveMode: false, + TrainingCPUThreads: 0, + TrainingWorkers: 2, + TrainingYoloBatchSize: 0, + TrainingLowPriority: false, + TrainingVideoMAEEnabled: true, EncryptedCookies: "", } @@ -136,6 +150,47 @@ func normalizeSettingsDir(raw string, fallback string, legacyDefaults ...string) return path } +func normalizeTrainingSettings(s *RecorderSettings) { + if s == nil { + return + } + + if s.TrainingDetectorEpochs < 1 { + s.TrainingDetectorEpochs = 60 + } + if s.TrainingDetectorEpochs > 300 { + s.TrainingDetectorEpochs = 300 + } + + rawMode := strings.TrimSpace(s.TrainingPerformanceMode) + if rawMode == "" && s.TrainingPowerSaveMode { + rawMode = trainingPerformanceEco + } + s.TrainingPerformanceMode = normalizeTrainingPerformanceMode(rawMode) + s.TrainingPowerSaveMode = s.TrainingPerformanceMode == trainingPerformanceEco + + if s.TrainingCPUThreads < 0 { + s.TrainingCPUThreads = 0 + } + if s.TrainingCPUThreads > 32 { + s.TrainingCPUThreads = 32 + } + + if s.TrainingWorkers < 0 { + s.TrainingWorkers = 0 + } + if s.TrainingWorkers > 16 { + s.TrainingWorkers = 16 + } + + if s.TrainingYoloBatchSize < 0 { + s.TrainingYoloBatchSize = 0 + } + if s.TrainingYoloBatchSize > 64 { + s.TrainingYoloBatchSize = 64 + } +} + func settingsFilePath() string { // optionaler Override per ENV name := settingsFile @@ -198,12 +253,7 @@ func loadSettings() { s.LowDiskPauseBelowGB = 10_000 } - if s.TrainingDetectorEpochs < 1 { - s.TrainingDetectorEpochs = 60 - } - if s.TrainingDetectorEpochs > 300 { - s.TrainingDetectorEpochs = 300 - } + normalizeTrainingSettings(&s) settingsMu.Lock() settings = s @@ -316,12 +366,27 @@ type RecorderSettingsPublic struct { GenerateAssetsAnalyze bool `json:"generateAssetsAnalyze"` EnrichPostworkEnabled bool `json:"enrichPostworkEnabled"` - TrainingRecognitionEnabled bool `json:"trainingRecognitionEnabled"` - TrainingDetectorEpochs int `json:"trainingDetectorEpochs"` + TrainingRecognitionEnabled bool `json:"trainingRecognitionEnabled"` + TrainingDetectorEpochs int `json:"trainingDetectorEpochs"` + TrainingPerformanceMode string `json:"trainingPerformanceMode"` + TrainingPowerSaveMode bool `json:"trainingPowerSaveMode"` + TrainingCPUThreads int `json:"trainingCpuThreads"` + TrainingWorkers int `json:"trainingWorkers"` + TrainingYoloBatchSize int `json:"trainingYoloBatchSize"` + TrainingLowPriority bool `json:"trainingLowPriority"` + TrainingVideoMAEEnabled bool `json:"trainingVideoMAEEnabled"` + TrainingCPUCoreCount int `json:"trainingCpuCoreCount"` + TrainingEffectiveMode string `json:"trainingEffectiveMode"` + TrainingEffectiveCPUThreads int `json:"trainingEffectiveCpuThreads"` + TrainingEffectiveWorkers int `json:"trainingEffectiveWorkers"` + TrainingEffectiveYoloBatchSize int `json:"trainingEffectiveYoloBatchSize"` + TrainingEffectiveLowPriority bool `json:"trainingEffectiveLowPriority"` } func toPublicSettings(s RecorderSettings) RecorderSettingsPublic { normalizePostgresSettings(&s) + normalizeTrainingSettings(&s) + trainingOpts := trainingRuntimeOptionsFromRecorderSettings(s) return RecorderSettingsPublic{ RecordDir: s.RecordDir, DoneDir: s.DoneDir, @@ -362,8 +427,21 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic { GenerateAssetsAnalyze: s.GenerateAssetsAnalyze, EnrichPostworkEnabled: s.EnrichPostworkEnabled, - TrainingRecognitionEnabled: s.TrainingRecognitionEnabled, - TrainingDetectorEpochs: s.TrainingDetectorEpochs, + TrainingRecognitionEnabled: s.TrainingRecognitionEnabled, + TrainingDetectorEpochs: s.TrainingDetectorEpochs, + TrainingPerformanceMode: s.TrainingPerformanceMode, + TrainingPowerSaveMode: s.TrainingPowerSaveMode, + TrainingCPUThreads: s.TrainingCPUThreads, + TrainingWorkers: s.TrainingWorkers, + TrainingYoloBatchSize: s.TrainingYoloBatchSize, + TrainingLowPriority: s.TrainingLowPriority, + TrainingVideoMAEEnabled: s.TrainingVideoMAEEnabled, + TrainingCPUCoreCount: trainingOpts.CPUCoreCount, + TrainingEffectiveMode: trainingOpts.PerformanceMode, + TrainingEffectiveCPUThreads: trainingOpts.CPUThreads, + TrainingEffectiveWorkers: trainingOpts.Workers, + TrainingEffectiveYoloBatchSize: trainingOpts.YoloBatchSize, + TrainingEffectiveLowPriority: trainingOpts.LowPriority, } } @@ -438,12 +516,7 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) { in.MaxConcurrentDownloads = 1 } - if in.TrainingDetectorEpochs < 1 { - in.TrainingDetectorEpochs = 60 - } - if in.TrainingDetectorEpochs > 300 { - in.TrainingDetectorEpochs = 300 - } + normalizeTrainingSettings(&in.RecorderSettings) // --- ensure folders (Fehler zurückgeben, falls z.B. keine Rechte) --- recAbs, err := resolvePathRelativeToApp(in.RecordDir) @@ -551,6 +624,13 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) { next.TrainingRecognitionEnabled = in.TrainingRecognitionEnabled next.TrainingDetectorEpochs = in.TrainingDetectorEpochs + next.TrainingPerformanceMode = in.TrainingPerformanceMode + next.TrainingPowerSaveMode = in.TrainingPowerSaveMode + next.TrainingCPUThreads = in.TrainingCPUThreads + next.TrainingWorkers = in.TrainingWorkers + next.TrainingYoloBatchSize = in.TrainingYoloBatchSize + next.TrainingLowPriority = in.TrainingLowPriority + next.TrainingVideoMAEEnabled = in.TrainingVideoMAEEnabled dbChanged := normalizeDatabaseType(next.DatabaseType) != normalizeDatabaseType(current.DatabaseType) || diff --git a/backend/training.go b/backend/training.go index f3e3d42..91edbc5 100644 --- a/backend/training.go +++ b/backend/training.go @@ -18,6 +18,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "sort" "strconv" "strings" @@ -768,6 +769,109 @@ func trainingPublishJobStatus(status TrainingJobStatus) { publishSSE("training", b) } +type trainingAnalysisStatusEntry struct { + updatedAt time.Time + payload map[string]any +} + +var trainingAnalysisStatuses = struct { + sync.Mutex + items map[string]trainingAnalysisStatusEntry +}{ + items: map[string]trainingAnalysisStatusEntry{}, +} + +const trainingAnalysisStatusTTL = 2 * time.Hour + +func trainingRememberAnalysisPayload(payload map[string]any) { + requestID := strings.TrimSpace(fmt.Sprint(payload["requestId"])) + if requestID == "" { + return + } + + now := time.Now().UTC() + copied := make(map[string]any, len(payload)) + for k, v := range payload { + copied[k] = v + } + + trainingAnalysisStatuses.Lock() + for id, entry := range trainingAnalysisStatuses.items { + if !entry.updatedAt.IsZero() && now.Sub(entry.updatedAt) > trainingAnalysisStatusTTL { + delete(trainingAnalysisStatuses.items, id) + } + } + trainingAnalysisStatuses.items[requestID] = trainingAnalysisStatusEntry{ + updatedAt: now, + payload: copied, + } + trainingAnalysisStatuses.Unlock() +} + +func trainingAnalysisStatusPayload(requestID string) map[string]any { + requestID = strings.TrimSpace(requestID) + + trainingAnalysisStatuses.Lock() + defer trainingAnalysisStatuses.Unlock() + + if requestID != "" { + entry, ok := trainingAnalysisStatuses.items[requestID] + if !ok || entry.payload == nil { + return nil + } + + out := make(map[string]any, len(entry.payload)) + for k, v := range entry.payload { + out[k] = v + } + return out + } + + var latest *trainingAnalysisStatusEntry + for _, entry := range trainingAnalysisStatuses.items { + if entry.payload == nil || !trainingAnalysisValueTruthy(entry.payload["running"]) { + continue + } + if latest == nil || entry.updatedAt.After(latest.updatedAt) { + copyEntry := entry + latest = ©Entry + } + } + + if latest == nil { + return nil + } + + out := make(map[string]any, len(latest.payload)) + for k, v := range latest.payload { + out[k] = v + } + return out +} + +func trainingAnalysisValueTruthy(value any) bool { + switch v := value.(type) { + case bool: + return v + case string: + return strings.EqualFold(strings.TrimSpace(v), "true") || strings.TrimSpace(v) == "1" + case int: + return v != 0 + case int64: + return v != 0 + case float64: + return v != 0 + default: + return false + } +} + +func trainingPublishAnalysisPayload(payload map[string]any) { + trainingRememberAnalysisPayload(payload) + + trainingPublishAnalysisPayload(payload) +} + func trainingPublishAnalysisStep( requestID string, startedAtMs int64, @@ -871,10 +975,7 @@ func trainingPublishAnalysisStartedWithPreview( payload["previewUrl"] = strings.TrimSpace(previewURL) } - b, err := json.Marshal(payload) - if err == nil { - publishSSE("analysisProgress", b) - } + trainingPublishAnalysisPayload(payload) return startedAtMs } @@ -892,7 +993,7 @@ func trainingPublishAnalysisFinished( durationMs = 0 } - b, err := json.Marshal(map[string]any{ + payload := map[string]any{ "type": "analysis_progress", "scope": "training", "requestId": requestID, @@ -907,12 +1008,9 @@ func trainingPublishAnalysisFinished( "sourceFile": strings.TrimSpace(sourceFile), "message": strings.TrimSpace(message), "ts": time.Now().UnixMilli(), - }) - if err != nil { - return } - publishSSE("analysisProgress", b) + trainingPublishAnalysisPayload(payload) } func trainingPublishAnalysisError( @@ -933,7 +1031,7 @@ func trainingPublishAnalysisError( errText = err.Error() } - b, marshalErr := json.Marshal(map[string]any{ + payload := map[string]any{ "type": "analysis_progress", "scope": "training", "requestId": requestID, @@ -947,12 +1045,9 @@ func trainingPublishAnalysisError( "message": strings.TrimSpace(message), "error": errText, "ts": time.Now().UnixMilli(), - }) - if marshalErr != nil { - return } - publishSSE("analysisProgress", b) + trainingPublishAnalysisPayload(payload) } func trainingRunCommandStreaming( @@ -963,9 +1058,13 @@ func trainingRunCommandStreaming( args ...string, ) (string, error) { cmdArgs := append([]string{script}, args...) - cmd := exec.CommandContext(ctx, python, cmdArgs...) + cmd := exec.Command(python, cmdArgs...) hideCommandWindow(cmd) + opts := trainingRuntimeOptionsFromSettings() + cmd.Env = trainingCommandEnv(opts) + prepareTrainingCommandForCancel(cmd) + applyTrainingLowPriorityBeforeStart(cmd, opts.LowPriority) stdout, err := cmd.StdoutPipe() if err != nil { @@ -977,9 +1076,14 @@ func trainingRunCommandStreaming( return "", err } + if ctx.Err() != nil { + return "", errTrainingCancelled + } + if err := cmd.Start(); err != nil { return "", err } + applyTrainingLowPriorityAfterStart(cmd, opts.LowPriority) var outMu sync.Mutex var lines []string @@ -1022,7 +1126,17 @@ func trainingRunCommandStreaming( readPipe(bufio.NewScanner(stderr)) }() - err = cmd.Wait() + waitCh := make(chan error, 1) + go func() { + waitCh <- cmd.Wait() + }() + + select { + case err = <-waitCh: + case <-ctx.Done(): + terminateTrainingCommand(cmd) + err = <-waitCh + } wg.Wait() outMu.Lock() @@ -1054,6 +1168,174 @@ const trainingPositionContextOverrideMargin = 0.16 var errTrainingCancelled = errors.New("training cancelled") +const ( + trainingPerformanceAuto = "auto" + trainingPerformanceEco = "eco" + trainingPerformanceBalanced = "balanced" + trainingPerformancePerformance = "performance" + trainingPerformanceCustom = "custom" +) + +type trainingRuntimeOptions struct { + PerformanceMode string + PowerSaveMode bool + CPUCoreCount int + CPUThreads int + Workers int + YoloBatchSize int + LowPriority bool + VideoMAEEnabled bool +} + +func normalizeTrainingPerformanceMode(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "auto", "automatic", "automatisch": + return trainingPerformanceAuto + case "eco", "schonend", "schonmodus", "powersave", "power-save", "power_save": + return trainingPerformanceEco + case "balanced", "ausgewogen", "normal": + return trainingPerformanceBalanced + case "performance", "leistung", "fast", "schnell": + return trainingPerformancePerformance + case "custom", "manual", "manuell": + return trainingPerformanceCustom + default: + return trainingPerformanceAuto + } +} + +func clampTrainingInt(value int, minValue int, maxValue int) int { + if value < minValue { + return minValue + } + if value > maxValue { + return maxValue + } + return value +} + +func trainingPresetValues(mode string, cpuCores int) (threads int, workers int, yoloBatch int, lowPriority bool) { + cpuCores = clampTrainingInt(cpuCores, 1, 256) + + switch normalizeTrainingPerformanceMode(mode) { + case trainingPerformanceEco: + threads = 1 + if cpuCores >= 4 { + threads = 2 + } + workers = 0 + yoloBatch = 1 + if cpuCores >= 6 { + yoloBatch = 2 + } + lowPriority = true + + case trainingPerformancePerformance: + threads = clampTrainingInt(cpuCores-1, 2, 16) + workers = clampTrainingInt(threads/2, 2, 4) + yoloBatch = 4 + if cpuCores >= 8 { + yoloBatch = 8 + } + if cpuCores >= 16 { + yoloBatch = 12 + } + lowPriority = false + + default: + threads = clampTrainingInt(cpuCores/2, 2, 8) + workers = clampTrainingInt(threads/2, 1, 2) + yoloBatch = 2 + if cpuCores >= 8 { + yoloBatch = 4 + } + if cpuCores >= 16 { + yoloBatch = 6 + } + lowPriority = false + } + + return threads, workers, yoloBatch, lowPriority +} + +func trainingRuntimeOptionsFromSettings() trainingRuntimeOptions { + s := getSettings() + return trainingRuntimeOptionsFromRecorderSettings(s) +} + +func trainingRuntimeOptionsFromRecorderSettings(s RecorderSettings) trainingRuntimeOptions { + normalizeTrainingSettings(&s) + cpuCores := runtime.NumCPU() + if cpuCores < 1 { + cpuCores = 1 + } + + mode := normalizeTrainingPerformanceMode(s.TrainingPerformanceMode) + if mode == trainingPerformanceAuto { + if cpuCores <= 4 { + mode = trainingPerformanceEco + } else if cpuCores >= 12 { + mode = trainingPerformancePerformance + } else { + mode = trainingPerformanceBalanced + } + } + + presetThreads, presetWorkers, presetBatch, presetLowPriority := trainingPresetValues(mode, cpuCores) + + opts := trainingRuntimeOptions{ + PerformanceMode: mode, + PowerSaveMode: mode == trainingPerformanceEco, + CPUCoreCount: cpuCores, + CPUThreads: presetThreads, + Workers: presetWorkers, + YoloBatchSize: presetBatch, + LowPriority: presetLowPriority, + VideoMAEEnabled: s.TrainingVideoMAEEnabled, + } + + if mode == trainingPerformanceCustom { + opts.PowerSaveMode = s.TrainingPowerSaveMode + if s.TrainingCPUThreads > 0 { + opts.CPUThreads = clampTrainingInt(s.TrainingCPUThreads, 1, cpuCores) + } else { + opts.CPUThreads = 0 + } + opts.Workers = clampTrainingInt(s.TrainingWorkers, 0, 16) + opts.YoloBatchSize = clampTrainingInt(s.TrainingYoloBatchSize, 0, 64) + opts.LowPriority = s.TrainingLowPriority + } else { + if opts.CPUThreads > cpuCores { + opts.CPUThreads = cpuCores + } + } + + return opts +} + +func trainingCommandEnv(opts trainingRuntimeOptions) []string { + env := os.Environ() + if opts.CPUThreads <= 0 { + return env + } + + threads := strconv.Itoa(opts.CPUThreads) + limitVars := []string{ + "OMP_NUM_THREADS", + "MKL_NUM_THREADS", + "OPENBLAS_NUM_THREADS", + "NUMEXPR_NUM_THREADS", + "VECLIB_MAXIMUM_THREADS", + "TORCH_NUM_THREADS", + } + + for _, name := range limitVars { + env = append(env, name+"="+threads) + } + + return env +} + var trainingJob = struct { mu sync.Mutex status TrainingJobStatus @@ -1241,8 +1523,46 @@ type TrainingImportVideoResponse struct { Samples []TrainingSample `json:"samples,omitempty"` Errors []string `json:"errors,omitempty"` Error string `json:"error,omitempty"` + Analysis map[string]any `json:"analysis,omitempty"` } +type TrainingNextRequest struct { + ForceNew bool + RefreshPrediction bool + PreferUncertain bool + AnalysisRequestID string + ExcludeIDs map[string]bool +} + +type TrainingNextResponse struct { + OK bool `json:"ok"` + Accepted bool `json:"accepted,omitempty"` + Running bool `json:"running,omitempty"` + RequestID string `json:"requestId,omitempty"` + Sample *TrainingSample `json:"sample,omitempty"` + Error string `json:"error,omitempty"` + Analysis map[string]any `json:"analysis,omitempty"` +} + +type trainingNextJobState struct { + requestID string + startedAt time.Time + finishedAt time.Time + running bool + statusCode int + response *TrainingNextResponse + errorText string +} + +var trainingNextJobs = struct { + sync.Mutex + items map[string]*trainingNextJobState +}{ + items: map[string]*trainingNextJobState{}, +} + +const trainingNextJobTTL = 2 * time.Hour + type trainingImportVideoJobState struct { requestID string startedAt time.Time @@ -1655,7 +1975,7 @@ func trainingRunImportVideoRequest(req TrainingImportVideoRequest) (TrainingImpo sample := &TrainingSample{ SampleID: id, FrameURL: "/api/training/frame?id=" + id, - SourceFile: sourceFile, + SourceFile: sourceFileWithFrame(i), SourcePath: outPath, SourceSizeBytes: sourceSizeBytes, Second: second, @@ -1789,6 +2109,7 @@ func trainingImportVideoJobResponse(requestID string) (TrainingImportVideoRespon Accepted: true, Running: true, RequestID: requestID, + Analysis: trainingAnalysisStatusPayload(requestID), }, http.StatusAccepted } @@ -1796,6 +2117,7 @@ func trainingImportVideoJobResponse(requestID string) (TrainingImportVideoRespon resp := *job.response resp.Running = false resp.RequestID = requestID + resp.Analysis = trainingAnalysisStatusPayload(requestID) if resp.Error == "" { resp.Error = job.errorText } @@ -1824,6 +2146,32 @@ func trainingImportVideoStatusHandler(w http.ResponseWriter, r *http.Request) { trainingWriteJSON(w, statusCode, resp) } +func trainingAnalysisStatusHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + requestID := strings.TrimSpace(r.URL.Query().Get("requestId")) + if requestID == "" { + requestID = strings.TrimSpace(r.URL.Query().Get("id")) + } + + payload := trainingAnalysisStatusPayload(requestID) + if payload == nil { + trainingWriteJSON(w, http.StatusNotFound, map[string]any{ + "ok": false, + "error": "Analyse-Status nicht gefunden.", + }) + return + } + + trainingWriteJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "analysis": payload, + }) +} + func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") @@ -1898,6 +2246,9 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) { seconds := trainingFrameSecondsForVideo(duration, req.Count) sourceFile := filepath.Base(outPath) previewURL := "" + sourceFileWithFrame := func(index int) string { + return fmt.Sprintf("%s (%d / %d)", sourceFile, index+1, len(seconds)) + } requestID := strings.TrimSpace(req.AnalysisRequestID) if requestID == "" { @@ -1933,7 +2284,7 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) { startedAtMs, stepBase+1, totalSteps, - sourceFile, + sourceFileWithFrame(i), previewURL, fmt.Sprintf("Frame %d/%d wird extrahiert…", i+1, len(seconds)), ) @@ -1954,7 +2305,7 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) { startedAtMs, stepBase+2, totalSteps, - sourceFile, + sourceFileWithFrame(i), previewURL, fmt.Sprintf("Frame %d/%d wird analysiert…", i+1, len(seconds)), ) @@ -1964,7 +2315,7 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) { sample := &TrainingSample{ SampleID: id, FrameURL: "/api/training/frame?id=" + id, - SourceFile: sourceFile, + SourceFile: sourceFileWithFrame(i), SourcePath: outPath, SourceSizeBytes: sourceSizeBytes, Second: second, @@ -1977,7 +2328,7 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) { startedAtMs, stepBase+3, totalSteps, - sourceFile, + sourceFileWithFrame(i), previewURL, fmt.Sprintf("Frame %d/%d wird gespeichert…", i+1, len(seconds)), ) @@ -2034,6 +2385,38 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) { return } + req := trainingNextRequestFromHTTP(r) + + if strings.TrimSpace(req.AnalysisRequestID) == "" { + req.AnalysisRequestID = trainingMakeSampleID("training-next", float64(time.Now().UnixNano())) + } + + async := r.URL.Query().Get("async") == "1" || + strings.EqualFold(r.URL.Query().Get("async"), "true") + + if async { + trainingStartNextJob(req) + resp, statusCode := trainingNextJobResponse(req.AnalysisRequestID) + trainingWriteJSON(w, statusCode, resp) + return + } + + resp, statusCode, errorText := trainingRunNextRequest(req) + if statusCode >= 400 || !resp.OK || resp.Sample == nil { + if errorText == "" { + errorText = resp.Error + } + if errorText == "" { + errorText = "Trainingsbild konnte nicht geladen werden." + } + trainingWriteError(w, statusCode, errorText) + return + } + + trainingWriteJSON(w, http.StatusOK, resp.Sample) +} + +func trainingNextRequestFromHTTP(r *http.Request) TrainingNextRequest { forceNew := r.URL.Query().Get("force") == "1" || strings.EqualFold(r.URL.Query().Get("force"), "true") @@ -2044,15 +2427,42 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) { preferUncertain := strings.EqualFold(r.URL.Query().Get("mode"), "uncertain") || strings.EqualFold(r.URL.Query().Get("sampleMode"), "uncertain") - root, err := trainingRootDir() - if err != nil { - trainingWriteError(w, http.StatusInternalServerError, err.Error()) - return - } - refreshPrediction := r.URL.Query().Get("refresh") == "1" || strings.EqualFold(r.URL.Query().Get("refresh"), "true") + return TrainingNextRequest{ + ForceNew: forceNew, + RefreshPrediction: refreshPrediction, + PreferUncertain: preferUncertain, + AnalysisRequestID: analysisRequestID, + ExcludeIDs: excludeIDs, + } +} + +func trainingRunNextRequest(req TrainingNextRequest) (TrainingNextResponse, int, string) { + forceNew := req.ForceNew + refreshPrediction := req.RefreshPrediction + preferUncertain := req.PreferUncertain + analysisRequestID := strings.TrimSpace(req.AnalysisRequestID) + if analysisRequestID == "" { + analysisRequestID = trainingMakeSampleID("training-next", float64(time.Now().UnixNano())) + } + + excludeIDs := req.ExcludeIDs + if excludeIDs == nil { + excludeIDs = map[string]bool{} + } + + root, err := trainingRootDir() + if err != nil { + return TrainingNextResponse{ + OK: false, + RequestID: analysisRequestID, + Error: err.Error(), + Analysis: trainingAnalysisStatusPayload(analysisRequestID), + }, http.StatusInternalServerError, err.Error() + } + if !forceNew && !preferUncertain { var startedAtMs int64 @@ -2079,8 +2489,12 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) { ) } - trainingWriteError(w, http.StatusInternalServerError, err.Error()) - return + return TrainingNextResponse{ + OK: false, + RequestID: analysisRequestID, + Error: err.Error(), + Analysis: trainingAnalysisStatusPayload(analysisRequestID), + }, http.StatusInternalServerError, err.Error() } else if ok { if refreshPrediction { trainingPublishAnalysisFinished( @@ -2092,20 +2506,23 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) { ) } - trainingWriteJSON(w, http.StatusOK, sample) - return + return TrainingNextResponse{ + OK: true, + RequestID: analysisRequestID, + Sample: sample, + Analysis: trainingAnalysisStatusPayload(analysisRequestID), + }, http.StatusOK, "" } } + totalSteps := 4 + if preferUncertain { + totalSteps = trainingUncertainCandidateCount*4 + 1 + } + startedAtMs := trainingPublishAnalysisStarted( analysisRequestID, - func() int { - if preferUncertain { - return trainingUncertainCandidateCount*4 + 1 - } - - return 4 - }(), + totalSteps, "", func() string { if preferUncertain { @@ -2132,19 +2549,147 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) { "Trainingsbild konnte nicht erstellt werden.", err, ) - trainingWriteError(w, http.StatusInternalServerError, err.Error()) - return + return TrainingNextResponse{ + OK: false, + RequestID: analysisRequestID, + Error: err.Error(), + Analysis: trainingAnalysisStatusPayload(analysisRequestID), + }, http.StatusInternalServerError, err.Error() } trainingPublishAnalysisFinished( analysisRequestID, startedAtMs, - 4, + totalSteps, sample.SourceFile, "Analyse abgeschlossen.", ) - trainingWriteJSON(w, http.StatusOK, sample) + return TrainingNextResponse{ + OK: true, + RequestID: analysisRequestID, + Sample: sample, + Analysis: trainingAnalysisStatusPayload(analysisRequestID), + }, http.StatusOK, "" +} + +func trainingPruneNextJobsLocked(now time.Time) { + for requestID, job := range trainingNextJobs.items { + if job == nil || job.running { + continue + } + if !job.finishedAt.IsZero() && now.Sub(job.finishedAt) > trainingNextJobTTL { + delete(trainingNextJobs.items, requestID) + } + } +} + +func trainingStartNextJob(req TrainingNextRequest) { + requestID := strings.TrimSpace(req.AnalysisRequestID) + if requestID == "" { + requestID = trainingMakeSampleID("training-next", float64(time.Now().UnixNano())) + req.AnalysisRequestID = requestID + } + + now := time.Now().UTC() + + trainingNextJobs.Lock() + trainingPruneNextJobsLocked(now) + if _, exists := trainingNextJobs.items[requestID]; exists { + trainingNextJobs.Unlock() + return + } + + trainingNextJobs.items[requestID] = &trainingNextJobState{ + requestID: requestID, + startedAt: now, + running: true, + statusCode: http.StatusAccepted, + } + trainingNextJobs.Unlock() + + go func() { + resp, statusCode, errorText := trainingRunNextRequest(req) + resp.RequestID = requestID + resp.Analysis = trainingAnalysisStatusPayload(requestID) + + trainingNextJobs.Lock() + if job := trainingNextJobs.items[requestID]; job != nil { + job.running = false + job.finishedAt = time.Now().UTC() + job.statusCode = statusCode + job.response = &resp + job.errorText = strings.TrimSpace(errorText) + } + trainingNextJobs.Unlock() + }() +} + +func trainingNextJobResponse(requestID string) (TrainingNextResponse, int) { + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return TrainingNextResponse{OK: false, Error: "requestId missing"}, http.StatusBadRequest + } + + trainingNextJobs.Lock() + job := trainingNextJobs.items[requestID] + trainingNextJobs.Unlock() + + if job == nil { + return TrainingNextResponse{ + OK: false, + RequestID: requestID, + Error: "Analyse-Job nicht gefunden.", + Analysis: trainingAnalysisStatusPayload(requestID), + }, http.StatusNotFound + } + + if job.running { + return TrainingNextResponse{ + OK: true, + Accepted: true, + Running: true, + RequestID: requestID, + Analysis: trainingAnalysisStatusPayload(requestID), + }, http.StatusAccepted + } + + if job.response != nil { + resp := *job.response + resp.Running = false + resp.RequestID = requestID + resp.Analysis = trainingAnalysisStatusPayload(requestID) + if resp.Error == "" { + resp.Error = job.errorText + } + statusCode := job.statusCode + if statusCode < 100 { + statusCode = http.StatusOK + } + return resp, statusCode + } + + return TrainingNextResponse{ + OK: false, + RequestID: requestID, + Error: job.errorText, + Analysis: trainingAnalysisStatusPayload(requestID), + }, http.StatusInternalServerError +} + +func trainingNextStatusHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + requestID := strings.TrimSpace(r.URL.Query().Get("requestId")) + if requestID == "" { + requestID = strings.TrimSpace(r.URL.Query().Get("id")) + } + + resp, statusCode := trainingNextJobResponse(requestID) + trainingWriteJSON(w, statusCode, resp) } func trainingLatestOpenSample( @@ -2912,6 +3457,18 @@ func trainingRunJob(ctx context.Context, root string, count int) { python := trainingPythonExe() appLogln("ML-Python für Training:", python) + runtimeOpts := trainingRuntimeOptionsFromSettings() + appLogf( + "Training-Laufzeit: mode=%s cpuCores=%d schonmodus=%v threads=%d workers=%d yoloBatch=%d lowPriority=%v videoMAE=%v", + runtimeOpts.PerformanceMode, + runtimeOpts.CPUCoreCount, + runtimeOpts.PowerSaveMode, + runtimeOpts.CPUThreads, + runtimeOpts.Workers, + runtimeOpts.YoloBatchSize, + runtimeOpts.LowPriority, + runtimeOpts.VideoMAEEnabled, + ) cleanOutput := func(text string) string { out := strings.TrimSpace(text) @@ -2967,7 +3524,24 @@ func trainingRunJob(ctx context.Context, root string, count int) { s.Step = "YOLO26 Detector wird trainiert…" }) + detectorBasePath := "yolo26n.pt" + if p, err := trainingMLCacheFilePath("yolo26n.pt"); err == nil { + detectorBasePath = p + } + detectorScript := trainingScriptPath("train_detector_model.py") + detectorArgs := []string{ + "--root", root, + "--base", detectorBasePath, + "--epochs", strconv.Itoa(trainingDetectorEpochs()), + "--imgsz", "640", + "--workers", strconv.Itoa(runtimeOpts.Workers), + "--threads", strconv.Itoa(runtimeOpts.CPUThreads), + } + if runtimeOpts.YoloBatchSize > 0 { + detectorArgs = append(detectorArgs, "--batch", strconv.Itoa(runtimeOpts.YoloBatchSize)) + } + detectorOut, detectorErr := trainingRunCommandStreaming( ctx, python, @@ -2980,10 +3554,7 @@ func trainingRunJob(ctx context.Context, root string, count int) { "YOLO26 Detector wird trainiert…", ) }, - "--root", root, - "--base", "yolo26n.pt", - "--epochs", strconv.Itoa(trainingDetectorEpochs()), - "--imgsz", "640", + detectorArgs..., ) if errors.Is(detectorErr, errTrainingCancelled) { @@ -3077,6 +3648,18 @@ func trainingRunJob(ctx context.Context, root string, count int) { } poseScript := trainingScriptPath("train_pose_model.py") + poseArgs := []string{ + "--root", root, + "--base", poseBasePath, + "--epochs", strconv.Itoa(trainingDetectorEpochs()), + "--imgsz", "640", + "--workers", strconv.Itoa(runtimeOpts.Workers), + "--threads", strconv.Itoa(runtimeOpts.CPUThreads), + } + if runtimeOpts.YoloBatchSize > 0 { + poseArgs = append(poseArgs, "--batch", strconv.Itoa(runtimeOpts.YoloBatchSize)) + } + poseOut, poseErr := trainingRunCommandStreaming( ctx, python, @@ -3089,10 +3672,7 @@ func trainingRunJob(ctx context.Context, root string, count int) { "YOLO26 Pose wird trainiert...", ) }, - "--root", root, - "--base", poseBasePath, - "--epochs", strconv.Itoa(trainingDetectorEpochs()), - "--imgsz", "640", + poseArgs..., ) if errors.Is(poseErr, errTrainingCancelled) { @@ -3132,100 +3712,114 @@ func trainingRunJob(ctx context.Context, root string, count int) { poseOutputClean := cleanOutput(poseOutput) - trainingSetJobStatus(func(s *TrainingJobStatus) { - if s.Progress < 84 { - s.Progress = 84 - } - s.Step = "VideoMAE Clip-Daten werden aufgebaut..." - }) - - videoMAETrainCount, videoMAEValCount, videoMAEWritten, videoMAESyncErr := - trainingSyncVideoMAEDataset(ctx, root) - if errors.Is(videoMAESyncErr, context.Canceled) || errors.Is(videoMAESyncErr, errTrainingCancelled) { - appLogln("VideoMAE dataset sync cancelled") - trainingFinishCancelled(root) - return - } - if videoMAESyncErr != nil { - videoMAEStatus = "failed" - videoMAEOutput = "VideoMAE-Dataset konnte nicht aufgebaut werden: " + videoMAESyncErr.Error() + if !runtimeOpts.VideoMAEEnabled { + trainingSetJobStatus(func(s *TrainingJobStatus) { + if s.Progress < 84 { + s.Progress = 84 + } + s.Step = "VideoMAE wurde in den Settings übersprungen." + }) + videoMAEStatus = "skipped_disabled" + videoMAEOutput = "VideoMAE übersprungen: in den Training-Settings deaktiviert." appLogln(videoMAEOutput) } else { - appLogf( - "VideoMAE samples synced: written=%d train=%d val=%d", - videoMAEWritten, - videoMAETrainCount, - videoMAEValCount, - ) - } - - if videoMAEStatus != "failed" && - videoMAETrainCount >= minVideoMAETrainCount && - videoMAEValCount >= minVideoMAEValCount { trainingSetJobStatus(func(s *TrainingJobStatus) { - if s.Progress < 86 { - s.Progress = 86 + if s.Progress < 84 { + s.Progress = 84 } - s.Step = "VideoMAE Clip-Classifier wird trainiert..." + s.Step = "VideoMAE Clip-Daten werden aufgebaut..." }) - videoMAEScript := trainingScriptPath("train_videomae_model.py") - videoMAEArgs := []string{ - "--root", root, - "--epochs", strconv.Itoa(trainingVideoMAEEpochs()), - "--batch-size", strconv.Itoa(trainingVideoMAEBatchSize()), - "--num-frames", strconv.Itoa(trainingVideoMAENumFrames), - } - if base := strings.TrimSpace(os.Getenv("VIDEOMAE_BASE_MODEL")); base != "" { - videoMAEArgs = append(videoMAEArgs, "--base", base) - } - - videoMAEOut, videoMAEErr := trainingRunCommandStreaming( - ctx, - python, - videoMAEScript, - func(line string) bool { - return trainingHandleProgressLine( - line, - 86, - 98, - "VideoMAE Clip-Classifier wird trainiert...", - ) - }, - videoMAEArgs..., - ) - - if errors.Is(videoMAEErr, errTrainingCancelled) { - appLogln("VideoMAE training cancelled") + videoMAETrainCount, videoMAEValCount, videoMAEWritten, videoMAESyncErr := + trainingSyncVideoMAEDataset(ctx, root) + if errors.Is(videoMAESyncErr, context.Canceled) || errors.Is(videoMAESyncErr, errTrainingCancelled) { + appLogln("VideoMAE dataset sync cancelled") trainingFinishCancelled(root) return } - - videoMAEOutput = videoMAEOut - videoMAEOutputClean := cleanOutput(videoMAEOutput) - - if videoMAEErr != nil { + if videoMAESyncErr != nil { videoMAEStatus = "failed" - appLogln("VideoMAE training failed:", videoMAEErr) - if videoMAEOutputClean != "" { - appLogln("VideoMAE output:", videoMAEOutputClean) - } + videoMAEOutput = "VideoMAE-Dataset konnte nicht aufgebaut werden: " + videoMAESyncErr.Error() + appLogln(videoMAEOutput) } else { - videoMAEStatus = "trained" - if videoMAEOutputClean != "" { - appLogln("VideoMAE training:", videoMAEOutputClean) - } + appLogf( + "VideoMAE samples synced: written=%d train=%d val=%d", + videoMAEWritten, + videoMAETrainCount, + videoMAEValCount, + ) + } + + if videoMAEStatus != "failed" && + videoMAETrainCount >= minVideoMAETrainCount && + videoMAEValCount >= minVideoMAEValCount { + trainingSetJobStatus(func(s *TrainingJobStatus) { + if s.Progress < 86 { + s.Progress = 86 + } + s.Step = "VideoMAE Clip-Classifier wird trainiert..." + }) + + videoMAEScript := trainingScriptPath("train_videomae_model.py") + videoMAEArgs := []string{ + "--root", root, + "--epochs", strconv.Itoa(trainingVideoMAEEpochs()), + "--batch-size", strconv.Itoa(trainingVideoMAEBatchSize()), + "--num-frames", strconv.Itoa(trainingVideoMAENumFrames), + "--workers", strconv.Itoa(runtimeOpts.Workers), + "--threads", strconv.Itoa(runtimeOpts.CPUThreads), + } + if base := strings.TrimSpace(os.Getenv("VIDEOMAE_BASE_MODEL")); base != "" { + videoMAEArgs = append(videoMAEArgs, "--base", base) + } + + videoMAEOut, videoMAEErr := trainingRunCommandStreaming( + ctx, + python, + videoMAEScript, + func(line string) bool { + return trainingHandleProgressLine( + line, + 86, + 98, + "VideoMAE Clip-Classifier wird trainiert...", + ) + }, + videoMAEArgs..., + ) + + if errors.Is(videoMAEErr, errTrainingCancelled) { + appLogln("VideoMAE training cancelled") + trainingFinishCancelled(root) + return + } + + videoMAEOutput = videoMAEOut + videoMAEOutputClean := cleanOutput(videoMAEOutput) + + if videoMAEErr != nil { + videoMAEStatus = "failed" + appLogln("VideoMAE training failed:", videoMAEErr) + if videoMAEOutputClean != "" { + appLogln("VideoMAE output:", videoMAEOutputClean) + } + } else { + videoMAEStatus = "trained" + if videoMAEOutputClean != "" { + appLogln("VideoMAE training:", videoMAEOutputClean) + } + } + } else if videoMAEStatus != "failed" { + videoMAEStatus = "skipped_no_videomae_data" + videoMAEOutput = fmt.Sprintf( + "VideoMAE übersprungen: zu wenige Clip-Beispiele. Train=%d, Val=%d. Benoetigt: mindestens %d Train und %d Val.", + videoMAETrainCount, + videoMAEValCount, + minVideoMAETrainCount, + minVideoMAEValCount, + ) + appLogln(videoMAEOutput) } - } else if videoMAEStatus != "failed" { - videoMAEStatus = "skipped_no_videomae_data" - videoMAEOutput = fmt.Sprintf( - "VideoMAE übersprungen: zu wenige Clip-Beispiele. Train=%d, Val=%d. Benoetigt: mindestens %d Train und %d Val.", - videoMAETrainCount, - videoMAEValCount, - minVideoMAETrainCount, - minVideoMAEValCount, - ) - appLogln(videoMAEOutput) } videoMAEOutputClean := cleanOutput(videoMAEOutput) @@ -3281,6 +3875,10 @@ func trainingRunJob(ctx context.Context, root string, count int) { if detectorStatus == "trained" || poseStatus == "trained" { message += " VideoMAE wurde übersprungen: zu wenige Clip-Beispiele." } + } else if videoMAEStatus == "skipped_disabled" { + if detectorStatus == "trained" || poseStatus == "trained" { + message += " VideoMAE wurde übersprungen: in den Settings deaktiviert." + } } else if videoMAEStatus == "failed" { message += " VideoMAE ist fehlgeschlagen." if videoMAEOutputClean != "" { diff --git a/backend/training_priority_other.go b/backend/training_priority_other.go new file mode 100644 index 0000000..f620aad --- /dev/null +++ b/backend/training_priority_other.go @@ -0,0 +1,46 @@ +//go:build !windows + +package main + +import ( + "os/exec" + "syscall" + "time" +) + +func applyTrainingLowPriorityBeforeStart(cmd *exec.Cmd, enabled bool) { + _ = cmd + _ = enabled +} + +func applyTrainingLowPriorityAfterStart(cmd *exec.Cmd, enabled bool) { + if cmd == nil || cmd.Process == nil || !enabled { + return + } + + _ = syscall.Setpriority(syscall.PRIO_PROCESS, cmd.Process.Pid, 10) +} + +func prepareTrainingCommandForCancel(cmd *exec.Cmd) { + if cmd == nil { + return + } + + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + + cmd.SysProcAttr.Setpgid = true +} + +func terminateTrainingCommand(cmd *exec.Cmd) { + if cmd == nil || cmd.Process == nil || cmd.Process.Pid <= 0 { + return + } + + pid := cmd.Process.Pid + _ = syscall.Kill(-pid, syscall.SIGTERM) + time.Sleep(800 * time.Millisecond) + _ = syscall.Kill(-pid, syscall.SIGKILL) + _ = cmd.Process.Kill() +} diff --git a/backend/training_priority_windows.go b/backend/training_priority_windows.go new file mode 100644 index 0000000..de7eeef --- /dev/null +++ b/backend/training_priority_windows.go @@ -0,0 +1,54 @@ +//go:build windows + +package main + +import ( + "os/exec" + "strconv" + "syscall" +) + +const windowsBelowNormalPriorityClass = 0x00004000 +const windowsCreateNewProcessGroup = 0x00000200 + +func applyTrainingLowPriorityBeforeStart(cmd *exec.Cmd, enabled bool) { + if cmd == nil { + return + } + + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + + if enabled { + cmd.SysProcAttr.CreationFlags |= windowsBelowNormalPriorityClass + } +} + +func applyTrainingLowPriorityAfterStart(cmd *exec.Cmd, enabled bool) { + _ = cmd + _ = enabled +} + +func prepareTrainingCommandForCancel(cmd *exec.Cmd) { + if cmd == nil { + return + } + + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + + cmd.SysProcAttr.CreationFlags |= windowsCreateNewProcessGroup +} + +func terminateTrainingCommand(cmd *exec.Cmd) { + if cmd == nil || cmd.Process == nil || cmd.Process.Pid <= 0 { + return + } + + killCmd := exec.Command("taskkill", "/PID", strconv.Itoa(cmd.Process.Pid), "/T", "/F") + hideCommandWindow(killCmd) + _ = killCmd.Run() + _ = cmd.Process.Kill() +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e76e0a9..2e8a35f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,7 +5,7 @@ import './App.css' import Button from './components/ui/Button' import CookieModal from './components/ui/CookieModal' import Tabs, { type TabItem } from './components/ui/Tabs' -import RecorderSettings from './components/ui/RecorderSettings' +import Settings from './components/ui/Settings' import FinishedDownloads from './components/ui/FinishedDownloads' import Player from './components/ui/Player' import type { RecordJob, JobEvent, RecorderSettingsState, TaskStateEvent, AnalysisProgressEvent, BackgroundSplitProgress } from './types' @@ -84,11 +84,16 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = { generateAssetsTeaser: true, generateAssetsSprites: true, generateAssetsAnalyze: true, - enrichPostworkEnabled: true, - trainingRecognitionEnabled: true, trainingDetectorEpochs: 60, + trainingPerformanceMode: 'auto', + trainingPowerSaveMode: false, + trainingCpuThreads: 0, + trainingWorkers: 2, + trainingYoloBatchSize: 0, + trainingLowPriority: false, + trainingVideoMAEEnabled: true, } type StoredModel = { @@ -4498,7 +4503,7 @@ export default function App() { {selectedTab === 'categories' ? : null} {selectedTab === 'settings' ? ( - ) : null} diff --git a/frontend/src/components/ui/FinishedDownloads.tsx b/frontend/src/components/ui/FinishedDownloads.tsx index 9ac08a7..792412f 100644 --- a/frontend/src/components/ui/FinishedDownloads.tsx +++ b/frontend/src/components/ui/FinishedDownloads.tsx @@ -2314,6 +2314,7 @@ export default function FinishedDownloads({ const [deletingKeys, setDeletingKeys] = useState>(() => new Set()) const [deletedSuccessKeys, setDeletedSuccessKeys] = useState>(() => new Set()) const [keepingKeys, setKeepingKeys] = useState>(() => new Set()) + const [keptSuccessKeys, setKeptSuccessKeys] = useState>(() => new Set()) const [removingKeys, setRemovingKeys] = useState>(() => new Set()) const [hiddenFiles, setHiddenFiles] = useState>(() => new Set()) const [restoredJobsByKey, setRestoredJobsByKey] = useState>({}) @@ -3156,6 +3157,7 @@ export default function FinishedDownloads({ deletingKeys.size > 0 || deletedSuccessKeys.size > 0 || keepingKeys.size > 0 || + keptSuccessKeys.size > 0 || removingKeys.size > 0 // ----------------------------------------------------------------------------- @@ -3461,6 +3463,15 @@ export default function FinishedDownloads({ }) }, []) + const markKeptSuccess = useCallback((key: string, value: boolean) => { + setKeptSuccessKeys((prev) => { + const next = new Set(prev) + if (value) next.add(key) + else next.delete(key) + return next + }) + }, []) + const markDeleted = useCallback((key: string) => { setDeletedKeys((prev) => { const next = new Set(prev) @@ -3612,6 +3623,9 @@ export default function FinishedDownloads({ }) markDeletedSuccess(key, false) + markKeptSuccess(key, false) + markDeleting(key, false) + markKeeping(key, false) markDeleted(key) markRemoving(key, false) @@ -3619,7 +3633,16 @@ export default function FinishedDownloads({ refreshHoverPreviewFromPointer() }) }, - [cancelRemoveTimer, markDeletedSuccess, markDeleted, markRemoving, refreshHoverPreviewFromPointer] + [ + cancelRemoveTimer, + markDeletedSuccess, + markKeptSuccess, + markDeleting, + markKeeping, + markDeleted, + markRemoving, + refreshHoverPreviewFromPointer, + ] ) const restoreRow = useCallback( @@ -3647,6 +3670,7 @@ export default function FinishedDownloads({ setDeletingKeys(removeKeyAliases) setDeletedSuccessKeys(removeKeyAliases) setKeepingKeys(removeKeyAliases) + setKeptSuccessKeys(removeKeyAliases) if (fileAliases.size > 0) { setHiddenFiles((prev) => { @@ -3681,6 +3705,78 @@ export default function FinishedDownloads({ [cancelRemoveTimer, markDeleting, markRemoving, markDeletedSuccess, removeRowNow] ) + const markDeletedRowSuccess = useCallback( + (key: string) => { + cancelRemoveTimer(key) + markDeleting(key, false) + markKeeping(key, false) + markRemoving(key, false) + markKeptSuccess(key, false) + markDeletedSuccess(key, true) + }, + [ + cancelRemoveTimer, + markDeleting, + markKeeping, + markRemoving, + markKeptSuccess, + markDeletedSuccess, + ] + ) + + const markKeptRowSuccess = useCallback( + (key: string) => { + cancelRemoveTimer(key) + markDeleting(key, false) + markKeeping(key, false) + markRemoving(key, false) + markDeletedSuccess(key, false) + markKeptSuccess(key, true) + }, + [ + cancelRemoveTimer, + markDeleting, + markKeeping, + markRemoving, + markDeletedSuccess, + markKeptSuccess, + ] + ) + + const removeRowsTogether = useCallback( + (items: Array<{ key: string; file?: string }>, delayMs = 780) => { + const uniqueItems = Array.from( + items.reduce((map, item) => { + if (!item.key) return map + if (!map.has(item.key)) map.set(item.key, item) + return map + }, new Map()) + .values() + ) + + if (uniqueItems.length === 0) return + + for (const item of uniqueItems) { + cancelRemoveTimer(item.key) + } + + const t = window.setTimeout(() => { + for (const item of uniqueItems) { + removeTimersRef.current.delete(item.key) + } + + for (const item of uniqueItems) { + removeRowNow(item.key, item.file) + } + }, delayMs) + + for (const item of uniqueItems) { + removeTimersRef.current.set(item.key, t) + } + }, + [cancelRemoveTimer, removeRowNow] + ) + const animateRemove = useCallback( (key: string, file?: string) => { if (file) { @@ -4445,6 +4541,7 @@ export default function FinishedDownloads({ const selectedDeleteFiles = selectedDeleteItems.map((item) => item.file) const deletedKeys = new Set() const failedKeys = new Set() + const deletedItems: Array<{ file: string; key: string }> = [] const clearDeleting = (keys: Iterable) => { setDeletingKeys((prev) => { @@ -4459,18 +4556,20 @@ export default function FinishedDownloads({ const handleResult = (item: any) => { if (item?.done) return - const file = String(item?.file || '').trim() + const file = baseName(String(item?.file || '').trim()) if (!file) return + const selectedItem = selectedDeleteItems.find((selected) => selected.file === file) const key = + selectedItem?.key || fileToKeyRef.current.get(file) || - selectedDeleteItems.find((selected) => selected.file === file)?.key || file if (item?.ok) { if (!deletedKeys.has(key)) { deletedKeys.add(key) - completeDeletedRow(key, file) + deletedItems.push({ key, file }) + markDeletedRowSuccess(key) } return } @@ -4561,6 +4660,7 @@ export default function FinishedDownloads({ emitCountHint(-deletedCount) if (deletedCount > 0) { + removeRowsTogether(deletedItems) notify.success?.('Löschen erfolgreich', `${deletedCount} Downloads gelöscht.`) } } catch (e: any) { @@ -4570,6 +4670,7 @@ export default function FinishedDownloads({ if (deletedKeys.size > 0) { clearSelection() emitCountHint(-deletedKeys.size) + removeRowsTogether(deletedItems) notify.success?.('L\u00f6schen erfolgreich', `${deletedKeys.size} Downloads gel\u00f6scht.`) } @@ -4583,23 +4684,80 @@ export default function FinishedDownloads({ selectedJobsForBulk, clearSelection, emitCountHint, - completeDeletedRow, + markDeletedRowSuccess, + removeRowsTogether, notify, ]) const bulkKeepSelected = useCallback(async () => { if (bulkBusy) return - if (selectedFiles.length === 0) return + if (selectedJobsForBulk.length === 0) return const ok = window.confirm(`${selectedFiles.length} Downloads behalten?`) if (!ok) return + const selectedKeepItems = selectedJobsForBulk + .map((job) => { + const file = baseName(job.output || '') + const cleanFile = String(file || '').trim() + if (!cleanFile) return null + + return { + file: cleanFile, + key: keyFor(job), + } + }) + .filter(Boolean) as Array<{ file: string; key: string }> + + if (selectedKeepItems.length === 0) return + + const selectedKeepFiles = selectedKeepItems.map((item) => item.file) + const selectedKeepKeys = selectedKeepItems.map((item) => item.key) + const keptKeys = new Set() + const failedKeys = new Set() + const keptItems: Array<{ file: string; key: string }> = [] + + const clearKeeping = (keys: Iterable) => { + setKeepingKeys((prev) => { + const next = new Set(prev) + for (const key of keys) { + next.delete(key) + } + return next + }) + } + + const handleKeepSuccess = (file: string) => { + const cleanFile = baseName(String(file || '').trim()) + if (!cleanFile) return + + const selectedItem = selectedKeepItems.find((selected) => selected.file === cleanFile) + const key = + selectedItem?.key || + fileToKeyRef.current.get(cleanFile) || + cleanFile + + if (keptKeys.has(key)) return + + keptKeys.add(key) + keptItems.push({ key, file: cleanFile }) + markKeptRowSuccess(key) + } + + setKeepingKeys((prev) => { + const next = new Set(prev) + for (const key of selectedKeepKeys) { + next.add(key) + } + return next + }) + setBulkBusy(true) try { const res = await fetch('/api/record/keep-many', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ files: selectedFiles }), + body: JSON.stringify({ files: selectedKeepFiles }), }) if (!res.ok) { @@ -4611,27 +4769,64 @@ export default function FinishedDownloads({ const results = Array.isArray(data?.results) ? data.results : [] for (const item of results) { - if (!item?.ok) continue - - const file = String(item.file || '').trim() + const file = baseName(String(item.file || '').trim()) if (!file) continue + const selectedItem = selectedKeepItems.find((selected) => selected.file === file) const key = + selectedItem?.key || fileToKeyRef.current.get(file) || file - animateRemove(key, file) + if (item?.ok) { + handleKeepSuccess(file) + continue + } + + failedKeys.add(key) + clearKeeping([key]) } - const keptCount = Number(data?.kept ?? results.filter((x: any) => x?.ok).length ?? 0) + if (results.length === 0) { + const keptFromResponse = Number(data?.kept ?? 0) + const count = Number.isFinite(keptFromResponse) + ? Math.min(selectedKeepItems.length, Math.max(0, Math.floor(keptFromResponse))) + : 0 + + for (const item of selectedKeepItems.slice(0, count)) { + handleKeepSuccess(item.file) + } + } + + for (const item of selectedKeepItems) { + if (!keptKeys.has(item.key) && !failedKeys.has(item.key)) { + failedKeys.add(item.key) + } + } + + if (failedKeys.size > 0) { + clearKeeping(failedKeys) + } + + const keptCount = keptKeys.size clearSelection() emitCountHint(includeKeep ? 0 : -keptCount) if (keptCount > 0) { + removeRowsTogether(keptItems) notify.success?.('Keep erfolgreich', `${keptCount} Downloads behalten.`) } } catch (e: any) { + clearKeeping(selectedKeepKeys.filter((key) => !keptKeys.has(key))) + + if (keptKeys.size > 0) { + clearSelection() + emitCountHint(includeKeep ? 0 : -keptKeys.size) + removeRowsTogether(keptItems) + notify.success?.('Keep erfolgreich', `${keptKeys.size} Downloads behalten.`) + } + notify.error('Bulk-Keep fehlgeschlagen', String(e?.message || e)) } finally { setBulkBusy(false) @@ -4639,7 +4834,9 @@ export default function FinishedDownloads({ }, [ bulkBusy, selectedFiles, - animateRemove, + selectedJobsForBulk, + markKeptRowSuccess, + removeRowsTogether, clearSelection, emitCountHint, includeKeep, @@ -6609,6 +6806,7 @@ export default function FinishedDownloads({ inlinePlay={inlinePlay} deletingKeys={deletingKeys} deletedSuccessKeys={deletedSuccessKeys} + keptSuccessKeys={keptSuccessKeys} keepingKeys={keepingKeys} removingKeys={removingKeys} swipeRefs={swipeRefs} @@ -6691,6 +6889,7 @@ export default function FinishedDownloads({ assetNonceForJob={assetNonceForJob} deletingKeys={deletingKeys} deletedSuccessKeys={deletedSuccessKeys} + keptSuccessKeys={keptSuccessKeys} keepingKeys={keepingKeys} removingKeys={removingKeys} modelsByKey={modelsByKey} @@ -6743,6 +6942,7 @@ export default function FinishedDownloads({ formatBytes={formatBytes} deletingKeys={deletingKeys} deletedSuccessKeys={deletedSuccessKeys} + keptSuccessKeys={keptSuccessKeys} keepingKeys={keepingKeys} removingKeys={removingKeys} registerTeaserHost={registerTeaserHost} diff --git a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx index 97c727c..aa9e026 100644 --- a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx @@ -77,6 +77,7 @@ type Props = { deletingKeys: Set deletedSuccessKeys: Set + keptSuccessKeys: Set keepingKeys: Set removingKeys: Set @@ -339,6 +340,7 @@ function FinishedDownloadsCardsView({ inlinePlay, deletingKeys, deletedSuccessKeys, + keptSuccessKeys, keepingKeys, removingKeys, @@ -530,7 +532,9 @@ function FinishedDownloadsCardsView({ }) const deleteDone = deletedSuccessKeys.has(k) - const busy = deletingKeys.has(k) || deleteDone || keepingKeys.has(k) || removingKeys.has(k) + const keepDone = keptSuccessKeys.has(k) + const keepBusy = keepingKeys.has(k) + const busy = deletingKeys.has(k) || deleteDone || keepBusy || keepDone || removingKeys.has(k) const model = modelNameFromOutput(j.output) const fileRaw = baseName(j.output || '') @@ -637,7 +641,7 @@ function FinishedDownloadsCardsView({ busy && 'pointer-events-none opacity-70', deletingKeys.has(k) && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30', deleteDone && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30', - keepingKeys.has(k) && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30', + (keepBusy || keepDone) && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30', removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]', ] .filter(Boolean) @@ -890,6 +894,11 @@ function FinishedDownloadsCardsView({ {deletingKeys.has(k) || deleteDone ? ( + ) : keepBusy || keepDone ? ( + ) : null} @@ -1220,6 +1229,7 @@ function FinishedDownloadsCardsView({ selectionModeActive={selectionModeActive} isDeleting={deletingKeys.has(k)} isDeleted={deletedSuccessKeys.has(k)} + isKept={keptSuccessKeys.has(k)} isKeeping={keepingKeys.has(k)} isRemoving={removingKeys.has(k)} postworkBadge={getPostworkSummaryForOutput(j.output, postworkByFile)} diff --git a/frontend/src/components/ui/FinishedDownloadsDesktopCard.tsx b/frontend/src/components/ui/FinishedDownloadsDesktopCard.tsx index 557bfc1..1a8980b 100644 --- a/frontend/src/components/ui/FinishedDownloadsDesktopCard.tsx +++ b/frontend/src/components/ui/FinishedDownloadsDesktopCard.tsx @@ -50,6 +50,7 @@ export type FinishedDownloadsDesktopCardProps = { selectionModeActive: boolean isDeleting: boolean isDeleted: boolean + isKept: boolean isKeeping: boolean isRemoving: boolean postworkBadge?: FinishedPostworkSummary @@ -143,6 +144,7 @@ const FinishedDownloadsDesktopCard = memo( selectionModeActive, isDeleting, isDeleted, + isKept, isKeeping, isRemoving, postworkBadge, @@ -186,7 +188,7 @@ const FinishedDownloadsDesktopCard = memo( renderRatingOverlay, }: FinishedDownloadsDesktopCardProps) { const k = keyFor(j) - const busy = isDeleting || isDeleted || isKeeping || isRemoving + const busy = isDeleting || isDeleted || isKeeping || isKept || isRemoving const checked = useSelectionChecked(selectionStore, k) const longPressTimerRef = useRef(null) const longPressTriggeredRef = useRef(false) @@ -338,7 +340,7 @@ const FinishedDownloadsDesktopCard = memo( busy && 'pointer-events-none opacity-70', isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30', isDeleted && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30', - isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30', + (isKeeping || isKept) && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30', isRemoving && 'opacity-0 translate-y-2 scale-[0.98]', ].filter(Boolean).join(' ')} onClick={(e) => { @@ -494,6 +496,11 @@ const FinishedDownloadsDesktopCard = memo( {isDeleting || isDeleted ? ( + ) : isKeeping || isKept ? ( + ) : null} @@ -615,6 +622,7 @@ const FinishedDownloadsDesktopCard = memo( prev.job === next.job && prev.isDeleting === next.isDeleting && prev.isDeleted === next.isDeleted && + prev.isKept === next.isKept && prev.isKeeping === next.isKeeping && prev.isRemoving === next.isRemoving && samePostworkSummaryLite(prev.postworkBadge, next.postworkBadge) && diff --git a/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx b/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx index ea8e044..44d9a75 100644 --- a/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx +++ b/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx @@ -75,6 +75,7 @@ type Props = { deletingKeys: Set deletedSuccessKeys: Set + keptSuccessKeys: Set keepingKeys: Set removingKeys: Set @@ -134,6 +135,7 @@ function FinishedDownloadsGalleryCardInner({ activeTagSet, deletingKeys, deletedSuccessKeys, + keptSuccessKeys, keepingKeys, removingKeys, registerTeaserHost, @@ -307,8 +309,9 @@ function FinishedDownloadsGalleryCardInner({ const isRemoving = removingKeys.has(k) const isDeleting = deletingKeys.has(k) const isDeleted = deletedSuccessKeys.has(k) + const isKept = keptSuccessKeys.has(k) const isKeeping = keepingKeys.has(k) - const busy = isDeleting || isDeleted || isKeeping || isRemoving + const busy = isDeleting || isDeleted || isKeeping || isKept || isRemoving const sprite = useMemo( () => @@ -408,7 +411,7 @@ function FinishedDownloadsGalleryCardInner({ busy && !isRemoving && 'pointer-events-none opacity-70', isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30', isDeleted && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30', - isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30', + (isKeeping || isKept) && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30', isRemoving ? 'pointer-events-none opacity-0 translate-y-2 scale-[0.98]' : 'opacity-100 translate-y-0 scale-100', ].filter(Boolean).join(' ')} > @@ -565,6 +568,11 @@ function FinishedDownloadsGalleryCardInner({ {isDeleting || isDeleted ? ( + ) : isKeeping || isKept ? ( + ) : null} diff --git a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx index c6d314d..f4188ba 100644 --- a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx @@ -47,6 +47,7 @@ type Props = { deletingKeys: Set deletedSuccessKeys: Set + keptSuccessKeys: Set keepingKeys: Set removingKeys: Set @@ -118,6 +119,7 @@ function FinishedDownloadsGalleryView({ formatBytes, deletingKeys, deletedSuccessKeys, + keptSuccessKeys, keepingKeys, removingKeys, registerTeaserHost, @@ -397,6 +399,7 @@ function FinishedDownloadsGalleryView({ activeTagSet={activeTagSet} deletingKeys={deletingKeys} deletedSuccessKeys={deletedSuccessKeys} + keptSuccessKeys={keptSuccessKeys} keepingKeys={keepingKeys} removingKeys={removingKeys} registerTeaserHost={registerTeaserHostIfNeeded} diff --git a/frontend/src/components/ui/FinishedDownloadsTableView.tsx b/frontend/src/components/ui/FinishedDownloadsTableView.tsx index ced6770..0a1a2f4 100644 --- a/frontend/src/components/ui/FinishedDownloadsTableView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsTableView.tsx @@ -69,6 +69,7 @@ type Props = { // state/flags for actions row + rowClassName deletingKeys: Set deletedSuccessKeys: Set + keptSuccessKeys: Set keepingKeys: Set removingKeys: Set @@ -132,6 +133,7 @@ export default function FinishedDownloadsTableView({ deletingKeys, deletedSuccessKeys, + keptSuccessKeys, keepingKeys, removingKeys, @@ -313,6 +315,8 @@ export default function FinishedDownloadsTableView({ const k = keyFor(j) const isDeleting = deletingKeys.has(k) const isDeleted = deletedSuccessKeys.has(k) + const isKeeping = keepingKeys.has(k) + const isKept = keptSuccessKeys.has(k) const isPreviewActive = teaserState.activeKey === k || teaserState.hoverKey === k const allowSound = shouldEnableTeaserAudio({ @@ -390,6 +394,13 @@ export default function FinishedDownloadsTableView({ {isDeleting || isDeleted ? ( + ) : isKeeping || isKept ? ( + ) : null} @@ -557,7 +568,12 @@ export default function FinishedDownloadsTableView({ srOnlyHeader: true, cell: (j) => { const k = keyFor(j) - const busy = deletingKeys.has(k) || deletedSuccessKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k) + const busy = + deletingKeys.has(k) || + deletedSuccessKeys.has(k) || + keepingKeys.has(k) || + keptSuccessKeys.has(k) || + removingKeys.has(k) const fileRaw = baseName(j.output || '') const isHot = isHotName(fileRaw) @@ -621,6 +637,7 @@ export default function FinishedDownloadsTableView({ resolutions, deletingKeys, deletedSuccessKeys, + keptSuccessKeys, keepingKeys, removingKeys, previewSpriteInfoOf, @@ -680,6 +697,7 @@ export default function FinishedDownloadsTableView({ (deletingKeys.has(k) || removingKeys.has(k)) && 'bg-red-50/60 dark:bg-red-500/10 pointer-events-none', deletingKeys.has(k) && 'animate-pulse', deletedSuccessKeys.has(k) && 'bg-emerald-50/60 dark:bg-emerald-500/10 pointer-events-none', + keptSuccessKeys.has(k) && 'bg-emerald-50/60 dark:bg-emerald-500/10 pointer-events-none', (keepingKeys.has(k) || removingKeys.has(k)) && 'pointer-events-none', keepingKeys.has(k) && 'bg-emerald-50/60 dark:bg-emerald-500/10 animate-pulse', removingKeys.has(k) && 'opacity-0', @@ -687,7 +705,7 @@ export default function FinishedDownloadsTableView({ .filter(Boolean) .join(' ') }, - [keyFor, deletingKeys, deletedSuccessKeys, keepingKeys, removingKeys] + [keyFor, deletingKeys, deletedSuccessKeys, keptSuccessKeys, keepingKeys, removingKeys] ) return ( diff --git a/frontend/src/components/ui/RecordJobActions.tsx b/frontend/src/components/ui/RecordJobActions.tsx index 498fbf0..f0b97f3 100644 --- a/frontend/src/components/ui/RecordJobActions.tsx +++ b/frontend/src/components/ui/RecordJobActions.tsx @@ -138,7 +138,15 @@ export default function RecordJobActions({ const btnBase = variant === 'table' - ? `inline-flex items-center justify-center rounded-md ${pad} hover:bg-gray-100/70 dark:hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/60 disabled:opacity-50 disabled:cursor-not-allowed transition-transform active:scale-95` + ? [ + 'inline-flex items-center justify-center rounded-md', + pad, + 'bg-gray-50/90 text-gray-700 shadow-sm ring-1 ring-gray-200/80', + 'hover:bg-white hover:text-gray-950 hover:ring-gray-300', + 'dark:bg-white/5 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10 dark:hover:text-white', + 'focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/60', + 'disabled:cursor-not-allowed disabled:opacity-50 transition-transform active:scale-95', + ].join(' ') : 'inline-flex items-center justify-center rounded-md ' + `bg-white/75 ${pad} text-gray-900 backdrop-blur ring-1 ring-black/10 ` + 'hover:bg-white/90 ' + @@ -152,7 +160,7 @@ export default function RecordJobActions({ favOn: 'text-amber-600 dark:text-amber-300', likeOn: 'text-rose-600 dark:text-rose-300', hotOn: 'text-amber-600 dark:text-amber-300', - off: 'text-gray-500 dark:text-gray-300', + off: 'text-gray-800 dark:text-gray-100', keep: 'text-emerald-600 dark:text-emerald-300', del: 'text-red-600 dark:text-red-300', watchOn: 'text-sky-600 dark:text-sky-300', @@ -169,6 +177,8 @@ export default function RecordJobActions({ // ✅ Reihenfolge strikt nach `order` (wenn gesetzt). Keys die nicht im order stehen: niemals anzeigen. + const outlineStrokeWidth = variant === 'table' ? 1.75 : undefined + const actionOrder: ActionKey[] = order ?? ['watch', 'favorite', 'like', 'training', 'split', 'hot', 'keep', 'delete', 'details'] const inOrder = (k: ActionKey) => actionOrder.includes(k) @@ -318,7 +328,7 @@ export default function RecordJobActions({ }} > - + {detailsLabel} @@ -341,7 +351,7 @@ export default function RecordJobActions({ {addState === 'ok' ? ( ) : ( - + )} Zu Downloads @@ -362,7 +372,7 @@ export default function RecordJobActions({ }} > - + Ins Training übernehmen @@ -386,6 +396,7 @@ export default function RecordJobActions({ iconFill, colors.off )} + strokeWidth={outlineStrokeWidth} /> - + ) : null @@ -518,7 +532,7 @@ export default function RecordJobActions({ onClick={run(onKeep)} > - + ) : null @@ -533,7 +547,7 @@ export default function RecordJobActions({ onClick={run(onDelete)} > - + ) : null @@ -728,7 +742,7 @@ export default function RecordJobActions({ fireDetails() }} > - + Details ) @@ -748,7 +762,7 @@ export default function RecordJobActions({ await doAdd() }} > - + Zu Downloads ) @@ -768,7 +782,7 @@ export default function RecordJobActions({ await doTrainingImport() }} > - + Ins Training übernehmen ) @@ -788,7 +802,7 @@ export default function RecordJobActions({ await onSplit?.(job) }} > - + Video schneiden ) @@ -811,7 +825,7 @@ export default function RecordJobActions({ {isFavorite ? ( ) : ( - + )} {isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'} @@ -835,7 +849,7 @@ export default function RecordJobActions({ {isLiked ? ( ) : ( - + )} {isLiked ? 'Gefällt mir entfernen' : 'Gefällt mir'} @@ -859,7 +873,7 @@ export default function RecordJobActions({ {isWatching ? ( ) : ( - + )} {isWatching ? 'Watched entfernen' : 'Watched'} @@ -883,7 +897,7 @@ export default function RecordJobActions({ {isHot ? ( ) : ( - + )} {isHot ? 'HOT entfernen' : 'Als HOT markieren'} @@ -908,7 +922,7 @@ export default function RecordJobActions({ } }} > - + Behalten ) @@ -932,7 +946,7 @@ export default function RecordJobActions({ } }} > - + Löschen ) @@ -967,7 +981,7 @@ export default function RecordJobActions({ }} > - + diff --git a/frontend/src/components/ui/RecorderSettings.tsx b/frontend/src/components/ui/Settings.tsx similarity index 84% rename from frontend/src/components/ui/RecorderSettings.tsx rename to frontend/src/components/ui/Settings.tsx index fed7213..3fc50ef 100644 --- a/frontend/src/components/ui/RecorderSettings.tsx +++ b/frontend/src/components/ui/Settings.tsx @@ -12,7 +12,9 @@ import PostgresUrlModal from './PostgresUrlModal' import { CheckIcon, ChevronDownIcon, XMarkIcon } from '@heroicons/react/24/solid' import { startRegistration } from '@simplewebauthn/browser' -type RecorderSettings = { +type TrainingPerformanceMode = 'auto' | 'eco' | 'balanced' | 'performance' | 'custom' + +type Settings = { databaseType?: 'postgres' databaseUrl?: string hasDbPassword?: boolean @@ -46,6 +48,19 @@ type RecorderSettings = { trainingRecognitionEnabled?: boolean trainingDetectorEpochs?: number + trainingPerformanceMode?: TrainingPerformanceMode | string + trainingPowerSaveMode?: boolean + trainingCpuThreads?: number + trainingWorkers?: number + trainingYoloBatchSize?: number + trainingLowPriority?: boolean + trainingVideoMAEEnabled?: boolean + trainingCpuCoreCount?: number + trainingEffectiveMode?: TrainingPerformanceMode | string + trainingEffectiveCpuThreads?: number + trainingEffectiveWorkers?: number + trainingEffectiveYoloBatchSize?: number + trainingEffectiveLowPriority?: boolean } type AuthDevice = { @@ -79,6 +94,13 @@ type AIServerStatus = { error?: string } +type TrainingJobStatus = { + running?: boolean + progress?: number + step?: string + message?: string +} + type AppLog = { ok: boolean exists?: boolean @@ -279,7 +301,7 @@ function SettingsSection(props: { ) } -const DEFAULTS: RecorderSettings = { +const DEFAULTS: Settings = { databaseType: 'postgres', databaseUrl: '', hasDbPassword: false, @@ -313,6 +335,70 @@ const DEFAULTS: RecorderSettings = { trainingRecognitionEnabled: true, trainingDetectorEpochs: 60, + trainingPerformanceMode: 'auto', + trainingPowerSaveMode: false, + trainingCpuThreads: 0, + trainingWorkers: 2, + trainingYoloBatchSize: 0, + trainingLowPriority: false, + trainingVideoMAEEnabled: true, +} + +const TRAINING_PERFORMANCE_MODES: Array<{ + key: TrainingPerformanceMode + title: string + description: string +}> = [ + { + key: 'auto', + title: 'Automatisch', + description: 'Wählt anhand der CPU des Servers einen passenden Modus.', + }, + { + key: 'eco', + title: 'Schonend', + description: 'Minimale Last, niedrige Priorität, langsameres Training.', + }, + { + key: 'balanced', + title: 'Ausgewogen', + description: 'Guter Kompromiss aus Tempo und Temperatur.', + }, + { + key: 'performance', + title: 'Leistung', + description: 'Mehr Threads und größere Batches für schnellere Läufe.', + }, + { + key: 'custom', + title: 'Manuell', + description: 'Eigene Werte für Threads, Worker, Batch und Priorität.', + }, +] + +function normalizeTrainingPerformanceMode(value: unknown): TrainingPerformanceMode { + const raw = String(value ?? '').trim().toLowerCase() + + if (raw === 'eco' || raw === 'schonend' || raw === 'powersave' || raw === 'power-save') { + return 'eco' + } + if (raw === 'balanced' || raw === 'ausgewogen' || raw === 'normal') { + return 'balanced' + } + if (raw === 'performance' || raw === 'leistung' || raw === 'fast') { + return 'performance' + } + if (raw === 'custom' || raw === 'manual' || raw === 'manuell') { + return 'custom' + } + + return 'auto' +} + +function formatTrainingEffectiveValue(value: unknown, fallback = 'Auto') { + const n = Number(value) + if (!Number.isFinite(n) || n <= 0) return fallback + return String(Math.round(n)) } type Props = { @@ -678,8 +764,8 @@ function RatingThresholdStars({ ) } -export default function RecorderSettings({ onAssetsGenerated }: Props) { - const [value, setValue] = useState(DEFAULTS) +export default function Settings({ onAssetsGenerated }: Props) { + const [value, setValue] = useState(DEFAULTS) const [saving, setSaving] = useState(false) const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState(0) const saveSuccessTimerRef = useRef(null) @@ -701,6 +787,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { const [dbRestoreBusy, setDbRestoreBusy] = useState(false) const [dbMaintenanceMsg, setDbMaintenanceMsg] = useState(null) const [dbMaintenanceErr, setDbMaintenanceErr] = useState(null) + const [trainingJobStatus, setTrainingJobStatus] = useState(null) const now = Date.now() const saveSucceeded = saveSuccessUntilMs > now const saveFailed = Boolean(err) && !saving && !saveSucceeded @@ -715,6 +802,25 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { const savedDatabaseConfigured = Boolean(loadedDatabaseUrl) const databaseConfigured = savedDatabaseConfigured const dbMaintenanceBusy = dbBackupBusy || dbRestoreBusy + const trainingPerformanceMode = normalizeTrainingPerformanceMode( + value.trainingPerformanceMode ?? DEFAULTS.trainingPerformanceMode + ) + const trainingEffectiveMode = normalizeTrainingPerformanceMode( + value.trainingEffectiveMode ?? trainingPerformanceMode + ) + const trainingManualMode = trainingPerformanceMode === 'custom' + const trainingCpuCoreCount = Math.max(0, Math.round(Number(value.trainingCpuCoreCount ?? 0))) + const trainingEffectiveCpuThreads = value.trainingEffectiveCpuThreads ?? value.trainingCpuThreads + const trainingEffectiveWorkers = value.trainingEffectiveWorkers ?? value.trainingWorkers + const trainingEffectiveYoloBatchSize = value.trainingEffectiveYoloBatchSize ?? value.trainingYoloBatchSize + const trainingEffectiveLowPriority = + value.trainingEffectiveLowPriority ?? ( + trainingPerformanceMode === 'eco' || + (trainingPerformanceMode === 'custom' && !!value.trainingLowPriority) + ) + const trainingSettingsLocked = Boolean(trainingJobStatus?.running) + const enrichPostworkTemporarilyDisabled = + trainingSettingsLocked && !!value.enrichPostworkEnabled const [authStatus, setAuthStatus] = useState(null) const [securityBusy, setSecurityBusy] = useState(false) @@ -850,7 +956,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { if (!r.ok) throw new Error(await r.text()) return r.json() }) - .then((data: RecorderSettings) => { + .then((data: Settings) => { if (!alive) return const databaseType = normalizeDatabaseType((data as any).databaseType) setValue({ @@ -904,6 +1010,32 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { (data as any).trainingRecognitionEnabled ?? DEFAULTS.trainingRecognitionEnabled, trainingDetectorEpochs: (data as any).trainingDetectorEpochs ?? DEFAULTS.trainingDetectorEpochs, + trainingPerformanceMode: + normalizeTrainingPerformanceMode((data as any).trainingPerformanceMode ?? DEFAULTS.trainingPerformanceMode), + trainingPowerSaveMode: + (data as any).trainingPowerSaveMode ?? DEFAULTS.trainingPowerSaveMode, + trainingCpuThreads: + (data as any).trainingCpuThreads ?? DEFAULTS.trainingCpuThreads, + trainingWorkers: + (data as any).trainingWorkers ?? DEFAULTS.trainingWorkers, + trainingYoloBatchSize: + (data as any).trainingYoloBatchSize ?? DEFAULTS.trainingYoloBatchSize, + trainingLowPriority: + (data as any).trainingLowPriority ?? DEFAULTS.trainingLowPriority, + trainingVideoMAEEnabled: + (data as any).trainingVideoMAEEnabled ?? DEFAULTS.trainingVideoMAEEnabled, + trainingCpuCoreCount: + (data as any).trainingCpuCoreCount ?? DEFAULTS.trainingCpuCoreCount, + trainingEffectiveMode: + normalizeTrainingPerformanceMode((data as any).trainingEffectiveMode ?? (data as any).trainingPerformanceMode), + trainingEffectiveCpuThreads: + (data as any).trainingEffectiveCpuThreads ?? DEFAULTS.trainingEffectiveCpuThreads, + trainingEffectiveWorkers: + (data as any).trainingEffectiveWorkers ?? DEFAULTS.trainingEffectiveWorkers, + trainingEffectiveYoloBatchSize: + (data as any).trainingEffectiveYoloBatchSize ?? DEFAULTS.trainingEffectiveYoloBatchSize, + trainingEffectiveLowPriority: + (data as any).trainingEffectiveLowPriority ?? DEFAULTS.trainingEffectiveLowPriority, }) setLoadedDatabaseType(databaseType) setLoadedDatabaseUrl(String((data as any).databaseUrl ?? '').trim()) @@ -916,6 +1048,47 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { } }, []) + useEffect(() => { + let alive = true + + const loadTrainingStatus = async () => { + try { + const res = await fetch('/api/training/status', { cache: 'no-store' }) + const data = await res.json().catch(() => null) + + if (!alive) return + + if (!res.ok || !data) { + setTrainingJobStatus(null) + return + } + + const job = data?.training && typeof data.training === 'object' + ? data.training + : null + + setTrainingJobStatus(job + ? { + running: Boolean(job.running), + progress: Number(job.progress || 0), + step: String(job.step || ''), + message: String(job.message || ''), + } + : null) + } catch { + if (alive) setTrainingJobStatus(null) + } + } + + void loadTrainingStatus() + const timer = window.setInterval(loadTrainingStatus, 2500) + + return () => { + alive = false + window.clearInterval(timer) + } + }, []) + useEffect(() => { let alive = true @@ -1418,6 +1591,26 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { 1, Math.min(300, Math.floor(Number(value.trainingDetectorEpochs ?? DEFAULTS.trainingDetectorEpochs ?? 60))) ) + const trainingPerformanceMode = normalizeTrainingPerformanceMode( + value.trainingPerformanceMode ?? DEFAULTS.trainingPerformanceMode + ) + const trainingPowerSaveMode = + trainingPerformanceMode === 'eco' || + (trainingPerformanceMode === 'custom' && !!value.trainingPowerSaveMode) + const trainingCpuThreads = Math.max( + 0, + Math.min(32, Math.floor(Number(value.trainingCpuThreads ?? DEFAULTS.trainingCpuThreads ?? 0))) + ) + const trainingWorkers = Math.max( + 0, + Math.min(16, Math.floor(Number(value.trainingWorkers ?? DEFAULTS.trainingWorkers ?? 2))) + ) + const trainingYoloBatchSize = Math.max( + 0, + Math.min(64, Math.floor(Number(value.trainingYoloBatchSize ?? DEFAULTS.trainingYoloBatchSize ?? 0))) + ) + const trainingLowPriority = !!value.trainingLowPriority + const trainingVideoMAEEnabled = value.trainingVideoMAEEnabled !== false setSaving(true) try { @@ -1456,12 +1649,20 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { enrichPostworkEnabled, trainingRecognitionEnabled, trainingDetectorEpochs, + trainingPerformanceMode, + trainingPowerSaveMode, + trainingCpuThreads, + trainingWorkers, + trainingYoloBatchSize, + trainingLowPriority, + trainingVideoMAEEnabled, }), }) if (!res.ok) { const t = await res.text().catch(() => '') throw new Error(t || `HTTP ${res.status}`) } + const savedSettings = await res.json().catch(() => null) // Button-Label: "Gespeichert" für 2.5s const until = Date.now() + 2500 setSaveSuccessUntilMs(until) @@ -1476,8 +1677,15 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { window.dispatchEvent(new CustomEvent('recorder-settings-updated')) setValue((v) => ({ ...v, + ...(savedSettings && typeof savedSettings === 'object' ? savedSettings : {}), databaseType, databaseUrl, + trainingPerformanceMode: normalizeTrainingPerformanceMode( + savedSettings?.trainingPerformanceMode ?? trainingPerformanceMode + ), + trainingEffectiveMode: normalizeTrainingPerformanceMode( + savedSettings?.trainingEffectiveMode ?? savedSettings?.trainingPerformanceMode ?? trainingPerformanceMode + ), })) const databaseChanged = @@ -2397,16 +2605,27 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
setValue((v) => ({ ...v, enrichPostworkEnabled: checked, })) } + disabled={trainingSettingsLocked} label="EnrichQ-Postwork im Hintergrund" - description="Wenn aktiv, werden fehlende Analyse-/Enrich-Assets automatisch im Hintergrund nachgeholt. Wenn deaktiviert, werden keine neuen EnrichQ-Hintergrundjobs gestartet; manuelle Asset-Generierung bleibt möglich." + description={ + trainingSettingsLocked + ? 'Während das Training läuft, ist EnrichQ temporär pausiert. Nach dem Training gilt wieder die gespeicherte Einstellung.' + : 'Wenn aktiv, werden fehlende Analyse-/Enrich-Assets automatisch im Hintergrund nachgeholt. Wenn deaktiviert, werden keine neuen EnrichQ-Hintergrundjobs gestartet; manuelle Asset-Generierung bleibt möglich.' + } /> + + {enrichPostworkTemporarilyDisabled ? ( +
+ EnrichQ-Postwork ist pausiert, solange das Training läuft. +
+ ) : null} setValue((v) => ({ @@ -2635,6 +2855,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { })) } className="h-9 w-32 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm + disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-gray-900 dark:text-gray-100" /> @@ -2644,6 +2865,231 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
+ +
+
+
+
+ Training-Schonmodus +
+
+ Wähle einen Leistungsmodus. „Automatisch“ passt Threads, Worker und Batchgröße anhand der CPU des Servers an. + Änderungen an diesen Trainingsparametern gelten ab dem nächsten Trainingsstart. +
+
+ + {trainingCpuCoreCount > 0 ? ( + + {trainingCpuCoreCount} CPU-Threads erkannt + + ) : null} +
+ + {trainingSettingsLocked ? ( +
+ Training läuft gerade. Leistungsmodus, Epochs, Threads, Worker, Batchgröße und VideoMAE können erst nach Abschluss oder Abbruch des aktuellen Trainings geändert werden. + {trainingJobStatus?.step ? ( + {trainingJobStatus.step} + ) : null} +
+ ) : ( +
+ Hinweis: Diese Werte werden beim Start des Trainings an Python/PyTorch übergeben und wirken nicht nachträglich auf bereits laufende Trainingsprozesse. +
+ )} + +
+ {TRAINING_PERFORMANCE_MODES.map((mode) => { + const active = trainingPerformanceMode === mode.key + + return ( + + ) + })} +
+ +
+
+
Wirksam
+
{trainingEffectiveMode}
+
+
+
Threads
+
{formatTrainingEffectiveValue(trainingEffectiveCpuThreads, 'Auto')}
+
+
+
Worker
+
{formatTrainingEffectiveValue(trainingEffectiveWorkers, '0')}
+
+
+
YOLO-Batch
+
{formatTrainingEffectiveValue(trainingEffectiveYoloBatchSize, 'Auto')}
+
+
+
Priorität
+
{trainingEffectiveLowPriority ? 'Niedrig' : 'Normal'}
+
+
+ +
+
+ +
+ + setValue((v) => ({ + ...v, + trainingCpuThreads: Number(e.target.value || 0), + })) + } + className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-gray-900 dark:text-gray-100" + /> + + 0 = Auto + +
+
+ +
+ +
+ + setValue((v) => ({ + ...v, + trainingWorkers: Number(e.target.value || 0), + })) + } + className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-gray-900 dark:text-gray-100" + /> + + 0-16 + +
+
+ +
+ +
+ + setValue((v) => ({ + ...v, + trainingYoloBatchSize: Number(e.target.value || 0), + })) + } + className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-gray-900 dark:text-gray-100" + /> + + 0 = Auto + +
+
+
+ +
+ + setValue((v) => ({ + ...v, + trainingLowPriority: checked, + })) + } + disabled={!trainingManualMode || trainingSettingsLocked} + label="Niedrige Prozesspriorität" + description="Im manuellen Modus steuerbar. Presets setzen die Priorität passend zum gewählten Leistungsmodus." + /> + + + setValue((v) => ({ + ...v, + trainingVideoMAEEnabled: checked, + })) + } + disabled={trainingSettingsLocked} + label="VideoMAE mittrainieren" + description="Deaktivieren spart viel CPU-Zeit. YOLO Detector und Pose können weiterhin trainiert werden." + /> +
+
| null | undefined): } function TrainingStageOverlay(props: { - mode: 'training' | 'analysis' + mode: 'training' | 'analysis' | 'saving' + title?: string text?: string + sourceFile?: string + frameLabel?: string + statusText?: string progress?: number backgroundUrl?: string visible?: boolean @@ -1140,6 +1144,7 @@ function TrainingStageOverlay(props: { }) { const progress = clampPercent(props.progress ?? 0) const isTraining = props.mode === 'training' + const isSaving = props.mode === 'saving' const visible = props.visible ?? true const [displayedBackgroundUrl, setDisplayedBackgroundUrl] = useState('') @@ -1221,15 +1226,29 @@ function TrainingStageOverlay(props: { ? 'transition-opacity duration-200 ease-out will-change-opacity motion-reduce:transition-none' : 'transition-opacity duration-500 ease-out will-change-opacity motion-reduce:transition-none' - const title = isTraining ? 'Training läuft…' : 'Analyse läuft…' + const title = props.title || ( + isTraining + ? 'Training läuft…' + : isSaving + ? 'Speichert…' + : 'Analyse läuft…' + ) const fallbackText = isTraining ? 'Bitte warten. Die Oberfläche ist währenddessen gesperrt.' - : 'Bild wird erstellt und analysiert. Bitte warten.' + : isSaving + ? 'Feedback wird gespeichert. Bitte warten.' + : 'Bild wird erstellt und analysiert. Bitte warten.' + const sourceFile = String(props.sourceFile || '').trim() + const frameLabel = String(props.frameLabel || '').trim() + const statusText = String(props.statusText || props.text || fallbackText).trim() + const hasStructuredDetails = Boolean(sourceFile || frameLabel) + const primaryText = hasStructuredDetails ? statusText : title + const secondaryText = hasStructuredDetails ? sourceFile : statusText return (
-
+
-
- {title} +
+ {primaryText}
-
- {props.text || fallbackText} -
- -
+ {secondaryText ? (
-
+ className="mt-1 flex max-w-full items-center justify-center gap-1.5" + title={secondaryText} + > +
+ {secondaryText} +
-
- {Math.round(progress)}% + {frameLabel ? ( +
+ {frameLabel} +
+ ) : null} +
+ ) : frameLabel ? ( +
+
+ Frame +
+ +
+ {frameLabel} +
+
+ ) : null} + +
+
+
+
+ +
+ {Math.round(progress)}% +
) } +function compactTrainingSourceFile(sourceFile: string) { + let cleanSourceFile = String(sourceFile || '').trim() + let frameLabel = '' + + const sourceFrameMatch = cleanSourceFile.match(/^(.*?)\s*\((\d+)\s*\/\s*(\d+)\)\s*$/) + + if (sourceFrameMatch) { + cleanSourceFile = sourceFrameMatch[1].trim() + frameLabel = `${sourceFrameMatch[2]} / ${sourceFrameMatch[3]}` + } + + return { + sourceFile: cleanSourceFile, + frameLabel, + } +} + +function withTrainingFrameLabels(samples: TrainingSample[]) { + if (samples.length <= 1) return samples + + return samples.map((sample, index) => { + const sourceDetails = compactTrainingSourceFile(sample.sourceFile) + const sourceFile = sourceDetails.sourceFile || String(sample.sourceFile || '').trim() + + if (!sourceFile || sourceDetails.frameLabel) { + return sample + } + + return { + ...sample, + sourceFile: `${sourceFile} (${index + 1} / ${samples.length})`, + } + }) +} + +function formatTrainingStageStatus(value: string) { + const text = String(value || '').trim() + + if (!text) return text + + return text.charAt(0).toLocaleUpperCase('de-DE') + text.slice(1) +} + +function compactTrainingStageDetails(sourceFile: string, stepText: string) { + const sourceDetails = compactTrainingSourceFile(sourceFile) + let cleanSourceFile = sourceDetails.sourceFile + let frameLabel = sourceDetails.frameLabel + let statusText = String(stepText || '').trim() + + const stepFrameMatch = statusText.match(/^Frame\s+(\d+)\s*\/\s*(\d+)\s+(.+)$/i) + + if (stepFrameMatch) { + if (!frameLabel) { + frameLabel = `${stepFrameMatch[1]} / ${stepFrameMatch[2]}` + } + + statusText = stepFrameMatch[3].trim() + } + + return { + sourceFile: cleanSourceFile, + frameLabel, + statusText: formatTrainingStageStatus(statusText || 'Bild wird geladen…'), + } +} + function labelTileClass(active: boolean) { return [ 'group flex min-h-[58px] w-full flex-col items-center justify-center gap-1 rounded-xl px-2 py-1.5 text-center text-[10px] font-semibold leading-tight ring-1 transition sm:min-h-[74px] sm:py-2', @@ -2753,6 +2863,7 @@ const TRAINING_INFO_DISMISSED_STORAGE_KEY = 'training:info-dismissed-key' const TRAINING_IMAGE_EXPANDED_STORAGE_KEY = 'training:image-expanded' const TRAINING_PENDING_IMPORT_VIDEO_STORAGE_KEY = 'training:pending-import-video' const TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY = 'training:active-import-video' +const TRAINING_ACTIVE_NEXT_STORAGE_KEY = 'training:active-next' export default function TrainingTab(props: { active?: boolean @@ -2770,6 +2881,7 @@ export default function TrainingTab(props: { const [analysisStep, setAnalysisStep] = useState('') const [analysisSourceFile, setAnalysisSourceFile] = useState('') const [saving, setSaving] = useState(false) + const [savingOverlayText, setSavingOverlayText] = useState('') const [training, setTraining] = useState(false) const [trainingStatus, setTrainingStatus] = useState(null) const [deletingTrainingData, setDeletingTrainingData] = useState(false) @@ -3015,8 +3127,8 @@ export default function TrainingTab(props: { setFeedbackModalOpen(false) window.requestAnimationFrame(() => { - mobileLabelsScrollRef.current?.scrollTo({ - top: 0, + mobileLabelsScrollRef.current?.scrollIntoView({ + block: 'start', behavior: 'smooth', }) }) @@ -3100,6 +3212,7 @@ export default function TrainingTab(props: { const videoImportStartedRef = useRef(false) const videoImportInFlightKeyRef = useRef(null) + const nextAnalysisInFlightRequestIdRef = useRef(null) const epochTimingRef = useRef<{ @@ -3182,16 +3295,12 @@ export default function TrainingTab(props: { const scrollMobileSectionToTop = useCallback( (key: keyof typeof expandedCorrectionSections) => { window.requestAnimationFrame(() => { - const scrollEl = mobileLabelsScrollRef.current const sectionEl = mobileSectionRefs.current[key] - if (!scrollEl || !sectionEl) return + if (!sectionEl) return - const scrollRect = scrollEl.getBoundingClientRect() - const sectionRect = sectionEl.getBoundingClientRect() - - scrollEl.scrollTo({ - top: scrollEl.scrollTop + sectionRect.top - scrollRect.top, + sectionEl.scrollIntoView({ + block: 'start', behavior: 'smooth', }) }) @@ -3405,6 +3514,80 @@ export default function TrainingTab(props: { setLoadingPreviewFailed(false) }, []) + const applyTrainingAnalysisEvent = useCallback((raw: any, opts?: { requireActiveRequest?: boolean }) => { + const data = raw?.analysis || raw + if (!data) return false + + const requestId = String( + data?.requestId || + data?.analysisRequestId || + '' + ).trim() + + const activeRequestId = activeAnalysisRequestIdRef.current + if (opts?.requireActiveRequest && (!activeRequestId || requestId !== activeRequestId)) { + return false + } + + const scope = String(data?.scope || '').trim() + if (scope && scope !== 'training') { + return false + } + + const running = Boolean(data?.running) + if (running) { + loadingRef.current = true + setLoading(true) + } + + const sourceFile = String(data?.sourceFile || '').trim() + if (sourceFile) { + setAnalysisSourceFile(sourceFile) + } + + const previewUrl = String(data?.previewUrl || '').trim() + if (previewUrl) { + setLoadingPreviewCandidate(previewUrl) + } + + const message = String( + data?.message || + data?.step || + data?.title || + '' + ).trim() + + if (message) { + setAnalysisStep(message) + } + + const current = Number(data?.current ?? data?.stepIndex ?? data?.index) + const total = Number(data?.total ?? data?.steps ?? data?.stepTotal) + const rawProgress = Number(data?.progress ?? data?.percent) + + let nextProgress: number | null = null + + if ( + Number.isFinite(current) && + Number.isFinite(total) && + total > 0 + ) { + nextProgress = (current / total) * 100 + } else if (Number.isFinite(rawProgress)) { + nextProgress = rawProgress <= 1 ? rawProgress * 100 : rawProgress + } + + if (nextProgress !== null) { + setAnalysisProgress((prev) => + running + ? Math.max(prev, clampPercent(nextProgress)) + : clampPercent(nextProgress) + ) + } + + return true + }, [setLoadingPreviewCandidate]) + useEffect(() => { if (!imageSrc) { setFrameImageLoaded(false) @@ -3615,8 +3798,8 @@ export default function TrainingTab(props: { setMobilePanel(trainingRunningRef.current ? 'training' : 'labels') window.requestAnimationFrame(() => { - mobileLabelsScrollRef.current?.scrollTo({ - top: 0, + mobileLabelsScrollRef.current?.scrollIntoView({ + block: 'start', behavior: 'smooth', }) }) @@ -3737,6 +3920,60 @@ export default function TrainingTab(props: { return true }, [currentSampleToQueuedItem, loadQueuedTrainingSample, setQueuedTrainingSamples]) + const completeNextFromData = useCallback((data: any, opts?: { deferCurrentSampleToQueueEnd?: boolean }) => { + const nextSample = data?.sample || ( + data?.sampleId && data?.frameUrl + ? data as TrainingSample + : null + ) + + if (!nextSample) { + throw new Error(backendText(data, 'Es wurde kein Trainingsbild erzeugt.')) + } + + setAnalysisProgress(92) + setAnalysisStep('Analyse-Ergebnis wird übernommen…') + + if (opts?.deferCurrentSampleToQueueEnd) { + deferCurrentSampleToQueueEnd() + } + + loadTrainingSampleIntoTab(nextSample as TrainingSample) + setImageReloadKey((value) => value + 1) + return true + }, [deferCurrentSampleToQueueEnd, loadTrainingSampleIntoTab]) + + const waitForNextResult = useCallback(async ( + requestId: string, + opts?: { deferCurrentSampleToQueueEnd?: boolean } + ) => { + const id = String(requestId || '').trim() + if (!id) throw new Error('requestId fehlt.') + + for (;;) { + const res = await fetch( + `/api/training/next/status?requestId=${encodeURIComponent(id)}`, + { cache: 'no-store' } + ) + const data = await res.json().catch(() => null) + + if (data?.analysis) { + applyTrainingAnalysisEvent(data.analysis) + } + + if (res.status === 202 || data?.running) { + await new Promise((resolve) => window.setTimeout(resolve, 800)) + continue + } + + if (!res.ok || !data?.ok) { + throw new Error(backendText(data, `HTTP ${res.status}`)) + } + + return completeNextFromData(data, opts) + } + }, [applyTrainingAnalysisEvent, completeNextFromData]) + const loadNext = useCallback(async (opts?: { forceNew?: boolean refreshPrediction?: boolean @@ -3747,6 +3984,7 @@ export default function TrainingTab(props: { }) => { const requestId = makeRequestId() activeAnalysisRequestIdRef.current = requestId + nextAnalysisInFlightRequestIdRef.current = requestId const isCurrentRequest = () => activeAnalysisRequestIdRef.current === requestId const mode = opts?.mode ?? trainingSampleModeRef.current @@ -3756,6 +3994,7 @@ export default function TrainingTab(props: { setLoadingPreviewFallbackUrl(previewUrl) setLoadingPreviewCandidate(previewUrl) + loadingRef.current = true setLoading(true) setAnalysisSourceFile('') setAnalysisProgress(8) @@ -3774,15 +4013,37 @@ export default function TrainingTab(props: { setMessage(null) } + let keepActiveJob = false + let completed = false + try { const params = new URLSearchParams() params.set('analysisRequestId', requestId) + params.set('async', '1') if (opts?.forceNew) params.set('force', '1') if (opts?.refreshPrediction) params.set('refresh', '1') if (uncertainMode) params.set('mode', 'uncertain') + try { + window.sessionStorage.setItem( + TRAINING_ACTIVE_NEXT_STORAGE_KEY, + JSON.stringify({ + requestId, + opts: { + forceNew: Boolean(opts?.forceNew), + refreshPrediction: Boolean(opts?.refreshPrediction), + mode, + previewUrl, + deferCurrentSampleToQueueEnd: Boolean(opts?.deferCurrentSampleToQueueEnd), + }, + }) + ) + } catch { + // ignore + } + const url = `/api/training/next${params.toString() ? `?${params.toString()}` : ''}` setAnalysisProgress(uncertainMode ? 5 : 25) @@ -3803,14 +4064,21 @@ export default function TrainingTab(props: { return } - setAnalysisProgress(92) - setAnalysisStep('Analyse-Ergebnis wird übernommen…') - - if (opts?.deferCurrentSampleToQueueEnd) { - deferCurrentSampleToQueueEnd() + if (data?.analysis) { + applyTrainingAnalysisEvent(data.analysis) } - loadTrainingSampleIntoTab(data as TrainingSample) + if (res.status === 202 || data?.accepted || data?.running) { + await waitForNextResult(requestId, { + deferCurrentSampleToQueueEnd: Boolean(opts?.deferCurrentSampleToQueueEnd), + }) + } else { + completeNextFromData(data, { + deferCurrentSampleToQueueEnd: Boolean(opts?.deferCurrentSampleToQueueEnd), + }) + } + + completed = true } catch (e) { if (isCurrentRequest()) { const msg = e instanceof Error ? e.message : String(e) @@ -3818,8 +4086,14 @@ export default function TrainingTab(props: { /load failed|failed to fetch|networkerror|network error/i.test(msg) if (mayStillRun) { + keepActiveJob = true setMessage('Analyse läuft im Backend weiter. Beim Zurückkehren wird das nächste offene Trainingsbild wieder geladen.') } else { + try { + window.sessionStorage.removeItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY) + } catch { + // ignore + } setError(msg) } } @@ -3828,6 +4102,18 @@ export default function TrainingTab(props: { return } + if (completed) { + try { + window.sessionStorage.removeItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY) + } catch { + // ignore + } + } + + if (keepActiveJob) { + return + } + setAnalysisProgress((value) => Math.max(value, 100)) setAnalysisStep((value) => value || 'Analyse abgeschlossen.') @@ -3837,13 +4123,20 @@ export default function TrainingTab(props: { if (activeAnalysisRequestIdRef.current !== finishedRequestId) return activeAnalysisRequestIdRef.current = null + nextAnalysisInFlightRequestIdRef.current = null + loadingRef.current = false setLoading(false) setAnalysisSourceFile('') setAnalysisProgress(0) setAnalysisStep('') }, 500) } - }, [deferCurrentSampleToQueueEnd, loadTrainingSampleIntoTab, setLoadingPreviewCandidate]) + }, [ + applyTrainingAnalysisEvent, + completeNextFromData, + setLoadingPreviewCandidate, + waitForNextResult, + ]) const reloadCurrentImage = useCallback(async () => { setDrawingBox(null) @@ -3868,11 +4161,12 @@ export default function TrainingTab(props: { }, [applyTrainingStatus]) const completeVideoImportFromData = useCallback(async (data: any) => { - const samples: TrainingSample[] = Array.isArray(data?.samples) + const rawSamples: TrainingSample[] = Array.isArray(data?.samples) ? data.samples : data?.sample ? [data.sample] : [] + const samples = withTrainingFrameLabels(rawSamples) if (samples.length === 0) { throw new Error('Es wurden keine Trainingsframes erzeugt.') @@ -4002,11 +4296,12 @@ export default function TrainingTab(props: { return true } - const samples: TrainingSample[] = Array.isArray(data.samples) + const rawSamples: TrainingSample[] = Array.isArray(data.samples) ? data.samples : data.sample ? [data.sample] : [] + const samples = withTrainingFrameLabels(rawSamples) if (samples.length === 0) { throw new Error('Es wurden keine Trainingsframes erzeugt.') @@ -4250,87 +4545,8 @@ export default function TrainingTab(props: { const onAnalysis = (event: Event) => { try { const data = (event as CustomEvent).detail - - const activeRequestId = activeAnalysisRequestIdRef.current - - if (!loadingRef.current || !activeRequestId) return - - const requestId = String( - data?.requestId || - data?.analysisRequestId || - data?.analysis?.requestId || - data?.analysis?.analysisRequestId || - '' - ).trim() - - const scope = String( - data?.scope || - data?.analysis?.scope || - '' - ).trim() - - if (scope !== 'training') return - if (requestId !== activeRequestId) return - - const message = String( - data?.message || - data?.step || - data?.title || - '' - ).trim() - - const sourceFile = String( - data?.sourceFile || - data?.analysis?.sourceFile || - '' - ).trim() - - if (sourceFile) { - setAnalysisSourceFile(sourceFile) - } - - const previewUrl = String( - data?.previewUrl || - data?.analysis?.previewUrl || - '' - ).trim() - - if (previewUrl) { - setLoadingPreviewCandidate(previewUrl) - } - - const current = Number(data?.current ?? data?.stepIndex ?? data?.index) - const total = Number(data?.total ?? data?.steps ?? data?.stepTotal) - - const rawProgress = Number( - data?.progress ?? - data?.percent ?? - data?.analysis?.progress - ) - - let nextProgress: number | null = null - - if ( - Number.isFinite(current) && - Number.isFinite(total) && - total > 0 - ) { - nextProgress = (current / total) * 100 - } else if (Number.isFinite(rawProgress)) { - nextProgress = rawProgress <= 1 - ? rawProgress * 100 - : rawProgress - } - - if (message) { - setAnalysisStep(message) - } - - if (nextProgress !== null) { - setAnalysisProgress((prev) => - Math.max(prev, clampPercent(nextProgress)) - ) - } + if (!loadingRef.current || !activeAnalysisRequestIdRef.current) return + applyTrainingAnalysisEvent(data, { requireActiveRequest: true }) } catch { // ignore } @@ -4341,7 +4557,7 @@ export default function TrainingTab(props: { return () => { window.removeEventListener('app:sse:analysis', onAnalysis as EventListener) } - }, [setLoadingPreviewCandidate]) + }, [applyTrainingAnalysisEvent]) useEffect(() => { const draggingBox = Boolean(drawingBox || boxInteraction) @@ -4489,6 +4705,7 @@ export default function TrainingTab(props: { window.addEventListener('training:import-video', onImportVideo as EventListener) let resumedActiveImport = false + let resumedActiveNext = false try { const activeRaw = window.sessionStorage.getItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY) @@ -4546,7 +4763,75 @@ export default function TrainingTab(props: { } } + const activeNextRaw = resumedActiveImport + ? '' + : window.sessionStorage.getItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY) + + if (activeNextRaw) { + const active = JSON.parse(activeNextRaw) + const requestId = String(active?.requestId || '').trim() + const activeOpts = active?.opts || {} + + if (requestId) { + activeAnalysisRequestIdRef.current = requestId + nextAnalysisInFlightRequestIdRef.current = requestId + loadingRef.current = true + setLoading(true) + setAnalysisSourceFile('') + setAnalysisProgress(5) + setAnalysisStep('Analyse wird fortgesetzt…') + setError(null) + + void fetch( + `/api/training/next/status?requestId=${encodeURIComponent(requestId)}`, + { cache: 'no-store' } + ) + .then((res) => res.json().catch(() => null)) + .then((data) => { + if (data?.analysis) { + applyTrainingAnalysisEvent(data.analysis) + } + }) + .catch(() => { + // ignore; waitForNextResult pollt danach weiter. + }) + + void waitForNextResult(requestId, { + deferCurrentSampleToQueueEnd: Boolean(activeOpts?.deferCurrentSampleToQueueEnd), + }) + .then(() => { + window.sessionStorage.removeItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY) + }) + .catch((e) => { + const msg = e instanceof Error ? e.message : String(e) + const mayStillRun = + /load failed|failed to fetch|networkerror|network error/i.test(msg) + + if (mayStillRun) { + setMessage('Analyse läuft im Backend weiter und wird beim Zurückkehren wieder aufgenommen.') + } else { + window.sessionStorage.removeItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY) + setError(msg) + } + }) + .finally(() => { + if (activeAnalysisRequestIdRef.current === requestId) { + activeAnalysisRequestIdRef.current = null + nextAnalysisInFlightRequestIdRef.current = null + loadingRef.current = false + setLoading(false) + setAnalysisSourceFile('') + setAnalysisProgress(0) + setAnalysisStep('') + } + }) + + resumedActiveNext = true + } + } + const raw = resumedActiveImport + || resumedActiveNext ? '' : window.sessionStorage.getItem(TRAINING_PENDING_IMPORT_VIDEO_STORAGE_KEY) @@ -4561,7 +4846,7 @@ export default function TrainingTab(props: { return () => { window.removeEventListener('training:import-video', onImportVideo as EventListener) } - }, [importVideoIntoTraining, waitForVideoImportResult]) + }, [applyTrainingAnalysisEvent, importVideoIntoTraining, waitForNextResult, waitForVideoImportResult]) useEffect(() => { if (!tabActive || initializedRef.current) return @@ -4579,7 +4864,11 @@ export default function TrainingTab(props: { // Wichtig: // Wenn gerade ein Video-Import über "Video ins Training übernehmen" läuft, // darf loadNext() nicht danach ein zufälliges/letztes Sample darüberlegen. - if (videoImportStartedRef.current || videoImportInFlightKeyRef.current) { + if ( + videoImportStartedRef.current || + videoImportInFlightKeyRef.current || + nextAnalysisInFlightRequestIdRef.current + ) { initializedRef.current = true return } @@ -4610,6 +4899,57 @@ export default function TrainingTab(props: { return () => window.cancelAnimationFrame(frame) }, [tabActive, loadTrainingStatus, updateImageLayerStyle]) + useEffect(() => { + if (!tabActive) return + + let cancelled = false + + async function refreshActiveAnalysisStatus() { + let nextRequestId = nextAnalysisInFlightRequestIdRef.current || '' + let importRequestId = activeAnalysisRequestIdRef.current || '' + + try { + const activeNextRaw = window.sessionStorage.getItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY) + if (!nextRequestId && activeNextRaw) { + nextRequestId = String(JSON.parse(activeNextRaw)?.requestId || '').trim() + } + } catch { + // ignore + } + + try { + const activeImportRaw = window.sessionStorage.getItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY) + if (activeImportRaw) { + importRequestId = String(JSON.parse(activeImportRaw)?.requestId || importRequestId || '').trim() + } + } catch { + // ignore + } + + const url = nextRequestId + ? `/api/training/next/status?requestId=${encodeURIComponent(nextRequestId)}` + : importRequestId + ? `/api/training/import-video/status?requestId=${encodeURIComponent(importRequestId)}` + : '/api/training/analysis/status' + + try { + const res = await fetch(url, { cache: 'no-store' }) + const data = await res.json().catch(() => null) + if (cancelled || !res.ok || !data?.analysis) return + + applyTrainingAnalysisEvent(data.analysis) + } catch { + // ignore + } + } + + void refreshActiveAnalysisStatus() + + return () => { + cancelled = true + } + }, [applyTrainingAnalysisEvent, tabActive]) + useEffect(() => { if (!trainingRunning) return @@ -4718,8 +5058,9 @@ export default function TrainingTab(props: { negative?: boolean } ) => { - if (!sample) return + if (!sample || trainingRunning) return + setSavingOverlayText(editingFeedback ? 'Feedback wird aktualisiert…' : 'Feedback wird gespeichert…') setSaving(true) setError(null) setMessage(null) @@ -4751,6 +5092,19 @@ export default function TrainingTab(props: { boxes: normalizedBoxes, } const effectiveAccepted = negative ? false : accepted + setSavingOverlayText( + negative + ? editingFeedback + ? 'Negativbeispiel wird aktualisiert…' + : 'Negativbeispiel wird gespeichert…' + : effectiveAccepted + ? editingFeedback + ? 'Feedback wird aktualisiert…' + : 'Feedback wird gespeichert…' + : editingFeedback + ? 'Korrektur wird aktualisiert…' + : 'Korrektur wird gespeichert…' + ) const payload = { sampleId: sample.sampleId, @@ -4852,6 +5206,7 @@ export default function TrainingTab(props: { setError(`Feedback konnte nicht gespeichert werden. ${short}`) } finally { setSaving(false) + setSavingOverlayText('') } }, [ @@ -4862,6 +5217,7 @@ export default function TrainingTab(props: { loadTrainingStatus, loadQueuedTrainingSample, loadNextImportedQueuedSample, + trainingRunning, ] ) @@ -4885,6 +5241,7 @@ export default function TrainingTab(props: { const skippedSampleId = sample.sampleId + setSavingOverlayText('Bild wird übersprungen…') setSaving(true) setError(null) setMessage(null) @@ -4924,6 +5281,7 @@ export default function TrainingTab(props: { setError(e instanceof Error ? e.message : String(e)) } finally { setSaving(false) + setSavingOverlayText('') } }, [ sample, @@ -6370,11 +6728,16 @@ export default function TrainingTab(props: { detectorBoxItemRefs.current[index] = el }} key={`box-${index}`} - onClick={() => setActiveBoxIndex(index)} + aria-disabled={uiLocked} + onClick={() => { + if (uiLocked) return + setActiveBoxIndex(index) + }} className={[ - 'group relative cursor-pointer overflow-hidden rounded-2xl border transition-all duration-200', + 'group relative overflow-hidden rounded-2xl border transition-all duration-200', 'bg-white shadow-sm', 'dark:border-white/10 dark:bg-gray-950/55', + uiLocked ? 'cursor-not-allowed opacity-60' : 'cursor-pointer', isActive ? [ 'border-gray-200 bg-white', @@ -6382,9 +6745,11 @@ export default function TrainingTab(props: { tone.activeSurface, ].join(' ') : [ - 'border-gray-200 hover:bg-gray-50/80 hover:shadow-md', - 'dark:border-white/10 dark:hover:bg-white/[0.04]', - tone.idleHover, + 'border-gray-200', + uiLocked ? '' : 'hover:bg-gray-50/80 hover:shadow-md', + 'dark:border-white/10', + uiLocked ? '' : 'dark:hover:bg-white/[0.04]', + uiLocked ? '' : tone.idleHover, ].join(' '), ].join(' ')} > @@ -6405,6 +6770,7 @@ export default function TrainingTab(props: { disabled={uiLocked} onClick={(e) => { e.stopPropagation() + if (uiLocked) return setActiveBoxIndex(index) }} className={[ @@ -6491,6 +6857,7 @@ export default function TrainingTab(props: { aria-label={`${item.text} löschen`} onClick={(e) => { e.stopPropagation() + if (uiLocked) return removeBox(index) }} > @@ -6530,6 +6897,7 @@ export default function TrainingTab(props: { loadingPreviewUrl && loadingPreviewLoaded && !loadingPreviewFailed ? loadingPreviewUrl : loadingPreviewFallbackUrl + const sampleSourceDetails = compactTrainingSourceFile(sample?.sourceFile || '') const frameLayoutSize = useMemo(() => { const width = Number(frameNaturalSize?.width) @@ -6602,24 +6970,50 @@ export default function TrainingTab(props: { 'lg:w-[min(100%,var(--image-stage-w-lg))]', ].join(' ') - const stageBusy = trainingRunning || frameBusy + const savingStageActive = saving && !trainingRunning && !frameBusy + const stageBusy = trainingRunning || frameBusy || saving - const stageOverlayMode: 'training' | 'analysis' = - trainingRunning ? 'training' : 'analysis' + const stageOverlayMode: 'training' | 'analysis' | 'saving' = + trainingRunning + ? 'training' + : savingStageActive + ? 'saving' + : 'analysis' - const analysisOverlayText = analysisSourceFile - ? `${analysisSourceFile} · ${analysisStep || 'Bild wird geladen…'}` - : analysisStep || 'Bild wird geladen…' + const analysisOverlayDetails = compactTrainingStageDetails( + analysisSourceFile, + analysisStep || 'Bild wird geladen…' + ) const stageOverlayText = trainingRunning ? shownTrainingStep || 'Aktuelles Bild wird geladen…' - : analysisOverlayText + : savingStageActive + ? savingOverlayText || 'Feedback wird gespeichert…' + : analysisOverlayDetails.statusText const stageOverlayProgress = trainingRunning ? shownTrainingProgress : loading ? analysisProgress - : 100 + : savingStageActive + ? 65 + : 100 + + const stageOverlaySourceFile = stageOverlayMode === 'analysis' + ? analysisOverlayDetails.sourceFile + : stageOverlayMode === 'saving' + ? sampleSourceDetails.sourceFile + : undefined + const stageOverlayFrameLabel = stageOverlayMode === 'analysis' + ? analysisOverlayDetails.frameLabel + : stageOverlayMode === 'saving' + ? sampleSourceDetails.frameLabel + : undefined + const stageOverlayStatusText = stageOverlayMode === 'analysis' + ? analysisOverlayDetails.statusText + : stageOverlayMode === 'saving' + ? stageOverlayText + : undefined const stageOverlayFadeMs = 300 @@ -6683,14 +7077,20 @@ export default function TrainingTab(props: { Training
- {sample?.sourceFile ? ( + {sampleSourceDetails.sourceFile ? (
- {sample.sourceFile} + {sampleSourceDetails.sourceFile} + + {sampleSourceDetails.frameLabel ? ( + + {sampleSourceDetails.frameLabel} + + ) : null}
) : null}
@@ -6734,14 +7134,20 @@ export default function TrainingTab(props: { Training
- {sample?.sourceFile ? ( + {sampleSourceDetails.sourceFile ? (
- {sample.sourceFile} + {sampleSourceDetails.sourceFile} + + {sampleSourceDetails.frameLabel ? ( + + {sampleSourceDetails.frameLabel} + + ) : null}
) : null} @@ -7540,24 +7946,24 @@ export default function TrainingTab(props: { ) : null}
-
+
{[ { key: 'labels', label: 'Labels' }, @@ -7780,7 +8189,7 @@ export default function TrainingTab(props: { { /* Rechte Seite */ }
{mobilePanel === 'labels' ? (