This commit is contained in:
Linrador 2026-06-25 14:54:58 +02:00
parent 885a22e206
commit e4a474ad54
31 changed files with 2781 additions and 399 deletions

View File

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

View File

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

View File

@ -1,3 +0,0 @@
{
"items": []
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -71,6 +71,16 @@ func trainingEmbeddedMLDir() (string, error) {
return dir, nil 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 { func embeddedMLScriptExists(name string) bool {
srcPath := filepath.ToSlash(filepath.Join("ml", name)) srcPath := filepath.ToSlash(filepath.Join("ml", name))
_, err := embeddedMLFiles.ReadFile(srcPath) _, err := embeddedMLFiles.ReadFile(srcPath)

View File

@ -147,6 +147,10 @@ func appLogLineSuppressed(line string) bool {
return true return true
} }
if m == "not found" {
return true
}
if strings.Contains(m, "mfc status failed") && if strings.Contains(m, "mfc status failed") &&
(strings.Contains(m, "user not found") || strings.Contains(m, "usernamelookup")) { (strings.Contains(m, "user not found") || strings.Contains(m, "usernamelookup")) {
return true return true

View File

@ -53,6 +53,57 @@ def safe_int(value, fallback):
return 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): def batch_sample_ids(batch):
if not isinstance(batch, dict): if not isinstance(batch, dict):
return [] return []
@ -126,6 +177,8 @@ def main():
parser.add_argument("--imgsz", default="640") parser.add_argument("--imgsz", default="640")
parser.add_argument("--device", default="auto") parser.add_argument("--device", default="auto")
parser.add_argument("--workers", default="2") parser.add_argument("--workers", default="2")
parser.add_argument("--batch", default="0")
parser.add_argument("--threads", default="0")
parser.add_argument("--patience", default="20") parser.add_argument("--patience", default="20")
args = parser.parse_args() args = parser.parse_args()
@ -140,8 +193,17 @@ def main():
epochs = max(1, safe_int(args.epochs, 80)) epochs = max(1, safe_int(args.epochs, 80))
imgsz = max(64, safe_int(args.imgsz, 640)) imgsz = max(64, safe_int(args.imgsz, 640))
workers = max(0, safe_int(args.workers, 2)) 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)) 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": if str(args.device).lower() == "auto":
train_device = 0 if torch.cuda.is_available() else "cpu" train_device = 0 if torch.cuda.is_available() else "cpu"
else: else:
@ -178,7 +240,7 @@ def main():
device=str(train_device), device=str(train_device),
) )
model = YOLO(args.base) model, base_used = load_base_model(args.base)
best_epoch = 0 best_epoch = 0
last_epoch = 0 last_epoch = 0
@ -273,23 +335,27 @@ def main():
device=str(train_device), device=str(train_device),
) )
result = model.train( train_kwargs = {
trainer=progress_detection_trainer( "trainer": progress_detection_trainer(
train_count, train_count,
val_count, val_count,
train_device, train_device,
epochs, epochs,
), ),
data=str(yaml_path), "data": str(yaml_path),
epochs=epochs, "epochs": epochs,
imgsz=imgsz, "imgsz": imgsz,
project=str(runs_dir), "project": str(runs_dir),
name="detect", "name": "detect",
exist_ok=True, "exist_ok": True,
device=train_device, "device": train_device,
workers=workers, "workers": workers,
patience=patience, "patience": patience,
) }
if batch_size > 0:
train_kwargs["batch"] = batch_size
result = model.train(**train_kwargs)
emit_progress( emit_progress(
"detector", "detector",
@ -325,6 +391,9 @@ def main():
"valSamples": val_count, "valSamples": val_count,
"epochs": epochs, "epochs": epochs,
"imgsz": imgsz, "imgsz": imgsz,
"batchSize": batch_size,
"threads": threads,
"workers": workers,
"device": str(train_device), "device": str(train_device),
"mAP50": round(best_map50, 4), "mAP50": round(best_map50, 4),
"mAP5095": round(best_map5095, 4), "mAP5095": round(best_map5095, 4),

View File

@ -164,6 +164,8 @@ def main():
parser.add_argument("--imgsz", default="640") parser.add_argument("--imgsz", default="640")
parser.add_argument("--device", default="auto") parser.add_argument("--device", default="auto")
parser.add_argument("--workers", default="2") parser.add_argument("--workers", default="2")
parser.add_argument("--batch", default="0")
parser.add_argument("--threads", default="0")
parser.add_argument("--patience", default="20") parser.add_argument("--patience", default="20")
args = parser.parse_args() args = parser.parse_args()
@ -178,8 +180,17 @@ def main():
epochs = max(1, safe_int(args.epochs, 80)) epochs = max(1, safe_int(args.epochs, 80))
imgsz = max(64, safe_int(args.imgsz, 640)) imgsz = max(64, safe_int(args.imgsz, 640))
workers = max(0, safe_int(args.workers, 2)) 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)) 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": if str(args.device).lower() == "auto":
train_device = 0 if torch.cuda.is_available() else "cpu" train_device = 0 if torch.cuda.is_available() else "cpu"
else: else:
@ -310,23 +321,27 @@ def main():
device=str(train_device), device=str(train_device),
) )
result = model.train( train_kwargs = {
trainer=progress_pose_trainer( "trainer": progress_pose_trainer(
train_count, train_count,
val_count, val_count,
train_device, train_device,
epochs, epochs,
), ),
data=str(yaml_path), "data": str(yaml_path),
epochs=epochs, "epochs": epochs,
imgsz=imgsz, "imgsz": imgsz,
project=str(runs_dir), "project": str(runs_dir),
name="pose", "name": "pose",
exist_ok=True, "exist_ok": True,
device=train_device, "device": train_device,
workers=workers, "workers": workers,
patience=patience, "patience": patience,
) }
if batch_size > 0:
train_kwargs["batch"] = batch_size
result = model.train(**train_kwargs)
emit_progress( emit_progress(
"pose", "pose",
@ -362,6 +377,9 @@ def main():
"valSamples": val_count, "valSamples": val_count,
"epochs": epochs, "epochs": epochs,
"imgsz": imgsz, "imgsz": imgsz,
"batchSize": batch_size,
"threads": threads,
"workers": workers,
"device": str(train_device), "device": str(train_device),
"mAP50": round(best_map50, 4), "mAP50": round(best_map50, 4),
"mAP5095": round(best_map5095, 4), "mAP5095": round(best_map5095, 4),

View File

@ -198,6 +198,7 @@ def main():
parser.add_argument("--lr", default="5e-5") parser.add_argument("--lr", default="5e-5")
parser.add_argument("--device", default="auto") parser.add_argument("--device", default="auto")
parser.add_argument("--workers", default="0") parser.add_argument("--workers", default="0")
parser.add_argument("--threads", default="0")
parser.add_argument("--num-frames", default="16") parser.add_argument("--num-frames", default="16")
parser.add_argument("--freeze-backbone", action="store_true") parser.add_argument("--freeze-backbone", action="store_true")
args = parser.parse_args() args = parser.parse_args()
@ -210,9 +211,17 @@ def main():
epochs = max(1, safe_int(args.epochs, 8)) epochs = max(1, safe_int(args.epochs, 8))
batch_size = max(1, safe_int(args.batch_size, 2)) batch_size = max(1, safe_int(args.batch_size, 2))
workers = max(0, safe_int(args.workers, 0)) 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)) num_frames = max(2, safe_int(args.num_frames, 16))
lr = max(1e-7, safe_float(args.lr, 5e-5)) 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": if str(args.device).lower() == "auto":
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
else: else:
@ -364,6 +373,9 @@ def main():
"bestEpoch": best_epoch, "bestEpoch": best_epoch,
"trainSamples": len(train_entries), "trainSamples": len(train_entries),
"valSamples": len(val_entries), "valSamples": len(val_entries),
"batchSize": batch_size,
"workers": workers,
"threads": threads,
"numFrames": num_frames, "numFrames": num_frames,
"baseModel": args.base, "baseModel": args.base,
"device": str(device), "device": str(device),

View File

@ -495,9 +495,11 @@ func waitUntilEnrichMayRun(ctx context.Context) error {
for { for {
waiting, running, _ := postWorkQ.Stats() 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 // enrich erst starten, wenn keine primäre Postwork mehr läuft
if waiting == 0 && running == 0 { if waiting == 0 && running == 0 && !trainingRunning {
return nil return nil
} }

View File

@ -1,4 +1,5 @@
{ {
"databaseType": "postgres",
"databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable", "databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable",
"encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=", "encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=",
"recordDir": "C:\\Users\\Rother\\Desktop\\Privat\\records", "recordDir": "C:\\Users\\Rother\\Desktop\\Privat\\records",
@ -29,5 +30,12 @@
"enrichPostworkEnabled": false, "enrichPostworkEnabled": false,
"trainingRecognitionEnabled": true, "trainingRecognitionEnabled": true,
"trainingDetectorEpochs": 60, "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=" "encryptedCookies": "p0aRfigWn3+TfypNAV7vPky13RW+gwhTlhNOGKKAED0FSMf0kdGV6LgIizzTeDMD5Ck+9SGGf6EfVg+RPnEr3ZjkkkjOTVGOJ9XNzpuczHqzi0ifmt0l9/lnNTaqh8oTzrk09uQC9YYe0CtsY6gjpYFAdVEPGimCPdULqn+8Jln1CSDiBGRGF0uZGxfmAZ1T/YQym/wjiCCdIRfcjTF4f+sz6PMBQOOnTA74if1OFNA6PxpGoXfHS7xvMeV6RX3J/qcTyMmFWr+uD2h1UKs7N9w4Y1sfw4heB10jzy/VL+9gWg2o4AKE5tGqOmlVYpv8KculMWfJHNxNpRonPNizPR4jO2jZYdr3AikuXTTeILyN7hb4MtO4fXblJ+9of1ABR6LvDe1dAXmbkNdqEqRo0k1WS6jaCdNkrleP8vGuH56H7jZMnEeJAcQxS99kVjFo0qMAEJ2ai8HPkdYURC3dWSu9uIbGmiqi6hgva2WM6jPG7TqZDyqUS7hjJwAmPa3aLPnsEWHqkcE="
} }

View File

@ -99,12 +99,14 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
api.HandleFunc("/api/training/train", trainingTrainHandler) api.HandleFunc("/api/training/train", trainingTrainHandler)
api.HandleFunc("/api/training/cancel", trainingCancelHandler) api.HandleFunc("/api/training/cancel", trainingCancelHandler)
api.HandleFunc("/api/training/status", trainingStatusHandler) api.HandleFunc("/api/training/status", trainingStatusHandler)
api.HandleFunc("/api/training/analysis/status", trainingAnalysisStatusHandler)
api.HandleFunc("/api/training/stats", trainingStatsHandler) api.HandleFunc("/api/training/stats", trainingStatsHandler)
api.HandleFunc("/api/training/history", trainingHistoryHandler) api.HandleFunc("/api/training/history", trainingHistoryHandler)
api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler) api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler)
api.HandleFunc("/api/training/skip", trainingSkipHandler) api.HandleFunc("/api/training/skip", trainingSkipHandler)
api.HandleFunc("/api/training/import-video", trainingImportVideoHandler) api.HandleFunc("/api/training/import-video", trainingImportVideoHandler)
api.HandleFunc("/api/training/import-video/status", trainingImportVideoStatusHandler) 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/training/video-preview", trainingVideoPreviewHandler)
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler) api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)

View File

@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
neturl "net/url"
"os" "os"
"os/exec" "os/exec"
"os/signal" "os/signal"
@ -98,8 +99,28 @@ func buildStartupNotificationMessage() string {
} }
type aiServerProcess struct { type aiServerProcess struct {
cmd *exec.Cmd cmd *exec.Cmd
done chan struct{} 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 // Globales Handle auf den laufenden AI-Server, damit er (z.B. nach einem neuen
@ -184,10 +205,12 @@ func aiServerURL() string {
} }
func aiServerPortFromURL() string { func aiServerPortFromURL() string {
url := aiServerURL() rawURL := aiServerURL()
if strings.Contains(url, ":8765") { if parsed, err := neturl.Parse(rawURL); err == nil && parsed != nil {
return "8765" if port := strings.TrimSpace(parsed.Port()); port != "" {
return port
}
} }
// Einfacher Fallback. Wenn du später andere Ports willst, // Einfacher Fallback. Wenn du später andere Ports willst,
@ -499,6 +522,10 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
return nil, nil return nil, nil
} }
if err := stopStaleAIServerOnPort(aiServerPortFromURL()); err != nil {
appLogln("⚠️ Alter AI Server konnte nicht bereinigt werden:", err)
}
if err := ensureMLPythonSetup(ctx); err != nil { if err := ensureMLPythonSetup(ctx); err != nil {
return nil, err return nil, err
} }
@ -552,7 +579,7 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
"--log-level", "error", "--log-level", "error",
) )
cmd := exec.CommandContext(ctx, pythonPath, args...) cmd := exec.Command(pythonPath, args...)
cmd.Dir = scriptDir cmd.Dir = scriptDir
env := os.Environ() env := os.Environ()
@ -612,6 +639,12 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
hideCommandWindow(cmd) hideCommandWindow(cmd)
} }
prepareAIServerCommandForLifecycle(cmd)
if ctx.Err() != nil {
return nil, ctx.Err()
}
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return nil, err return nil, err
} }
@ -624,7 +657,7 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
go func() { go func() {
err := cmd.Wait() err := cmd.Wait()
if err != nil && ctx.Err() == nil { if err != nil && ctx.Err() == nil && !proc.isStopping() {
appLogf("⚠️ AI Server beendet: %v", err) appLogf("⚠️ AI Server beendet: %v", err)
} else { } else {
appLogln("🛑 AI Server beendet.") appLogln("🛑 AI Server beendet.")
@ -648,9 +681,8 @@ func (p *aiServerProcess) Stop() {
return return
} }
if p.cmd != nil && p.cmd.Process != nil { p.markStopping()
_ = p.cmd.Process.Kill() terminateAIServerProcessTree(p.cmd)
}
// Auf das tatsächliche Prozessende warten, damit der Port frei ist, // Auf das tatsächliche Prozessende warten, damit der Port frei ist,
// bevor ggf. ein neuer Server gestartet wird. // bevor ggf. ein neuer Server gestartet wird.

View File

@ -56,8 +56,15 @@ type RecorderSettings struct {
GenerateAssetsAnalyze bool `json:"generateAssetsAnalyze"` GenerateAssetsAnalyze bool `json:"generateAssetsAnalyze"`
EnrichPostworkEnabled bool `json:"enrichPostworkEnabled"` EnrichPostworkEnabled bool `json:"enrichPostworkEnabled"`
TrainingRecognitionEnabled bool `json:"trainingRecognitionEnabled"` TrainingRecognitionEnabled bool `json:"trainingRecognitionEnabled"`
TrainingDetectorEpochs int `json:"trainingDetectorEpochs"` 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 contains base64(nonce+ciphertext) of a JSON cookie map.
EncryptedCookies string `json:"encryptedCookies"` EncryptedCookies string `json:"encryptedCookies"`
@ -107,6 +114,13 @@ var (
TrainingRecognitionEnabled: true, TrainingRecognitionEnabled: true,
TrainingDetectorEpochs: 60, TrainingDetectorEpochs: 60,
TrainingPerformanceMode: trainingPerformanceAuto,
TrainingPowerSaveMode: false,
TrainingCPUThreads: 0,
TrainingWorkers: 2,
TrainingYoloBatchSize: 0,
TrainingLowPriority: false,
TrainingVideoMAEEnabled: true,
EncryptedCookies: "", EncryptedCookies: "",
} }
@ -136,6 +150,47 @@ func normalizeSettingsDir(raw string, fallback string, legacyDefaults ...string)
return path 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 { func settingsFilePath() string {
// optionaler Override per ENV // optionaler Override per ENV
name := settingsFile name := settingsFile
@ -198,12 +253,7 @@ func loadSettings() {
s.LowDiskPauseBelowGB = 10_000 s.LowDiskPauseBelowGB = 10_000
} }
if s.TrainingDetectorEpochs < 1 { normalizeTrainingSettings(&s)
s.TrainingDetectorEpochs = 60
}
if s.TrainingDetectorEpochs > 300 {
s.TrainingDetectorEpochs = 300
}
settingsMu.Lock() settingsMu.Lock()
settings = s settings = s
@ -316,12 +366,27 @@ type RecorderSettingsPublic struct {
GenerateAssetsAnalyze bool `json:"generateAssetsAnalyze"` GenerateAssetsAnalyze bool `json:"generateAssetsAnalyze"`
EnrichPostworkEnabled bool `json:"enrichPostworkEnabled"` EnrichPostworkEnabled bool `json:"enrichPostworkEnabled"`
TrainingRecognitionEnabled bool `json:"trainingRecognitionEnabled"` TrainingRecognitionEnabled bool `json:"trainingRecognitionEnabled"`
TrainingDetectorEpochs int `json:"trainingDetectorEpochs"` 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 { func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
normalizePostgresSettings(&s) normalizePostgresSettings(&s)
normalizeTrainingSettings(&s)
trainingOpts := trainingRuntimeOptionsFromRecorderSettings(s)
return RecorderSettingsPublic{ return RecorderSettingsPublic{
RecordDir: s.RecordDir, RecordDir: s.RecordDir,
DoneDir: s.DoneDir, DoneDir: s.DoneDir,
@ -362,8 +427,21 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
GenerateAssetsAnalyze: s.GenerateAssetsAnalyze, GenerateAssetsAnalyze: s.GenerateAssetsAnalyze,
EnrichPostworkEnabled: s.EnrichPostworkEnabled, EnrichPostworkEnabled: s.EnrichPostworkEnabled,
TrainingRecognitionEnabled: s.TrainingRecognitionEnabled, TrainingRecognitionEnabled: s.TrainingRecognitionEnabled,
TrainingDetectorEpochs: s.TrainingDetectorEpochs, 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 in.MaxConcurrentDownloads = 1
} }
if in.TrainingDetectorEpochs < 1 { normalizeTrainingSettings(&in.RecorderSettings)
in.TrainingDetectorEpochs = 60
}
if in.TrainingDetectorEpochs > 300 {
in.TrainingDetectorEpochs = 300
}
// --- ensure folders (Fehler zurückgeben, falls z.B. keine Rechte) --- // --- ensure folders (Fehler zurückgeben, falls z.B. keine Rechte) ---
recAbs, err := resolvePathRelativeToApp(in.RecordDir) recAbs, err := resolvePathRelativeToApp(in.RecordDir)
@ -551,6 +624,13 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
next.TrainingRecognitionEnabled = in.TrainingRecognitionEnabled next.TrainingRecognitionEnabled = in.TrainingRecognitionEnabled
next.TrainingDetectorEpochs = in.TrainingDetectorEpochs 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 := dbChanged :=
normalizeDatabaseType(next.DatabaseType) != normalizeDatabaseType(current.DatabaseType) || normalizeDatabaseType(next.DatabaseType) != normalizeDatabaseType(current.DatabaseType) ||

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -5,7 +5,7 @@ import './App.css'
import Button from './components/ui/Button' import Button from './components/ui/Button'
import CookieModal from './components/ui/CookieModal' import CookieModal from './components/ui/CookieModal'
import Tabs, { type TabItem } from './components/ui/Tabs' 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 FinishedDownloads from './components/ui/FinishedDownloads'
import Player from './components/ui/Player' import Player from './components/ui/Player'
import type { RecordJob, JobEvent, RecorderSettingsState, TaskStateEvent, AnalysisProgressEvent, BackgroundSplitProgress } from './types' import type { RecordJob, JobEvent, RecorderSettingsState, TaskStateEvent, AnalysisProgressEvent, BackgroundSplitProgress } from './types'
@ -84,11 +84,16 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
generateAssetsTeaser: true, generateAssetsTeaser: true,
generateAssetsSprites: true, generateAssetsSprites: true,
generateAssetsAnalyze: true, generateAssetsAnalyze: true,
enrichPostworkEnabled: true, enrichPostworkEnabled: true,
trainingRecognitionEnabled: true, trainingRecognitionEnabled: true,
trainingDetectorEpochs: 60, trainingDetectorEpochs: 60,
trainingPerformanceMode: 'auto',
trainingPowerSaveMode: false,
trainingCpuThreads: 0,
trainingWorkers: 2,
trainingYoloBatchSize: 0,
trainingLowPriority: false,
trainingVideoMAEEnabled: true,
} }
type StoredModel = { type StoredModel = {
@ -4498,7 +4503,7 @@ export default function App() {
{selectedTab === 'categories' ? <CategoriesTab /> : null} {selectedTab === 'categories' ? <CategoriesTab /> : null}
{selectedTab === 'settings' ? ( {selectedTab === 'settings' ? (
<RecorderSettings <Settings
onAssetsGenerated={bumpAssets} onAssetsGenerated={bumpAssets}
/> />
) : null} ) : null}

View File

@ -2314,6 +2314,7 @@ export default function FinishedDownloads({
const [deletingKeys, setDeletingKeys] = useState<Set<string>>(() => new Set()) const [deletingKeys, setDeletingKeys] = useState<Set<string>>(() => new Set())
const [deletedSuccessKeys, setDeletedSuccessKeys] = useState<Set<string>>(() => new Set()) const [deletedSuccessKeys, setDeletedSuccessKeys] = useState<Set<string>>(() => new Set())
const [keepingKeys, setKeepingKeys] = useState<Set<string>>(() => new Set()) const [keepingKeys, setKeepingKeys] = useState<Set<string>>(() => new Set())
const [keptSuccessKeys, setKeptSuccessKeys] = useState<Set<string>>(() => new Set())
const [removingKeys, setRemovingKeys] = useState<Set<string>>(() => new Set()) const [removingKeys, setRemovingKeys] = useState<Set<string>>(() => new Set())
const [hiddenFiles, setHiddenFiles] = useState<Set<string>>(() => new Set()) const [hiddenFiles, setHiddenFiles] = useState<Set<string>>(() => new Set())
const [restoredJobsByKey, setRestoredJobsByKey] = useState<Record<string, RecordJob>>({}) const [restoredJobsByKey, setRestoredJobsByKey] = useState<Record<string, RecordJob>>({})
@ -3156,6 +3157,7 @@ export default function FinishedDownloads({
deletingKeys.size > 0 || deletingKeys.size > 0 ||
deletedSuccessKeys.size > 0 || deletedSuccessKeys.size > 0 ||
keepingKeys.size > 0 || keepingKeys.size > 0 ||
keptSuccessKeys.size > 0 ||
removingKeys.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) => { const markDeleted = useCallback((key: string) => {
setDeletedKeys((prev) => { setDeletedKeys((prev) => {
const next = new Set(prev) const next = new Set(prev)
@ -3612,6 +3623,9 @@ export default function FinishedDownloads({
}) })
markDeletedSuccess(key, false) markDeletedSuccess(key, false)
markKeptSuccess(key, false)
markDeleting(key, false)
markKeeping(key, false)
markDeleted(key) markDeleted(key)
markRemoving(key, false) markRemoving(key, false)
@ -3619,7 +3633,16 @@ export default function FinishedDownloads({
refreshHoverPreviewFromPointer() refreshHoverPreviewFromPointer()
}) })
}, },
[cancelRemoveTimer, markDeletedSuccess, markDeleted, markRemoving, refreshHoverPreviewFromPointer] [
cancelRemoveTimer,
markDeletedSuccess,
markKeptSuccess,
markDeleting,
markKeeping,
markDeleted,
markRemoving,
refreshHoverPreviewFromPointer,
]
) )
const restoreRow = useCallback( const restoreRow = useCallback(
@ -3647,6 +3670,7 @@ export default function FinishedDownloads({
setDeletingKeys(removeKeyAliases) setDeletingKeys(removeKeyAliases)
setDeletedSuccessKeys(removeKeyAliases) setDeletedSuccessKeys(removeKeyAliases)
setKeepingKeys(removeKeyAliases) setKeepingKeys(removeKeyAliases)
setKeptSuccessKeys(removeKeyAliases)
if (fileAliases.size > 0) { if (fileAliases.size > 0) {
setHiddenFiles((prev) => { setHiddenFiles((prev) => {
@ -3681,6 +3705,78 @@ export default function FinishedDownloads({
[cancelRemoveTimer, markDeleting, markRemoving, markDeletedSuccess, removeRowNow] [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<string, { key: string; file?: string }>())
.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( const animateRemove = useCallback(
(key: string, file?: string) => { (key: string, file?: string) => {
if (file) { if (file) {
@ -4445,6 +4541,7 @@ export default function FinishedDownloads({
const selectedDeleteFiles = selectedDeleteItems.map((item) => item.file) const selectedDeleteFiles = selectedDeleteItems.map((item) => item.file)
const deletedKeys = new Set<string>() const deletedKeys = new Set<string>()
const failedKeys = new Set<string>() const failedKeys = new Set<string>()
const deletedItems: Array<{ file: string; key: string }> = []
const clearDeleting = (keys: Iterable<string>) => { const clearDeleting = (keys: Iterable<string>) => {
setDeletingKeys((prev) => { setDeletingKeys((prev) => {
@ -4459,18 +4556,20 @@ export default function FinishedDownloads({
const handleResult = (item: any) => { const handleResult = (item: any) => {
if (item?.done) return if (item?.done) return
const file = String(item?.file || '').trim() const file = baseName(String(item?.file || '').trim())
if (!file) return if (!file) return
const selectedItem = selectedDeleteItems.find((selected) => selected.file === file)
const key = const key =
selectedItem?.key ||
fileToKeyRef.current.get(file) || fileToKeyRef.current.get(file) ||
selectedDeleteItems.find((selected) => selected.file === file)?.key ||
file file
if (item?.ok) { if (item?.ok) {
if (!deletedKeys.has(key)) { if (!deletedKeys.has(key)) {
deletedKeys.add(key) deletedKeys.add(key)
completeDeletedRow(key, file) deletedItems.push({ key, file })
markDeletedRowSuccess(key)
} }
return return
} }
@ -4561,6 +4660,7 @@ export default function FinishedDownloads({
emitCountHint(-deletedCount) emitCountHint(-deletedCount)
if (deletedCount > 0) { if (deletedCount > 0) {
removeRowsTogether(deletedItems)
notify.success?.('Löschen erfolgreich', `${deletedCount} Downloads gelöscht.`) notify.success?.('Löschen erfolgreich', `${deletedCount} Downloads gelöscht.`)
} }
} catch (e: any) { } catch (e: any) {
@ -4570,6 +4670,7 @@ export default function FinishedDownloads({
if (deletedKeys.size > 0) { if (deletedKeys.size > 0) {
clearSelection() clearSelection()
emitCountHint(-deletedKeys.size) emitCountHint(-deletedKeys.size)
removeRowsTogether(deletedItems)
notify.success?.('L\u00f6schen erfolgreich', `${deletedKeys.size} Downloads gel\u00f6scht.`) notify.success?.('L\u00f6schen erfolgreich', `${deletedKeys.size} Downloads gel\u00f6scht.`)
} }
@ -4583,23 +4684,80 @@ export default function FinishedDownloads({
selectedJobsForBulk, selectedJobsForBulk,
clearSelection, clearSelection,
emitCountHint, emitCountHint,
completeDeletedRow, markDeletedRowSuccess,
removeRowsTogether,
notify, notify,
]) ])
const bulkKeepSelected = useCallback(async () => { const bulkKeepSelected = useCallback(async () => {
if (bulkBusy) return if (bulkBusy) return
if (selectedFiles.length === 0) return if (selectedJobsForBulk.length === 0) return
const ok = window.confirm(`${selectedFiles.length} Downloads behalten?`) const ok = window.confirm(`${selectedFiles.length} Downloads behalten?`)
if (!ok) return 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<string>()
const failedKeys = new Set<string>()
const keptItems: Array<{ file: string; key: string }> = []
const clearKeeping = (keys: Iterable<string>) => {
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) setBulkBusy(true)
try { try {
const res = await fetch('/api/record/keep-many', { const res = await fetch('/api/record/keep-many', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ files: selectedFiles }), body: JSON.stringify({ files: selectedKeepFiles }),
}) })
if (!res.ok) { if (!res.ok) {
@ -4611,27 +4769,64 @@ export default function FinishedDownloads({
const results = Array.isArray(data?.results) ? data.results : [] const results = Array.isArray(data?.results) ? data.results : []
for (const item of results) { for (const item of results) {
if (!item?.ok) continue const file = baseName(String(item.file || '').trim())
const file = String(item.file || '').trim()
if (!file) continue if (!file) continue
const selectedItem = selectedKeepItems.find((selected) => selected.file === file)
const key = const key =
selectedItem?.key ||
fileToKeyRef.current.get(file) || fileToKeyRef.current.get(file) ||
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() clearSelection()
emitCountHint(includeKeep ? 0 : -keptCount) emitCountHint(includeKeep ? 0 : -keptCount)
if (keptCount > 0) { if (keptCount > 0) {
removeRowsTogether(keptItems)
notify.success?.('Keep erfolgreich', `${keptCount} Downloads behalten.`) notify.success?.('Keep erfolgreich', `${keptCount} Downloads behalten.`)
} }
} catch (e: any) { } 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)) notify.error('Bulk-Keep fehlgeschlagen', String(e?.message || e))
} finally { } finally {
setBulkBusy(false) setBulkBusy(false)
@ -4639,7 +4834,9 @@ export default function FinishedDownloads({
}, [ }, [
bulkBusy, bulkBusy,
selectedFiles, selectedFiles,
animateRemove, selectedJobsForBulk,
markKeptRowSuccess,
removeRowsTogether,
clearSelection, clearSelection,
emitCountHint, emitCountHint,
includeKeep, includeKeep,
@ -6609,6 +6806,7 @@ export default function FinishedDownloads({
inlinePlay={inlinePlay} inlinePlay={inlinePlay}
deletingKeys={deletingKeys} deletingKeys={deletingKeys}
deletedSuccessKeys={deletedSuccessKeys} deletedSuccessKeys={deletedSuccessKeys}
keptSuccessKeys={keptSuccessKeys}
keepingKeys={keepingKeys} keepingKeys={keepingKeys}
removingKeys={removingKeys} removingKeys={removingKeys}
swipeRefs={swipeRefs} swipeRefs={swipeRefs}
@ -6691,6 +6889,7 @@ export default function FinishedDownloads({
assetNonceForJob={assetNonceForJob} assetNonceForJob={assetNonceForJob}
deletingKeys={deletingKeys} deletingKeys={deletingKeys}
deletedSuccessKeys={deletedSuccessKeys} deletedSuccessKeys={deletedSuccessKeys}
keptSuccessKeys={keptSuccessKeys}
keepingKeys={keepingKeys} keepingKeys={keepingKeys}
removingKeys={removingKeys} removingKeys={removingKeys}
modelsByKey={modelsByKey} modelsByKey={modelsByKey}
@ -6743,6 +6942,7 @@ export default function FinishedDownloads({
formatBytes={formatBytes} formatBytes={formatBytes}
deletingKeys={deletingKeys} deletingKeys={deletingKeys}
deletedSuccessKeys={deletedSuccessKeys} deletedSuccessKeys={deletedSuccessKeys}
keptSuccessKeys={keptSuccessKeys}
keepingKeys={keepingKeys} keepingKeys={keepingKeys}
removingKeys={removingKeys} removingKeys={removingKeys}
registerTeaserHost={registerTeaserHost} registerTeaserHost={registerTeaserHost}

View File

@ -77,6 +77,7 @@ type Props = {
deletingKeys: Set<string> deletingKeys: Set<string>
deletedSuccessKeys: Set<string> deletedSuccessKeys: Set<string>
keptSuccessKeys: Set<string>
keepingKeys: Set<string> keepingKeys: Set<string>
removingKeys: Set<string> removingKeys: Set<string>
@ -339,6 +340,7 @@ function FinishedDownloadsCardsView({
inlinePlay, inlinePlay,
deletingKeys, deletingKeys,
deletedSuccessKeys, deletedSuccessKeys,
keptSuccessKeys,
keepingKeys, keepingKeys,
removingKeys, removingKeys,
@ -530,7 +532,9 @@ function FinishedDownloadsCardsView({
}) })
const deleteDone = deletedSuccessKeys.has(k) 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 model = modelNameFromOutput(j.output)
const fileRaw = baseName(j.output || '') const fileRaw = baseName(j.output || '')
@ -637,7 +641,7 @@ function FinishedDownloadsCardsView({
busy && 'pointer-events-none opacity-70', 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', 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', 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]', removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
] ]
.filter(Boolean) .filter(Boolean)
@ -890,6 +894,11 @@ function FinishedDownloadsCardsView({
{deletingKeys.has(k) || deleteDone ? ( {deletingKeys.has(k) || deleteDone ? (
<DeletingPreviewOverlay done={deleteDone} /> <DeletingPreviewOverlay done={deleteDone} />
) : keepBusy || keepDone ? (
<DeletingPreviewOverlay
done={keepDone}
label={keepDone ? 'Behalten' : 'Wird behalten'}
/>
) : null} ) : null}
</div> </div>
</div> </div>
@ -1220,6 +1229,7 @@ function FinishedDownloadsCardsView({
selectionModeActive={selectionModeActive} selectionModeActive={selectionModeActive}
isDeleting={deletingKeys.has(k)} isDeleting={deletingKeys.has(k)}
isDeleted={deletedSuccessKeys.has(k)} isDeleted={deletedSuccessKeys.has(k)}
isKept={keptSuccessKeys.has(k)}
isKeeping={keepingKeys.has(k)} isKeeping={keepingKeys.has(k)}
isRemoving={removingKeys.has(k)} isRemoving={removingKeys.has(k)}
postworkBadge={getPostworkSummaryForOutput(j.output, postworkByFile)} postworkBadge={getPostworkSummaryForOutput(j.output, postworkByFile)}

View File

@ -50,6 +50,7 @@ export type FinishedDownloadsDesktopCardProps = {
selectionModeActive: boolean selectionModeActive: boolean
isDeleting: boolean isDeleting: boolean
isDeleted: boolean isDeleted: boolean
isKept: boolean
isKeeping: boolean isKeeping: boolean
isRemoving: boolean isRemoving: boolean
postworkBadge?: FinishedPostworkSummary postworkBadge?: FinishedPostworkSummary
@ -143,6 +144,7 @@ const FinishedDownloadsDesktopCard = memo(
selectionModeActive, selectionModeActive,
isDeleting, isDeleting,
isDeleted, isDeleted,
isKept,
isKeeping, isKeeping,
isRemoving, isRemoving,
postworkBadge, postworkBadge,
@ -186,7 +188,7 @@ const FinishedDownloadsDesktopCard = memo(
renderRatingOverlay, renderRatingOverlay,
}: FinishedDownloadsDesktopCardProps) { }: FinishedDownloadsDesktopCardProps) {
const k = keyFor(j) const k = keyFor(j)
const busy = isDeleting || isDeleted || isKeeping || isRemoving const busy = isDeleting || isDeleted || isKeeping || isKept || isRemoving
const checked = useSelectionChecked(selectionStore, k) const checked = useSelectionChecked(selectionStore, k)
const longPressTimerRef = useRef<number | null>(null) const longPressTimerRef = useRef<number | null>(null)
const longPressTriggeredRef = useRef(false) const longPressTriggeredRef = useRef(false)
@ -338,7 +340,7 @@ const FinishedDownloadsDesktopCard = memo(
busy && 'pointer-events-none opacity-70', 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', 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', 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]', isRemoving && 'opacity-0 translate-y-2 scale-[0.98]',
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
onClick={(e) => { onClick={(e) => {
@ -494,6 +496,11 @@ const FinishedDownloadsDesktopCard = memo(
{isDeleting || isDeleted ? ( {isDeleting || isDeleted ? (
<DeletingPreviewOverlay done={isDeleted} /> <DeletingPreviewOverlay done={isDeleted} />
) : isKeeping || isKept ? (
<DeletingPreviewOverlay
done={isKept}
label={isKept ? 'Behalten' : 'Wird behalten'}
/>
) : null} ) : null}
</div> </div>
</div> </div>
@ -615,6 +622,7 @@ const FinishedDownloadsDesktopCard = memo(
prev.job === next.job && prev.job === next.job &&
prev.isDeleting === next.isDeleting && prev.isDeleting === next.isDeleting &&
prev.isDeleted === next.isDeleted && prev.isDeleted === next.isDeleted &&
prev.isKept === next.isKept &&
prev.isKeeping === next.isKeeping && prev.isKeeping === next.isKeeping &&
prev.isRemoving === next.isRemoving && prev.isRemoving === next.isRemoving &&
samePostworkSummaryLite(prev.postworkBadge, next.postworkBadge) && samePostworkSummaryLite(prev.postworkBadge, next.postworkBadge) &&

View File

@ -75,6 +75,7 @@ type Props = {
deletingKeys: Set<string> deletingKeys: Set<string>
deletedSuccessKeys: Set<string> deletedSuccessKeys: Set<string>
keptSuccessKeys: Set<string>
keepingKeys: Set<string> keepingKeys: Set<string>
removingKeys: Set<string> removingKeys: Set<string>
@ -134,6 +135,7 @@ function FinishedDownloadsGalleryCardInner({
activeTagSet, activeTagSet,
deletingKeys, deletingKeys,
deletedSuccessKeys, deletedSuccessKeys,
keptSuccessKeys,
keepingKeys, keepingKeys,
removingKeys, removingKeys,
registerTeaserHost, registerTeaserHost,
@ -307,8 +309,9 @@ function FinishedDownloadsGalleryCardInner({
const isRemoving = removingKeys.has(k) const isRemoving = removingKeys.has(k)
const isDeleting = deletingKeys.has(k) const isDeleting = deletingKeys.has(k)
const isDeleted = deletedSuccessKeys.has(k) const isDeleted = deletedSuccessKeys.has(k)
const isKept = keptSuccessKeys.has(k)
const isKeeping = keepingKeys.has(k) const isKeeping = keepingKeys.has(k)
const busy = isDeleting || isDeleted || isKeeping || isRemoving const busy = isDeleting || isDeleted || isKeeping || isKept || isRemoving
const sprite = useMemo( const sprite = useMemo(
() => () =>
@ -408,7 +411,7 @@ function FinishedDownloadsGalleryCardInner({
busy && !isRemoving && 'pointer-events-none opacity-70', 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', 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', 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', isRemoving ? 'pointer-events-none opacity-0 translate-y-2 scale-[0.98]' : 'opacity-100 translate-y-0 scale-100',
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
> >
@ -565,6 +568,11 @@ function FinishedDownloadsGalleryCardInner({
{isDeleting || isDeleted ? ( {isDeleting || isDeleted ? (
<DeletingPreviewOverlay done={isDeleted} /> <DeletingPreviewOverlay done={isDeleted} />
) : isKeeping || isKept ? (
<DeletingPreviewOverlay
done={isKept}
label={isKept ? 'Behalten' : 'Wird behalten'}
/>
) : null} ) : null}
</div> </div>
</div> </div>

View File

@ -47,6 +47,7 @@ type Props = {
deletingKeys: Set<string> deletingKeys: Set<string>
deletedSuccessKeys: Set<string> deletedSuccessKeys: Set<string>
keptSuccessKeys: Set<string>
keepingKeys: Set<string> keepingKeys: Set<string>
removingKeys: Set<string> removingKeys: Set<string>
@ -118,6 +119,7 @@ function FinishedDownloadsGalleryView({
formatBytes, formatBytes,
deletingKeys, deletingKeys,
deletedSuccessKeys, deletedSuccessKeys,
keptSuccessKeys,
keepingKeys, keepingKeys,
removingKeys, removingKeys,
registerTeaserHost, registerTeaserHost,
@ -397,6 +399,7 @@ function FinishedDownloadsGalleryView({
activeTagSet={activeTagSet} activeTagSet={activeTagSet}
deletingKeys={deletingKeys} deletingKeys={deletingKeys}
deletedSuccessKeys={deletedSuccessKeys} deletedSuccessKeys={deletedSuccessKeys}
keptSuccessKeys={keptSuccessKeys}
keepingKeys={keepingKeys} keepingKeys={keepingKeys}
removingKeys={removingKeys} removingKeys={removingKeys}
registerTeaserHost={registerTeaserHostIfNeeded} registerTeaserHost={registerTeaserHostIfNeeded}

View File

@ -69,6 +69,7 @@ type Props = {
// state/flags for actions row + rowClassName // state/flags for actions row + rowClassName
deletingKeys: Set<string> deletingKeys: Set<string>
deletedSuccessKeys: Set<string> deletedSuccessKeys: Set<string>
keptSuccessKeys: Set<string>
keepingKeys: Set<string> keepingKeys: Set<string>
removingKeys: Set<string> removingKeys: Set<string>
@ -132,6 +133,7 @@ export default function FinishedDownloadsTableView({
deletingKeys, deletingKeys,
deletedSuccessKeys, deletedSuccessKeys,
keptSuccessKeys,
keepingKeys, keepingKeys,
removingKeys, removingKeys,
@ -313,6 +315,8 @@ export default function FinishedDownloadsTableView({
const k = keyFor(j) const k = keyFor(j)
const isDeleting = deletingKeys.has(k) const isDeleting = deletingKeys.has(k)
const isDeleted = deletedSuccessKeys.has(k) const isDeleted = deletedSuccessKeys.has(k)
const isKeeping = keepingKeys.has(k)
const isKept = keptSuccessKeys.has(k)
const isPreviewActive = const isPreviewActive =
teaserState.activeKey === k || teaserState.hoverKey === k teaserState.activeKey === k || teaserState.hoverKey === k
const allowSound = shouldEnableTeaserAudio({ const allowSound = shouldEnableTeaserAudio({
@ -390,6 +394,13 @@ export default function FinishedDownloadsTableView({
{isDeleting || isDeleted ? ( {isDeleting || isDeleted ? (
<DeletingPreviewOverlay done={isDeleted} compact className="rounded-md" /> <DeletingPreviewOverlay done={isDeleted} compact className="rounded-md" />
) : isKeeping || isKept ? (
<DeletingPreviewOverlay
done={isKept}
label={isKept ? 'Behalten' : 'Wird behalten'}
compact
className="rounded-md"
/>
) : null} ) : null}
</div> </div>
</div> </div>
@ -557,7 +568,12 @@ export default function FinishedDownloadsTableView({
srOnlyHeader: true, srOnlyHeader: true,
cell: (j) => { cell: (j) => {
const k = keyFor(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 fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw) const isHot = isHotName(fileRaw)
@ -621,6 +637,7 @@ export default function FinishedDownloadsTableView({
resolutions, resolutions,
deletingKeys, deletingKeys,
deletedSuccessKeys, deletedSuccessKeys,
keptSuccessKeys,
keepingKeys, keepingKeys,
removingKeys, removingKeys,
previewSpriteInfoOf, 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) || removingKeys.has(k)) && 'bg-red-50/60 dark:bg-red-500/10 pointer-events-none',
deletingKeys.has(k) && 'animate-pulse', deletingKeys.has(k) && 'animate-pulse',
deletedSuccessKeys.has(k) && 'bg-emerald-50/60 dark:bg-emerald-500/10 pointer-events-none', 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) || removingKeys.has(k)) && 'pointer-events-none',
keepingKeys.has(k) && 'bg-emerald-50/60 dark:bg-emerald-500/10 animate-pulse', keepingKeys.has(k) && 'bg-emerald-50/60 dark:bg-emerald-500/10 animate-pulse',
removingKeys.has(k) && 'opacity-0', removingKeys.has(k) && 'opacity-0',
@ -687,7 +705,7 @@ export default function FinishedDownloadsTableView({
.filter(Boolean) .filter(Boolean)
.join(' ') .join(' ')
}, },
[keyFor, deletingKeys, deletedSuccessKeys, keepingKeys, removingKeys] [keyFor, deletingKeys, deletedSuccessKeys, keptSuccessKeys, keepingKeys, removingKeys]
) )
return ( return (

View File

@ -138,7 +138,15 @@ export default function RecordJobActions({
const btnBase = const btnBase =
variant === 'table' 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 ' + : 'inline-flex items-center justify-center rounded-md ' +
`bg-white/75 ${pad} text-gray-900 backdrop-blur ring-1 ring-black/10 ` + `bg-white/75 ${pad} text-gray-900 backdrop-blur ring-1 ring-black/10 ` +
'hover:bg-white/90 ' + 'hover:bg-white/90 ' +
@ -152,7 +160,7 @@ export default function RecordJobActions({
favOn: 'text-amber-600 dark:text-amber-300', favOn: 'text-amber-600 dark:text-amber-300',
likeOn: 'text-rose-600 dark:text-rose-300', likeOn: 'text-rose-600 dark:text-rose-300',
hotOn: 'text-amber-600 dark:text-amber-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', keep: 'text-emerald-600 dark:text-emerald-300',
del: 'text-red-600 dark:text-red-300', del: 'text-red-600 dark:text-red-300',
watchOn: 'text-sky-600 dark:text-sky-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. // ✅ 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 actionOrder: ActionKey[] = order ?? ['watch', 'favorite', 'like', 'training', 'split', 'hot', 'keep', 'delete', 'details']
const inOrder = (k: ActionKey) => actionOrder.includes(k) const inOrder = (k: ActionKey) => actionOrder.includes(k)
@ -318,7 +328,7 @@ export default function RecordJobActions({
}} }}
> >
<span className={cn('inline-flex items-center justify-center', iconBox)}> <span className={cn('inline-flex items-center justify-center', iconBox)}>
<MagnifyingGlassIcon className={cn(iconFill, colors.off)} /> <MagnifyingGlassIcon className={cn(iconFill, colors.off)} strokeWidth={outlineStrokeWidth} />
</span> </span>
<span className="sr-only">{detailsLabel}</span> <span className="sr-only">{detailsLabel}</span>
</button> </button>
@ -341,7 +351,7 @@ export default function RecordJobActions({
{addState === 'ok' ? ( {addState === 'ok' ? (
<CheckIcon className={cn(iconFill, 'text-emerald-600 dark:text-emerald-300')} /> <CheckIcon className={cn(iconFill, 'text-emerald-600 dark:text-emerald-300')} />
) : ( ) : (
<ArrowDownTrayIcon className={cn(iconFill, colors.off)} /> <ArrowDownTrayIcon className={cn(iconFill, colors.off)} strokeWidth={outlineStrokeWidth} />
)} )}
</span> </span>
<span className="sr-only">Zu Downloads</span> <span className="sr-only">Zu Downloads</span>
@ -362,7 +372,7 @@ export default function RecordJobActions({
}} }}
> >
<span className={cn('inline-flex items-center justify-center', iconBox)}> <span className={cn('inline-flex items-center justify-center', iconBox)}>
<AcademicCapIcon className={cn(iconFill, colors.off)} /> <AcademicCapIcon className={cn(iconFill, colors.off)} strokeWidth={outlineStrokeWidth} />
</span> </span>
<span className="sr-only">Ins Training übernehmen</span> <span className="sr-only">Ins Training übernehmen</span>
</button> </button>
@ -386,6 +396,7 @@ export default function RecordJobActions({
iconFill, iconFill,
colors.off colors.off
)} )}
strokeWidth={outlineStrokeWidth}
/> />
<StarSolidIcon <StarSolidIcon
className={cn( className={cn(
@ -417,6 +428,7 @@ export default function RecordJobActions({
iconFill, iconFill,
colors.off colors.off
)} )}
strokeWidth={outlineStrokeWidth}
/> />
<HeartSolidIcon <HeartSolidIcon
className={cn( className={cn(
@ -449,6 +461,7 @@ export default function RecordJobActions({
iconFill, iconFill,
colors.off colors.off
)} )}
strokeWidth={outlineStrokeWidth}
/> />
<FireSolidIcon <FireSolidIcon
className={cn( className={cn(
@ -480,6 +493,7 @@ export default function RecordJobActions({
iconFill, iconFill,
colors.off colors.off
)} )}
strokeWidth={outlineStrokeWidth}
/> />
<EyeSolidIcon <EyeSolidIcon
className={cn( className={cn(
@ -503,7 +517,7 @@ export default function RecordJobActions({
onClick={run(onSplit)} onClick={run(onSplit)}
> >
<span className={cn('inline-flex items-center justify-center', iconBox)}> <span className={cn('inline-flex items-center justify-center', iconBox)}>
<ScissorsIcon className={cn(iconFill, colors.off)} /> <ScissorsIcon className={cn(iconFill, colors.off)} strokeWidth={outlineStrokeWidth} />
</span> </span>
</button> </button>
) : null ) : null
@ -518,7 +532,7 @@ export default function RecordJobActions({
onClick={run(onKeep)} onClick={run(onKeep)}
> >
<span className={cn('inline-flex items-center justify-center', iconBox)}> <span className={cn('inline-flex items-center justify-center', iconBox)}>
<BookmarkSquareIcon className={cn(iconFill, colors.keep)} /> <BookmarkSquareIcon className={cn(iconFill, colors.keep)} strokeWidth={outlineStrokeWidth} />
</span> </span>
</button> </button>
) : null ) : null
@ -533,7 +547,7 @@ export default function RecordJobActions({
onClick={run(onDelete)} onClick={run(onDelete)}
> >
<span className={cn('inline-flex items-center justify-center', iconBox)}> <span className={cn('inline-flex items-center justify-center', iconBox)}>
<TrashIcon className={cn(iconFill, colors.del)} /> <TrashIcon className={cn(iconFill, colors.del)} strokeWidth={outlineStrokeWidth} />
</span> </span>
</button> </button>
) : null ) : null
@ -728,7 +742,7 @@ export default function RecordJobActions({
fireDetails() fireDetails()
}} }}
> >
<MagnifyingGlassIcon className={cn('size-4', colors.off)} /> <MagnifyingGlassIcon className={cn('size-4', colors.off)} strokeWidth={outlineStrokeWidth} />
<span className="truncate">Details</span> <span className="truncate">Details</span>
</button> </button>
) )
@ -748,7 +762,7 @@ export default function RecordJobActions({
await doAdd() await doAdd()
}} }}
> >
<ArrowDownTrayIcon className={cn('size-4', colors.off)} /> <ArrowDownTrayIcon className={cn('size-4', colors.off)} strokeWidth={outlineStrokeWidth} />
<span className="truncate">Zu Downloads</span> <span className="truncate">Zu Downloads</span>
</button> </button>
) )
@ -768,7 +782,7 @@ export default function RecordJobActions({
await doTrainingImport() await doTrainingImport()
}} }}
> >
<AcademicCapIcon className={cn('size-4', colors.off)} /> <AcademicCapIcon className={cn('size-4', colors.off)} strokeWidth={outlineStrokeWidth} />
<span className="truncate">Ins Training übernehmen</span> <span className="truncate">Ins Training übernehmen</span>
</button> </button>
) )
@ -788,7 +802,7 @@ export default function RecordJobActions({
await onSplit?.(job) await onSplit?.(job)
}} }}
> >
<ScissorsIcon className={cn('size-4', colors.off)} /> <ScissorsIcon className={cn('size-4', colors.off)} strokeWidth={outlineStrokeWidth} />
<span className="truncate">Video schneiden</span> <span className="truncate">Video schneiden</span>
</button> </button>
) )
@ -811,7 +825,7 @@ export default function RecordJobActions({
{isFavorite ? ( {isFavorite ? (
<StarSolidIcon className={cn('size-4', colors.favOn)} /> <StarSolidIcon className={cn('size-4', colors.favOn)} />
) : ( ) : (
<StarOutlineIcon className={cn('size-4', colors.off)} /> <StarOutlineIcon className={cn('size-4', colors.off)} strokeWidth={outlineStrokeWidth} />
)} )}
<span className="truncate">{isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}</span> <span className="truncate">{isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}</span>
</button> </button>
@ -835,7 +849,7 @@ export default function RecordJobActions({
{isLiked ? ( {isLiked ? (
<HeartSolidIcon className={cn('size-4', colors.favOn)} /> <HeartSolidIcon className={cn('size-4', colors.favOn)} />
) : ( ) : (
<HeartOutlineIcon className={cn('size-4', colors.off)} /> <HeartOutlineIcon className={cn('size-4', colors.off)} strokeWidth={outlineStrokeWidth} />
)} )}
<span className="truncate">{isLiked ? 'Gefällt mir entfernen' : 'Gefällt mir'}</span> <span className="truncate">{isLiked ? 'Gefällt mir entfernen' : 'Gefällt mir'}</span>
</button> </button>
@ -859,7 +873,7 @@ export default function RecordJobActions({
{isWatching ? ( {isWatching ? (
<EyeSolidIcon className={cn('size-4', colors.favOn)} /> <EyeSolidIcon className={cn('size-4', colors.favOn)} />
) : ( ) : (
<EyeOutlineIcon className={cn('size-4', colors.off)} /> <EyeOutlineIcon className={cn('size-4', colors.off)} strokeWidth={outlineStrokeWidth} />
)} )}
<span className="truncate">{isWatching ? 'Watched entfernen' : 'Watched'}</span> <span className="truncate">{isWatching ? 'Watched entfernen' : 'Watched'}</span>
</button> </button>
@ -883,7 +897,7 @@ export default function RecordJobActions({
{isHot ? ( {isHot ? (
<FireSolidIcon className={cn('size-4', colors.favOn)} /> <FireSolidIcon className={cn('size-4', colors.favOn)} />
) : ( ) : (
<FireOutlineIcon className={cn('size-4', colors.off)} /> <FireOutlineIcon className={cn('size-4', colors.off)} strokeWidth={outlineStrokeWidth} />
)} )}
<span className="truncate">{isHot ? 'HOT entfernen' : 'Als HOT markieren'}</span> <span className="truncate">{isHot ? 'HOT entfernen' : 'Als HOT markieren'}</span>
</button> </button>
@ -908,7 +922,7 @@ export default function RecordJobActions({
} }
}} }}
> >
<BookmarkSquareIcon className={cn('size-4', colors.keep)} /> <BookmarkSquareIcon className={cn('size-4', colors.keep)} strokeWidth={outlineStrokeWidth} />
<span className="truncate">Behalten</span> <span className="truncate">Behalten</span>
</button> </button>
) )
@ -932,7 +946,7 @@ export default function RecordJobActions({
} }
}} }}
> >
<TrashIcon className={cn('size-4', colors.del)} /> <TrashIcon className={cn('size-4', colors.del)} strokeWidth={outlineStrokeWidth} />
<span className="truncate">Löschen</span> <span className="truncate">Löschen</span>
</button> </button>
) )
@ -967,7 +981,7 @@ export default function RecordJobActions({
}} }}
> >
<span className={cn('inline-flex items-center justify-center', iconBox)}> <span className={cn('inline-flex items-center justify-center', iconBox)}>
<EllipsisVerticalIcon className={cn(iconFill, colors.off)} /> <EllipsisVerticalIcon className={cn(iconFill, colors.off)} strokeWidth={outlineStrokeWidth} />
</span> </span>
</button> </button>

View File

@ -12,7 +12,9 @@ import PostgresUrlModal from './PostgresUrlModal'
import { CheckIcon, ChevronDownIcon, XMarkIcon } from '@heroicons/react/24/solid' import { CheckIcon, ChevronDownIcon, XMarkIcon } from '@heroicons/react/24/solid'
import { startRegistration } from '@simplewebauthn/browser' import { startRegistration } from '@simplewebauthn/browser'
type RecorderSettings = { type TrainingPerformanceMode = 'auto' | 'eco' | 'balanced' | 'performance' | 'custom'
type Settings = {
databaseType?: 'postgres' databaseType?: 'postgres'
databaseUrl?: string databaseUrl?: string
hasDbPassword?: boolean hasDbPassword?: boolean
@ -46,6 +48,19 @@ type RecorderSettings = {
trainingRecognitionEnabled?: boolean trainingRecognitionEnabled?: boolean
trainingDetectorEpochs?: number 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 = { type AuthDevice = {
@ -79,6 +94,13 @@ type AIServerStatus = {
error?: string error?: string
} }
type TrainingJobStatus = {
running?: boolean
progress?: number
step?: string
message?: string
}
type AppLog = { type AppLog = {
ok: boolean ok: boolean
exists?: boolean exists?: boolean
@ -279,7 +301,7 @@ function SettingsSection(props: {
) )
} }
const DEFAULTS: RecorderSettings = { const DEFAULTS: Settings = {
databaseType: 'postgres', databaseType: 'postgres',
databaseUrl: '', databaseUrl: '',
hasDbPassword: false, hasDbPassword: false,
@ -313,6 +335,70 @@ const DEFAULTS: RecorderSettings = {
trainingRecognitionEnabled: true, trainingRecognitionEnabled: true,
trainingDetectorEpochs: 60, 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 = { type Props = {
@ -678,8 +764,8 @@ function RatingThresholdStars({
) )
} }
export default function RecorderSettings({ onAssetsGenerated }: Props) { export default function Settings({ onAssetsGenerated }: Props) {
const [value, setValue] = useState<RecorderSettings>(DEFAULTS) const [value, setValue] = useState<Settings>(DEFAULTS)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState<number>(0) const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState<number>(0)
const saveSuccessTimerRef = useRef<number | null>(null) const saveSuccessTimerRef = useRef<number | null>(null)
@ -701,6 +787,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [dbRestoreBusy, setDbRestoreBusy] = useState(false) const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
const [dbMaintenanceMsg, setDbMaintenanceMsg] = useState<string | null>(null) const [dbMaintenanceMsg, setDbMaintenanceMsg] = useState<string | null>(null)
const [dbMaintenanceErr, setDbMaintenanceErr] = useState<string | null>(null) const [dbMaintenanceErr, setDbMaintenanceErr] = useState<string | null>(null)
const [trainingJobStatus, setTrainingJobStatus] = useState<TrainingJobStatus | null>(null)
const now = Date.now() const now = Date.now()
const saveSucceeded = saveSuccessUntilMs > now const saveSucceeded = saveSuccessUntilMs > now
const saveFailed = Boolean(err) && !saving && !saveSucceeded const saveFailed = Boolean(err) && !saving && !saveSucceeded
@ -715,6 +802,25 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const savedDatabaseConfigured = Boolean(loadedDatabaseUrl) const savedDatabaseConfigured = Boolean(loadedDatabaseUrl)
const databaseConfigured = savedDatabaseConfigured const databaseConfigured = savedDatabaseConfigured
const dbMaintenanceBusy = dbBackupBusy || dbRestoreBusy 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<AuthSecurityStatus | null>(null) const [authStatus, setAuthStatus] = useState<AuthSecurityStatus | null>(null)
const [securityBusy, setSecurityBusy] = useState(false) const [securityBusy, setSecurityBusy] = useState(false)
@ -850,7 +956,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
if (!r.ok) throw new Error(await r.text()) if (!r.ok) throw new Error(await r.text())
return r.json() return r.json()
}) })
.then((data: RecorderSettings) => { .then((data: Settings) => {
if (!alive) return if (!alive) return
const databaseType = normalizeDatabaseType((data as any).databaseType) const databaseType = normalizeDatabaseType((data as any).databaseType)
setValue({ setValue({
@ -904,6 +1010,32 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
(data as any).trainingRecognitionEnabled ?? DEFAULTS.trainingRecognitionEnabled, (data as any).trainingRecognitionEnabled ?? DEFAULTS.trainingRecognitionEnabled,
trainingDetectorEpochs: trainingDetectorEpochs:
(data as any).trainingDetectorEpochs ?? DEFAULTS.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) setLoadedDatabaseType(databaseType)
setLoadedDatabaseUrl(String((data as any).databaseUrl ?? '').trim()) 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(() => { useEffect(() => {
let alive = true let alive = true
@ -1418,6 +1591,26 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
1, 1,
Math.min(300, Math.floor(Number(value.trainingDetectorEpochs ?? DEFAULTS.trainingDetectorEpochs ?? 60))) 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) setSaving(true)
try { try {
@ -1456,12 +1649,20 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
enrichPostworkEnabled, enrichPostworkEnabled,
trainingRecognitionEnabled, trainingRecognitionEnabled,
trainingDetectorEpochs, trainingDetectorEpochs,
trainingPerformanceMode,
trainingPowerSaveMode,
trainingCpuThreads,
trainingWorkers,
trainingYoloBatchSize,
trainingLowPriority,
trainingVideoMAEEnabled,
}), }),
}) })
if (!res.ok) { if (!res.ok) {
const t = await res.text().catch(() => '') const t = await res.text().catch(() => '')
throw new Error(t || `HTTP ${res.status}`) throw new Error(t || `HTTP ${res.status}`)
} }
const savedSettings = await res.json().catch(() => null)
// Button-Label: "Gespeichert" für 2.5s // Button-Label: "Gespeichert" für 2.5s
const until = Date.now() + 2500 const until = Date.now() + 2500
setSaveSuccessUntilMs(until) setSaveSuccessUntilMs(until)
@ -1476,8 +1677,15 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
window.dispatchEvent(new CustomEvent('recorder-settings-updated')) window.dispatchEvent(new CustomEvent('recorder-settings-updated'))
setValue((v) => ({ setValue((v) => ({
...v, ...v,
...(savedSettings && typeof savedSettings === 'object' ? savedSettings : {}),
databaseType, databaseType,
databaseUrl, databaseUrl,
trainingPerformanceMode: normalizeTrainingPerformanceMode(
savedSettings?.trainingPerformanceMode ?? trainingPerformanceMode
),
trainingEffectiveMode: normalizeTrainingPerformanceMode(
savedSettings?.trainingEffectiveMode ?? savedSettings?.trainingPerformanceMode ?? trainingPerformanceMode
),
})) }))
const databaseChanged = const databaseChanged =
@ -2397,16 +2605,27 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
<div className="space-y-3"> <div className="space-y-3">
<LabeledSwitch <LabeledSwitch
checked={!!value.enrichPostworkEnabled} checked={!trainingSettingsLocked && !!value.enrichPostworkEnabled}
onChange={(checked) => onChange={(checked) =>
setValue((v) => ({ setValue((v) => ({
...v, ...v,
enrichPostworkEnabled: checked, enrichPostworkEnabled: checked,
})) }))
} }
disabled={trainingSettingsLocked}
label="EnrichQ-Postwork im Hintergrund" 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 ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-medium text-amber-900 dark:border-amber-400/30 dark:bg-amber-500/10 dark:text-amber-100">
EnrichQ-Postwork ist pausiert, solange das Training läuft.
</div>
) : null}
<LabeledSwitch <LabeledSwitch
checked={!!value.autoAddToDownloadList} checked={!!value.autoAddToDownloadList}
@ -2627,6 +2846,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
min={1} min={1}
max={300} max={300}
step={1} step={1}
disabled={trainingSettingsLocked}
value={value.trainingDetectorEpochs ?? 60} value={value.trainingDetectorEpochs ?? 60}
onChange={(e) => onChange={(e) =>
setValue((v) => ({ 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 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" dark:border-white/10 dark:bg-gray-900 dark:text-gray-100"
/> />
@ -2644,6 +2865,231 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
</div> </div>
</div> </div>
</div> </div>
<div className="mt-4 border-t border-gray-200 pt-3 dark:border-white/10">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Training-Schonmodus
</div>
<div className="mt-1 max-w-3xl text-xs text-gray-600 dark:text-gray-300">
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.
</div>
</div>
{trainingCpuCoreCount > 0 ? (
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10">
{trainingCpuCoreCount} CPU-Threads erkannt
</span>
) : null}
</div>
{trainingSettingsLocked ? (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-medium text-amber-900 dark:border-amber-400/30 dark:bg-amber-500/10 dark:text-amber-100">
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 ? (
<span className="ml-1 font-semibold">{trainingJobStatus.step}</span>
) : null}
</div>
) : (
<div className="mt-3 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
Hinweis: Diese Werte werden beim Start des Trainings an Python/PyTorch übergeben und wirken nicht nachträglich auf bereits laufende Trainingsprozesse.
</div>
)}
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-5">
{TRAINING_PERFORMANCE_MODES.map((mode) => {
const active = trainingPerformanceMode === mode.key
return (
<button
key={mode.key}
type="button"
disabled={trainingSettingsLocked}
onClick={() =>
setValue((v) => ({
...v,
trainingPerformanceMode: mode.key,
trainingPowerSaveMode: mode.key === 'eco',
trainingLowPriority:
mode.key === 'eco'
? true
: mode.key === 'custom'
? v.trainingLowPriority
: false,
}))
}
className={[
'rounded-lg border px-3 py-2 text-left transition disabled:cursor-not-allowed disabled:opacity-60',
active
? 'border-indigo-300 bg-indigo-50 text-indigo-950 ring-1 ring-indigo-200 dark:border-indigo-400/40 dark:bg-indigo-500/15 dark:text-indigo-100 dark:ring-indigo-400/30'
: 'border-gray-200 bg-white text-gray-800 hover:bg-gray-50 dark:border-white/10 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-white/10',
].join(' ')}
>
<div className="text-sm font-bold">
{mode.title}
</div>
<div className="mt-0.5 text-[11px] leading-snug opacity-75">
{mode.description}
</div>
</button>
)
})}
</div>
<div className="mt-3 grid grid-cols-2 gap-2 rounded-xl border border-gray-200 bg-white p-3 text-xs text-gray-700 dark:border-white/10 dark:bg-gray-950/40 dark:text-gray-200 sm:grid-cols-5">
<div>
<div className="font-semibold text-gray-900 dark:text-white">Wirksam</div>
<div className="mt-0.5 capitalize">{trainingEffectiveMode}</div>
</div>
<div>
<div className="font-semibold text-gray-900 dark:text-white">Threads</div>
<div className="mt-0.5 tabular-nums">{formatTrainingEffectiveValue(trainingEffectiveCpuThreads, 'Auto')}</div>
</div>
<div>
<div className="font-semibold text-gray-900 dark:text-white">Worker</div>
<div className="mt-0.5 tabular-nums">{formatTrainingEffectiveValue(trainingEffectiveWorkers, '0')}</div>
</div>
<div>
<div className="font-semibold text-gray-900 dark:text-white">YOLO-Batch</div>
<div className="mt-0.5 tabular-nums">{formatTrainingEffectiveValue(trainingEffectiveYoloBatchSize, 'Auto')}</div>
</div>
<div>
<div className="font-semibold text-gray-900 dark:text-white">Priorität</div>
<div className="mt-0.5">{trainingEffectiveLowPriority ? 'Niedrig' : 'Normal'}</div>
</div>
</div>
<div
className={[
'mt-3 grid grid-cols-1 gap-3 lg:grid-cols-3',
trainingManualMode && !trainingSettingsLocked ? '' : 'opacity-60',
].join(' ')}
>
<div>
<label
htmlFor="trainingCpuThreads"
className="text-sm font-medium text-gray-900 dark:text-gray-200"
>
CPU-Threads
</label>
<div className="mt-1 flex items-center gap-2">
<input
id="trainingCpuThreads"
type="number"
min={0}
max={32}
step={1}
disabled={!trainingManualMode || trainingSettingsLocked}
value={value.trainingCpuThreads ?? 0}
onChange={(e) =>
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"
/>
<span className="shrink-0 text-xs text-gray-600 dark:text-gray-300">
0 = Auto
</span>
</div>
</div>
<div>
<label
htmlFor="trainingWorkers"
className="text-sm font-medium text-gray-900 dark:text-gray-200"
>
Loader-Worker
</label>
<div className="mt-1 flex items-center gap-2">
<input
id="trainingWorkers"
type="number"
min={0}
max={16}
step={1}
disabled={!trainingManualMode || trainingSettingsLocked}
value={value.trainingWorkers ?? 2}
onChange={(e) =>
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"
/>
<span className="shrink-0 text-xs text-gray-600 dark:text-gray-300">
0-16
</span>
</div>
</div>
<div>
<label
htmlFor="trainingYoloBatchSize"
className="text-sm font-medium text-gray-900 dark:text-gray-200"
>
YOLO-Batch
</label>
<div className="mt-1 flex items-center gap-2">
<input
id="trainingYoloBatchSize"
type="number"
min={0}
max={64}
step={1}
disabled={!trainingManualMode || trainingSettingsLocked}
value={value.trainingYoloBatchSize ?? 0}
onChange={(e) =>
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"
/>
<span className="shrink-0 text-xs text-gray-600 dark:text-gray-300">
0 = Auto
</span>
</div>
</div>
</div>
<div className="mt-3 grid grid-cols-1 gap-3 lg:grid-cols-2">
<LabeledSwitch
checked={
trainingManualMode
? !!value.trainingLowPriority
: trainingEffectiveLowPriority
}
onChange={(checked) =>
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."
/>
<LabeledSwitch
checked={value.trainingVideoMAEEnabled !== false}
onChange={(checked) =>
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."
/>
</div>
</div>
</div> </div>
<LabeledSwitch <LabeledSwitch

File diff suppressed because it is too large Load Diff

View File

@ -282,6 +282,19 @@ export type RecorderSettingsState = {
trainingRecognitionEnabled?: boolean trainingRecognitionEnabled?: boolean
trainingDetectorEpochs?: number trainingDetectorEpochs?: number
trainingPerformanceMode?: 'auto' | 'eco' | 'balanced' | 'performance' | 'custom' | string
trainingPowerSaveMode?: boolean
trainingCpuThreads?: number
trainingWorkers?: number
trainingYoloBatchSize?: number
trainingLowPriority?: boolean
trainingVideoMAEEnabled?: boolean
trainingCpuCoreCount?: number
trainingEffectiveMode?: string
trainingEffectiveCpuThreads?: number
trainingEffectiveWorkers?: number
trainingEffectiveYoloBatchSize?: number
trainingEffectiveLowPriority?: boolean
} }
export type JobEvent = { export type JobEvent = {