bugfixes
This commit is contained in:
parent
885a22e206
commit
e4a474ad54
Binary file not shown.
188
backend/ai_server_process_other.go
Normal file
188
backend/ai_server_process_other.go
Normal 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"))
|
||||
}
|
||||
128
backend/ai_server_process_windows.go
Normal file
128
backend/ai_server_process_windows.go
Normal 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"))
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
BIN
backend/dist/nsfwapp-linux-amd64
vendored
BIN
backend/dist/nsfwapp-linux-amd64
vendored
Binary file not shown.
BIN
backend/dist/nsfwapp.exe
vendored
BIN
backend/dist/nsfwapp.exe
vendored
Binary file not shown.
BIN
backend/dist/nsfwapp_amd64.deb
vendored
BIN
backend/dist/nsfwapp_amd64.deb
vendored
Binary file not shown.
@ -71,6 +71,16 @@ func trainingEmbeddedMLDir() (string, error) {
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func trainingMLCacheFilePath(name string) (string, error) {
|
||||
dir := filepath.Join(os.TempDir(), "nsfwapp-ml")
|
||||
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(dir, name), nil
|
||||
}
|
||||
|
||||
func embeddedMLScriptExists(name string) bool {
|
||||
srcPath := filepath.ToSlash(filepath.Join("ml", name))
|
||||
_, err := embeddedMLFiles.ReadFile(srcPath)
|
||||
|
||||
@ -147,6 +147,10 @@ func appLogLineSuppressed(line string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
if m == "not found" {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.Contains(m, "mfc status failed") &&
|
||||
(strings.Contains(m, "user not found") || strings.Contains(m, "usernamelookup")) {
|
||||
return true
|
||||
|
||||
@ -53,6 +53,57 @@ def safe_int(value, fallback):
|
||||
return fallback
|
||||
|
||||
|
||||
def load_base_model(base):
|
||||
base_text = str(base or "").strip()
|
||||
base_path = Path(base_text).expanduser()
|
||||
|
||||
is_explicit_path = base_path.is_absolute() or base_path.parent != Path(".")
|
||||
base_exists = base_path.is_file() and base_path.stat().st_size > 0
|
||||
if not is_explicit_path or base_exists:
|
||||
return YOLO(base_text), base_text
|
||||
|
||||
base_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
model_name = base_path.name
|
||||
model = YOLO(model_name)
|
||||
|
||||
candidates = []
|
||||
ckpt_path = getattr(model, "ckpt_path", None)
|
||||
if ckpt_path:
|
||||
candidates.append(Path(str(ckpt_path)).expanduser())
|
||||
|
||||
candidates.append(Path.cwd() / model_name)
|
||||
candidates.append(Path(model_name))
|
||||
|
||||
copied = False
|
||||
for candidate in candidates:
|
||||
try:
|
||||
candidate = candidate.resolve()
|
||||
target = base_path.resolve()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not candidate.exists() or candidate == target:
|
||||
continue
|
||||
|
||||
try:
|
||||
shutil.copy2(candidate, target)
|
||||
copied = True
|
||||
|
||||
if candidate.parent == Path.cwd().resolve():
|
||||
try:
|
||||
candidate.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if copied and base_path.exists():
|
||||
return YOLO(str(base_path)), str(base_path)
|
||||
|
||||
return model, model_name
|
||||
|
||||
|
||||
def batch_sample_ids(batch):
|
||||
if not isinstance(batch, dict):
|
||||
return []
|
||||
@ -126,6 +177,8 @@ def main():
|
||||
parser.add_argument("--imgsz", default="640")
|
||||
parser.add_argument("--device", default="auto")
|
||||
parser.add_argument("--workers", default="2")
|
||||
parser.add_argument("--batch", default="0")
|
||||
parser.add_argument("--threads", default="0")
|
||||
parser.add_argument("--patience", default="20")
|
||||
args = parser.parse_args()
|
||||
|
||||
@ -140,8 +193,17 @@ def main():
|
||||
epochs = max(1, safe_int(args.epochs, 80))
|
||||
imgsz = max(64, safe_int(args.imgsz, 640))
|
||||
workers = max(0, safe_int(args.workers, 2))
|
||||
batch_size = max(0, safe_int(args.batch, 0))
|
||||
threads = max(0, safe_int(args.threads, 0))
|
||||
patience = max(0, safe_int(args.patience, 20))
|
||||
|
||||
if threads > 0:
|
||||
torch.set_num_threads(threads)
|
||||
try:
|
||||
torch.set_num_interop_threads(max(1, min(threads, 4)))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if str(args.device).lower() == "auto":
|
||||
train_device = 0 if torch.cuda.is_available() else "cpu"
|
||||
else:
|
||||
@ -178,7 +240,7 @@ def main():
|
||||
device=str(train_device),
|
||||
)
|
||||
|
||||
model = YOLO(args.base)
|
||||
model, base_used = load_base_model(args.base)
|
||||
|
||||
best_epoch = 0
|
||||
last_epoch = 0
|
||||
@ -273,23 +335,27 @@ def main():
|
||||
device=str(train_device),
|
||||
)
|
||||
|
||||
result = model.train(
|
||||
trainer=progress_detection_trainer(
|
||||
train_kwargs = {
|
||||
"trainer": progress_detection_trainer(
|
||||
train_count,
|
||||
val_count,
|
||||
train_device,
|
||||
epochs,
|
||||
),
|
||||
data=str(yaml_path),
|
||||
epochs=epochs,
|
||||
imgsz=imgsz,
|
||||
project=str(runs_dir),
|
||||
name="detect",
|
||||
exist_ok=True,
|
||||
device=train_device,
|
||||
workers=workers,
|
||||
patience=patience,
|
||||
)
|
||||
"data": str(yaml_path),
|
||||
"epochs": epochs,
|
||||
"imgsz": imgsz,
|
||||
"project": str(runs_dir),
|
||||
"name": "detect",
|
||||
"exist_ok": True,
|
||||
"device": train_device,
|
||||
"workers": workers,
|
||||
"patience": patience,
|
||||
}
|
||||
if batch_size > 0:
|
||||
train_kwargs["batch"] = batch_size
|
||||
|
||||
result = model.train(**train_kwargs)
|
||||
|
||||
emit_progress(
|
||||
"detector",
|
||||
@ -325,6 +391,9 @@ def main():
|
||||
"valSamples": val_count,
|
||||
"epochs": epochs,
|
||||
"imgsz": imgsz,
|
||||
"batchSize": batch_size,
|
||||
"threads": threads,
|
||||
"workers": workers,
|
||||
"device": str(train_device),
|
||||
"mAP50": round(best_map50, 4),
|
||||
"mAP5095": round(best_map5095, 4),
|
||||
|
||||
@ -164,6 +164,8 @@ def main():
|
||||
parser.add_argument("--imgsz", default="640")
|
||||
parser.add_argument("--device", default="auto")
|
||||
parser.add_argument("--workers", default="2")
|
||||
parser.add_argument("--batch", default="0")
|
||||
parser.add_argument("--threads", default="0")
|
||||
parser.add_argument("--patience", default="20")
|
||||
args = parser.parse_args()
|
||||
|
||||
@ -178,8 +180,17 @@ def main():
|
||||
epochs = max(1, safe_int(args.epochs, 80))
|
||||
imgsz = max(64, safe_int(args.imgsz, 640))
|
||||
workers = max(0, safe_int(args.workers, 2))
|
||||
batch_size = max(0, safe_int(args.batch, 0))
|
||||
threads = max(0, safe_int(args.threads, 0))
|
||||
patience = max(0, safe_int(args.patience, 20))
|
||||
|
||||
if threads > 0:
|
||||
torch.set_num_threads(threads)
|
||||
try:
|
||||
torch.set_num_interop_threads(max(1, min(threads, 4)))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if str(args.device).lower() == "auto":
|
||||
train_device = 0 if torch.cuda.is_available() else "cpu"
|
||||
else:
|
||||
@ -310,23 +321,27 @@ def main():
|
||||
device=str(train_device),
|
||||
)
|
||||
|
||||
result = model.train(
|
||||
trainer=progress_pose_trainer(
|
||||
train_kwargs = {
|
||||
"trainer": progress_pose_trainer(
|
||||
train_count,
|
||||
val_count,
|
||||
train_device,
|
||||
epochs,
|
||||
),
|
||||
data=str(yaml_path),
|
||||
epochs=epochs,
|
||||
imgsz=imgsz,
|
||||
project=str(runs_dir),
|
||||
name="pose",
|
||||
exist_ok=True,
|
||||
device=train_device,
|
||||
workers=workers,
|
||||
patience=patience,
|
||||
)
|
||||
"data": str(yaml_path),
|
||||
"epochs": epochs,
|
||||
"imgsz": imgsz,
|
||||
"project": str(runs_dir),
|
||||
"name": "pose",
|
||||
"exist_ok": True,
|
||||
"device": train_device,
|
||||
"workers": workers,
|
||||
"patience": patience,
|
||||
}
|
||||
if batch_size > 0:
|
||||
train_kwargs["batch"] = batch_size
|
||||
|
||||
result = model.train(**train_kwargs)
|
||||
|
||||
emit_progress(
|
||||
"pose",
|
||||
@ -362,6 +377,9 @@ def main():
|
||||
"valSamples": val_count,
|
||||
"epochs": epochs,
|
||||
"imgsz": imgsz,
|
||||
"batchSize": batch_size,
|
||||
"threads": threads,
|
||||
"workers": workers,
|
||||
"device": str(train_device),
|
||||
"mAP50": round(best_map50, 4),
|
||||
"mAP5095": round(best_map5095, 4),
|
||||
|
||||
@ -198,6 +198,7 @@ def main():
|
||||
parser.add_argument("--lr", default="5e-5")
|
||||
parser.add_argument("--device", default="auto")
|
||||
parser.add_argument("--workers", default="0")
|
||||
parser.add_argument("--threads", default="0")
|
||||
parser.add_argument("--num-frames", default="16")
|
||||
parser.add_argument("--freeze-backbone", action="store_true")
|
||||
args = parser.parse_args()
|
||||
@ -210,9 +211,17 @@ def main():
|
||||
epochs = max(1, safe_int(args.epochs, 8))
|
||||
batch_size = max(1, safe_int(args.batch_size, 2))
|
||||
workers = max(0, safe_int(args.workers, 0))
|
||||
threads = max(0, safe_int(args.threads, 0))
|
||||
num_frames = max(2, safe_int(args.num_frames, 16))
|
||||
lr = max(1e-7, safe_float(args.lr, 5e-5))
|
||||
|
||||
if threads > 0:
|
||||
torch.set_num_threads(threads)
|
||||
try:
|
||||
torch.set_num_interop_threads(max(1, min(threads, 4)))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if str(args.device).lower() == "auto":
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
else:
|
||||
@ -364,6 +373,9 @@ def main():
|
||||
"bestEpoch": best_epoch,
|
||||
"trainSamples": len(train_entries),
|
||||
"valSamples": len(val_entries),
|
||||
"batchSize": batch_size,
|
||||
"workers": workers,
|
||||
"threads": threads,
|
||||
"numFrames": num_frames,
|
||||
"baseModel": args.base,
|
||||
"device": str(device),
|
||||
|
||||
@ -495,9 +495,11 @@ func waitUntilEnrichMayRun(ctx context.Context) error {
|
||||
|
||||
for {
|
||||
waiting, running, _ := postWorkQ.Stats()
|
||||
trainingRunning := trainingGetJobStatus().Running
|
||||
// Auch waehrend eines Trainings pausieren, damit EnrichQ keine CPU stiehlt.
|
||||
|
||||
// enrich erst starten, wenn keine primäre Postwork mehr läuft
|
||||
if waiting == 0 && running == 0 {
|
||||
if waiting == 0 && running == 0 && !trainingRunning {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
{
|
||||
"databaseType": "postgres",
|
||||
"databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable",
|
||||
"encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=",
|
||||
"recordDir": "C:\\Users\\Rother\\Desktop\\Privat\\records",
|
||||
@ -29,5 +30,12 @@
|
||||
"enrichPostworkEnabled": false,
|
||||
"trainingRecognitionEnabled": true,
|
||||
"trainingDetectorEpochs": 60,
|
||||
"trainingPerformanceMode": "auto",
|
||||
"trainingPowerSaveMode": false,
|
||||
"trainingCpuThreads": 0,
|
||||
"trainingWorkers": 2,
|
||||
"trainingYoloBatchSize": 0,
|
||||
"trainingLowPriority": false,
|
||||
"trainingVideoMAEEnabled": true,
|
||||
"encryptedCookies": "p0aRfigWn3+TfypNAV7vPky13RW+gwhTlhNOGKKAED0FSMf0kdGV6LgIizzTeDMD5Ck+9SGGf6EfVg+RPnEr3ZjkkkjOTVGOJ9XNzpuczHqzi0ifmt0l9/lnNTaqh8oTzrk09uQC9YYe0CtsY6gjpYFAdVEPGimCPdULqn+8Jln1CSDiBGRGF0uZGxfmAZ1T/YQym/wjiCCdIRfcjTF4f+sz6PMBQOOnTA74if1OFNA6PxpGoXfHS7xvMeV6RX3J/qcTyMmFWr+uD2h1UKs7N9w4Y1sfw4heB10jzy/VL+9gWg2o4AKE5tGqOmlVYpv8KculMWfJHNxNpRonPNizPR4jO2jZYdr3AikuXTTeILyN7hb4MtO4fXblJ+9of1ABR6LvDe1dAXmbkNdqEqRo0k1WS6jaCdNkrleP8vGuH56H7jZMnEeJAcQxS99kVjFo0qMAEJ2ai8HPkdYURC3dWSu9uIbGmiqi6hgva2WM6jPG7TqZDyqUS7hjJwAmPa3aLPnsEWHqkcE="
|
||||
}
|
||||
|
||||
@ -99,12 +99,14 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
||||
api.HandleFunc("/api/training/train", trainingTrainHandler)
|
||||
api.HandleFunc("/api/training/cancel", trainingCancelHandler)
|
||||
api.HandleFunc("/api/training/status", trainingStatusHandler)
|
||||
api.HandleFunc("/api/training/analysis/status", trainingAnalysisStatusHandler)
|
||||
api.HandleFunc("/api/training/stats", trainingStatsHandler)
|
||||
api.HandleFunc("/api/training/history", trainingHistoryHandler)
|
||||
api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler)
|
||||
api.HandleFunc("/api/training/skip", trainingSkipHandler)
|
||||
api.HandleFunc("/api/training/import-video", trainingImportVideoHandler)
|
||||
api.HandleFunc("/api/training/import-video/status", trainingImportVideoStatusHandler)
|
||||
api.HandleFunc("/api/training/next/status", trainingNextStatusHandler)
|
||||
api.HandleFunc("/api/training/video-preview", trainingVideoPreviewHandler)
|
||||
|
||||
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
@ -100,6 +101,26 @@ func buildStartupNotificationMessage() string {
|
||||
type aiServerProcess struct {
|
||||
cmd *exec.Cmd
|
||||
done chan struct{}
|
||||
mu sync.Mutex
|
||||
stopping bool
|
||||
}
|
||||
|
||||
func (p *aiServerProcess) markStopping() {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.stopping = true
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *aiServerProcess) isStopping() bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.stopping
|
||||
}
|
||||
|
||||
// Globales Handle auf den laufenden AI-Server, damit er (z.B. nach einem neuen
|
||||
@ -184,10 +205,12 @@ func aiServerURL() string {
|
||||
}
|
||||
|
||||
func aiServerPortFromURL() string {
|
||||
url := aiServerURL()
|
||||
rawURL := aiServerURL()
|
||||
|
||||
if strings.Contains(url, ":8765") {
|
||||
return "8765"
|
||||
if parsed, err := neturl.Parse(rawURL); err == nil && parsed != nil {
|
||||
if port := strings.TrimSpace(parsed.Port()); port != "" {
|
||||
return port
|
||||
}
|
||||
}
|
||||
|
||||
// Einfacher Fallback. Wenn du später andere Ports willst,
|
||||
@ -499,6 +522,10 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if err := stopStaleAIServerOnPort(aiServerPortFromURL()); err != nil {
|
||||
appLogln("⚠️ Alter AI Server konnte nicht bereinigt werden:", err)
|
||||
}
|
||||
|
||||
if err := ensureMLPythonSetup(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -552,7 +579,7 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
|
||||
"--log-level", "error",
|
||||
)
|
||||
|
||||
cmd := exec.CommandContext(ctx, pythonPath, args...)
|
||||
cmd := exec.Command(pythonPath, args...)
|
||||
cmd.Dir = scriptDir
|
||||
|
||||
env := os.Environ()
|
||||
@ -612,6 +639,12 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
|
||||
hideCommandWindow(cmd)
|
||||
}
|
||||
|
||||
prepareAIServerCommandForLifecycle(cmd)
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -624,7 +657,7 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
|
||||
if err != nil && ctx.Err() == nil {
|
||||
if err != nil && ctx.Err() == nil && !proc.isStopping() {
|
||||
appLogf("⚠️ AI Server beendet: %v", err)
|
||||
} else {
|
||||
appLogln("🛑 AI Server beendet.")
|
||||
@ -648,9 +681,8 @@ func (p *aiServerProcess) Stop() {
|
||||
return
|
||||
}
|
||||
|
||||
if p.cmd != nil && p.cmd.Process != nil {
|
||||
_ = p.cmd.Process.Kill()
|
||||
}
|
||||
p.markStopping()
|
||||
terminateAIServerProcessTree(p.cmd)
|
||||
|
||||
// Auf das tatsächliche Prozessende warten, damit der Port frei ist,
|
||||
// bevor ggf. ein neuer Server gestartet wird.
|
||||
|
||||
@ -58,6 +58,13 @@ type RecorderSettings struct {
|
||||
|
||||
TrainingRecognitionEnabled bool `json:"trainingRecognitionEnabled"`
|
||||
TrainingDetectorEpochs int `json:"trainingDetectorEpochs"`
|
||||
TrainingPerformanceMode string `json:"trainingPerformanceMode"`
|
||||
TrainingPowerSaveMode bool `json:"trainingPowerSaveMode"`
|
||||
TrainingCPUThreads int `json:"trainingCpuThreads"`
|
||||
TrainingWorkers int `json:"trainingWorkers"`
|
||||
TrainingYoloBatchSize int `json:"trainingYoloBatchSize"`
|
||||
TrainingLowPriority bool `json:"trainingLowPriority"`
|
||||
TrainingVideoMAEEnabled bool `json:"trainingVideoMAEEnabled"`
|
||||
|
||||
// EncryptedCookies contains base64(nonce+ciphertext) of a JSON cookie map.
|
||||
EncryptedCookies string `json:"encryptedCookies"`
|
||||
@ -107,6 +114,13 @@ var (
|
||||
|
||||
TrainingRecognitionEnabled: true,
|
||||
TrainingDetectorEpochs: 60,
|
||||
TrainingPerformanceMode: trainingPerformanceAuto,
|
||||
TrainingPowerSaveMode: false,
|
||||
TrainingCPUThreads: 0,
|
||||
TrainingWorkers: 2,
|
||||
TrainingYoloBatchSize: 0,
|
||||
TrainingLowPriority: false,
|
||||
TrainingVideoMAEEnabled: true,
|
||||
|
||||
EncryptedCookies: "",
|
||||
}
|
||||
@ -136,6 +150,47 @@ func normalizeSettingsDir(raw string, fallback string, legacyDefaults ...string)
|
||||
return path
|
||||
}
|
||||
|
||||
func normalizeTrainingSettings(s *RecorderSettings) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if s.TrainingDetectorEpochs < 1 {
|
||||
s.TrainingDetectorEpochs = 60
|
||||
}
|
||||
if s.TrainingDetectorEpochs > 300 {
|
||||
s.TrainingDetectorEpochs = 300
|
||||
}
|
||||
|
||||
rawMode := strings.TrimSpace(s.TrainingPerformanceMode)
|
||||
if rawMode == "" && s.TrainingPowerSaveMode {
|
||||
rawMode = trainingPerformanceEco
|
||||
}
|
||||
s.TrainingPerformanceMode = normalizeTrainingPerformanceMode(rawMode)
|
||||
s.TrainingPowerSaveMode = s.TrainingPerformanceMode == trainingPerformanceEco
|
||||
|
||||
if s.TrainingCPUThreads < 0 {
|
||||
s.TrainingCPUThreads = 0
|
||||
}
|
||||
if s.TrainingCPUThreads > 32 {
|
||||
s.TrainingCPUThreads = 32
|
||||
}
|
||||
|
||||
if s.TrainingWorkers < 0 {
|
||||
s.TrainingWorkers = 0
|
||||
}
|
||||
if s.TrainingWorkers > 16 {
|
||||
s.TrainingWorkers = 16
|
||||
}
|
||||
|
||||
if s.TrainingYoloBatchSize < 0 {
|
||||
s.TrainingYoloBatchSize = 0
|
||||
}
|
||||
if s.TrainingYoloBatchSize > 64 {
|
||||
s.TrainingYoloBatchSize = 64
|
||||
}
|
||||
}
|
||||
|
||||
func settingsFilePath() string {
|
||||
// optionaler Override per ENV
|
||||
name := settingsFile
|
||||
@ -198,12 +253,7 @@ func loadSettings() {
|
||||
s.LowDiskPauseBelowGB = 10_000
|
||||
}
|
||||
|
||||
if s.TrainingDetectorEpochs < 1 {
|
||||
s.TrainingDetectorEpochs = 60
|
||||
}
|
||||
if s.TrainingDetectorEpochs > 300 {
|
||||
s.TrainingDetectorEpochs = 300
|
||||
}
|
||||
normalizeTrainingSettings(&s)
|
||||
|
||||
settingsMu.Lock()
|
||||
settings = s
|
||||
@ -318,10 +368,25 @@ type RecorderSettingsPublic struct {
|
||||
|
||||
TrainingRecognitionEnabled bool `json:"trainingRecognitionEnabled"`
|
||||
TrainingDetectorEpochs int `json:"trainingDetectorEpochs"`
|
||||
TrainingPerformanceMode string `json:"trainingPerformanceMode"`
|
||||
TrainingPowerSaveMode bool `json:"trainingPowerSaveMode"`
|
||||
TrainingCPUThreads int `json:"trainingCpuThreads"`
|
||||
TrainingWorkers int `json:"trainingWorkers"`
|
||||
TrainingYoloBatchSize int `json:"trainingYoloBatchSize"`
|
||||
TrainingLowPriority bool `json:"trainingLowPriority"`
|
||||
TrainingVideoMAEEnabled bool `json:"trainingVideoMAEEnabled"`
|
||||
TrainingCPUCoreCount int `json:"trainingCpuCoreCount"`
|
||||
TrainingEffectiveMode string `json:"trainingEffectiveMode"`
|
||||
TrainingEffectiveCPUThreads int `json:"trainingEffectiveCpuThreads"`
|
||||
TrainingEffectiveWorkers int `json:"trainingEffectiveWorkers"`
|
||||
TrainingEffectiveYoloBatchSize int `json:"trainingEffectiveYoloBatchSize"`
|
||||
TrainingEffectiveLowPriority bool `json:"trainingEffectiveLowPriority"`
|
||||
}
|
||||
|
||||
func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
|
||||
normalizePostgresSettings(&s)
|
||||
normalizeTrainingSettings(&s)
|
||||
trainingOpts := trainingRuntimeOptionsFromRecorderSettings(s)
|
||||
return RecorderSettingsPublic{
|
||||
RecordDir: s.RecordDir,
|
||||
DoneDir: s.DoneDir,
|
||||
@ -364,6 +429,19 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
|
||||
|
||||
TrainingRecognitionEnabled: s.TrainingRecognitionEnabled,
|
||||
TrainingDetectorEpochs: s.TrainingDetectorEpochs,
|
||||
TrainingPerformanceMode: s.TrainingPerformanceMode,
|
||||
TrainingPowerSaveMode: s.TrainingPowerSaveMode,
|
||||
TrainingCPUThreads: s.TrainingCPUThreads,
|
||||
TrainingWorkers: s.TrainingWorkers,
|
||||
TrainingYoloBatchSize: s.TrainingYoloBatchSize,
|
||||
TrainingLowPriority: s.TrainingLowPriority,
|
||||
TrainingVideoMAEEnabled: s.TrainingVideoMAEEnabled,
|
||||
TrainingCPUCoreCount: trainingOpts.CPUCoreCount,
|
||||
TrainingEffectiveMode: trainingOpts.PerformanceMode,
|
||||
TrainingEffectiveCPUThreads: trainingOpts.CPUThreads,
|
||||
TrainingEffectiveWorkers: trainingOpts.Workers,
|
||||
TrainingEffectiveYoloBatchSize: trainingOpts.YoloBatchSize,
|
||||
TrainingEffectiveLowPriority: trainingOpts.LowPriority,
|
||||
}
|
||||
}
|
||||
|
||||
@ -438,12 +516,7 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
in.MaxConcurrentDownloads = 1
|
||||
}
|
||||
|
||||
if in.TrainingDetectorEpochs < 1 {
|
||||
in.TrainingDetectorEpochs = 60
|
||||
}
|
||||
if in.TrainingDetectorEpochs > 300 {
|
||||
in.TrainingDetectorEpochs = 300
|
||||
}
|
||||
normalizeTrainingSettings(&in.RecorderSettings)
|
||||
|
||||
// --- ensure folders (Fehler zurückgeben, falls z.B. keine Rechte) ---
|
||||
recAbs, err := resolvePathRelativeToApp(in.RecordDir)
|
||||
@ -551,6 +624,13 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
next.TrainingRecognitionEnabled = in.TrainingRecognitionEnabled
|
||||
next.TrainingDetectorEpochs = in.TrainingDetectorEpochs
|
||||
next.TrainingPerformanceMode = in.TrainingPerformanceMode
|
||||
next.TrainingPowerSaveMode = in.TrainingPowerSaveMode
|
||||
next.TrainingCPUThreads = in.TrainingCPUThreads
|
||||
next.TrainingWorkers = in.TrainingWorkers
|
||||
next.TrainingYoloBatchSize = in.TrainingYoloBatchSize
|
||||
next.TrainingLowPriority = in.TrainingLowPriority
|
||||
next.TrainingVideoMAEEnabled = in.TrainingVideoMAEEnabled
|
||||
|
||||
dbChanged :=
|
||||
normalizeDatabaseType(next.DatabaseType) != normalizeDatabaseType(current.DatabaseType) ||
|
||||
|
||||
@ -18,6 +18,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -768,6 +769,109 @@ func trainingPublishJobStatus(status TrainingJobStatus) {
|
||||
publishSSE("training", b)
|
||||
}
|
||||
|
||||
type trainingAnalysisStatusEntry struct {
|
||||
updatedAt time.Time
|
||||
payload map[string]any
|
||||
}
|
||||
|
||||
var trainingAnalysisStatuses = struct {
|
||||
sync.Mutex
|
||||
items map[string]trainingAnalysisStatusEntry
|
||||
}{
|
||||
items: map[string]trainingAnalysisStatusEntry{},
|
||||
}
|
||||
|
||||
const trainingAnalysisStatusTTL = 2 * time.Hour
|
||||
|
||||
func trainingRememberAnalysisPayload(payload map[string]any) {
|
||||
requestID := strings.TrimSpace(fmt.Sprint(payload["requestId"]))
|
||||
if requestID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
copied := make(map[string]any, len(payload))
|
||||
for k, v := range payload {
|
||||
copied[k] = v
|
||||
}
|
||||
|
||||
trainingAnalysisStatuses.Lock()
|
||||
for id, entry := range trainingAnalysisStatuses.items {
|
||||
if !entry.updatedAt.IsZero() && now.Sub(entry.updatedAt) > trainingAnalysisStatusTTL {
|
||||
delete(trainingAnalysisStatuses.items, id)
|
||||
}
|
||||
}
|
||||
trainingAnalysisStatuses.items[requestID] = trainingAnalysisStatusEntry{
|
||||
updatedAt: now,
|
||||
payload: copied,
|
||||
}
|
||||
trainingAnalysisStatuses.Unlock()
|
||||
}
|
||||
|
||||
func trainingAnalysisStatusPayload(requestID string) map[string]any {
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
|
||||
trainingAnalysisStatuses.Lock()
|
||||
defer trainingAnalysisStatuses.Unlock()
|
||||
|
||||
if requestID != "" {
|
||||
entry, ok := trainingAnalysisStatuses.items[requestID]
|
||||
if !ok || entry.payload == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make(map[string]any, len(entry.payload))
|
||||
for k, v := range entry.payload {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var latest *trainingAnalysisStatusEntry
|
||||
for _, entry := range trainingAnalysisStatuses.items {
|
||||
if entry.payload == nil || !trainingAnalysisValueTruthy(entry.payload["running"]) {
|
||||
continue
|
||||
}
|
||||
if latest == nil || entry.updatedAt.After(latest.updatedAt) {
|
||||
copyEntry := entry
|
||||
latest = ©Entry
|
||||
}
|
||||
}
|
||||
|
||||
if latest == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make(map[string]any, len(latest.payload))
|
||||
for k, v := range latest.payload {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func trainingAnalysisValueTruthy(value any) bool {
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
return strings.EqualFold(strings.TrimSpace(v), "true") || strings.TrimSpace(v) == "1"
|
||||
case int:
|
||||
return v != 0
|
||||
case int64:
|
||||
return v != 0
|
||||
case float64:
|
||||
return v != 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func trainingPublishAnalysisPayload(payload map[string]any) {
|
||||
trainingRememberAnalysisPayload(payload)
|
||||
|
||||
trainingPublishAnalysisPayload(payload)
|
||||
}
|
||||
|
||||
func trainingPublishAnalysisStep(
|
||||
requestID string,
|
||||
startedAtMs int64,
|
||||
@ -871,10 +975,7 @@ func trainingPublishAnalysisStartedWithPreview(
|
||||
payload["previewUrl"] = strings.TrimSpace(previewURL)
|
||||
}
|
||||
|
||||
b, err := json.Marshal(payload)
|
||||
if err == nil {
|
||||
publishSSE("analysisProgress", b)
|
||||
}
|
||||
trainingPublishAnalysisPayload(payload)
|
||||
|
||||
return startedAtMs
|
||||
}
|
||||
@ -892,7 +993,7 @@ func trainingPublishAnalysisFinished(
|
||||
durationMs = 0
|
||||
}
|
||||
|
||||
b, err := json.Marshal(map[string]any{
|
||||
payload := map[string]any{
|
||||
"type": "analysis_progress",
|
||||
"scope": "training",
|
||||
"requestId": requestID,
|
||||
@ -907,12 +1008,9 @@ func trainingPublishAnalysisFinished(
|
||||
"sourceFile": strings.TrimSpace(sourceFile),
|
||||
"message": strings.TrimSpace(message),
|
||||
"ts": time.Now().UnixMilli(),
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
publishSSE("analysisProgress", b)
|
||||
trainingPublishAnalysisPayload(payload)
|
||||
}
|
||||
|
||||
func trainingPublishAnalysisError(
|
||||
@ -933,7 +1031,7 @@ func trainingPublishAnalysisError(
|
||||
errText = err.Error()
|
||||
}
|
||||
|
||||
b, marshalErr := json.Marshal(map[string]any{
|
||||
payload := map[string]any{
|
||||
"type": "analysis_progress",
|
||||
"scope": "training",
|
||||
"requestId": requestID,
|
||||
@ -947,12 +1045,9 @@ func trainingPublishAnalysisError(
|
||||
"message": strings.TrimSpace(message),
|
||||
"error": errText,
|
||||
"ts": time.Now().UnixMilli(),
|
||||
})
|
||||
if marshalErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
publishSSE("analysisProgress", b)
|
||||
trainingPublishAnalysisPayload(payload)
|
||||
}
|
||||
|
||||
func trainingRunCommandStreaming(
|
||||
@ -963,9 +1058,13 @@ func trainingRunCommandStreaming(
|
||||
args ...string,
|
||||
) (string, error) {
|
||||
cmdArgs := append([]string{script}, args...)
|
||||
cmd := exec.CommandContext(ctx, python, cmdArgs...)
|
||||
cmd := exec.Command(python, cmdArgs...)
|
||||
|
||||
hideCommandWindow(cmd)
|
||||
opts := trainingRuntimeOptionsFromSettings()
|
||||
cmd.Env = trainingCommandEnv(opts)
|
||||
prepareTrainingCommandForCancel(cmd)
|
||||
applyTrainingLowPriorityBeforeStart(cmd, opts.LowPriority)
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
@ -977,9 +1076,14 @@ func trainingRunCommandStreaming(
|
||||
return "", err
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return "", errTrainingCancelled
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
applyTrainingLowPriorityAfterStart(cmd, opts.LowPriority)
|
||||
|
||||
var outMu sync.Mutex
|
||||
var lines []string
|
||||
@ -1022,7 +1126,17 @@ func trainingRunCommandStreaming(
|
||||
readPipe(bufio.NewScanner(stderr))
|
||||
}()
|
||||
|
||||
err = cmd.Wait()
|
||||
waitCh := make(chan error, 1)
|
||||
go func() {
|
||||
waitCh <- cmd.Wait()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err = <-waitCh:
|
||||
case <-ctx.Done():
|
||||
terminateTrainingCommand(cmd)
|
||||
err = <-waitCh
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
outMu.Lock()
|
||||
@ -1054,6 +1168,174 @@ const trainingPositionContextOverrideMargin = 0.16
|
||||
|
||||
var errTrainingCancelled = errors.New("training cancelled")
|
||||
|
||||
const (
|
||||
trainingPerformanceAuto = "auto"
|
||||
trainingPerformanceEco = "eco"
|
||||
trainingPerformanceBalanced = "balanced"
|
||||
trainingPerformancePerformance = "performance"
|
||||
trainingPerformanceCustom = "custom"
|
||||
)
|
||||
|
||||
type trainingRuntimeOptions struct {
|
||||
PerformanceMode string
|
||||
PowerSaveMode bool
|
||||
CPUCoreCount int
|
||||
CPUThreads int
|
||||
Workers int
|
||||
YoloBatchSize int
|
||||
LowPriority bool
|
||||
VideoMAEEnabled bool
|
||||
}
|
||||
|
||||
func normalizeTrainingPerformanceMode(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "", "auto", "automatic", "automatisch":
|
||||
return trainingPerformanceAuto
|
||||
case "eco", "schonend", "schonmodus", "powersave", "power-save", "power_save":
|
||||
return trainingPerformanceEco
|
||||
case "balanced", "ausgewogen", "normal":
|
||||
return trainingPerformanceBalanced
|
||||
case "performance", "leistung", "fast", "schnell":
|
||||
return trainingPerformancePerformance
|
||||
case "custom", "manual", "manuell":
|
||||
return trainingPerformanceCustom
|
||||
default:
|
||||
return trainingPerformanceAuto
|
||||
}
|
||||
}
|
||||
|
||||
func clampTrainingInt(value int, minValue int, maxValue int) int {
|
||||
if value < minValue {
|
||||
return minValue
|
||||
}
|
||||
if value > maxValue {
|
||||
return maxValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func trainingPresetValues(mode string, cpuCores int) (threads int, workers int, yoloBatch int, lowPriority bool) {
|
||||
cpuCores = clampTrainingInt(cpuCores, 1, 256)
|
||||
|
||||
switch normalizeTrainingPerformanceMode(mode) {
|
||||
case trainingPerformanceEco:
|
||||
threads = 1
|
||||
if cpuCores >= 4 {
|
||||
threads = 2
|
||||
}
|
||||
workers = 0
|
||||
yoloBatch = 1
|
||||
if cpuCores >= 6 {
|
||||
yoloBatch = 2
|
||||
}
|
||||
lowPriority = true
|
||||
|
||||
case trainingPerformancePerformance:
|
||||
threads = clampTrainingInt(cpuCores-1, 2, 16)
|
||||
workers = clampTrainingInt(threads/2, 2, 4)
|
||||
yoloBatch = 4
|
||||
if cpuCores >= 8 {
|
||||
yoloBatch = 8
|
||||
}
|
||||
if cpuCores >= 16 {
|
||||
yoloBatch = 12
|
||||
}
|
||||
lowPriority = false
|
||||
|
||||
default:
|
||||
threads = clampTrainingInt(cpuCores/2, 2, 8)
|
||||
workers = clampTrainingInt(threads/2, 1, 2)
|
||||
yoloBatch = 2
|
||||
if cpuCores >= 8 {
|
||||
yoloBatch = 4
|
||||
}
|
||||
if cpuCores >= 16 {
|
||||
yoloBatch = 6
|
||||
}
|
||||
lowPriority = false
|
||||
}
|
||||
|
||||
return threads, workers, yoloBatch, lowPriority
|
||||
}
|
||||
|
||||
func trainingRuntimeOptionsFromSettings() trainingRuntimeOptions {
|
||||
s := getSettings()
|
||||
return trainingRuntimeOptionsFromRecorderSettings(s)
|
||||
}
|
||||
|
||||
func trainingRuntimeOptionsFromRecorderSettings(s RecorderSettings) trainingRuntimeOptions {
|
||||
normalizeTrainingSettings(&s)
|
||||
cpuCores := runtime.NumCPU()
|
||||
if cpuCores < 1 {
|
||||
cpuCores = 1
|
||||
}
|
||||
|
||||
mode := normalizeTrainingPerformanceMode(s.TrainingPerformanceMode)
|
||||
if mode == trainingPerformanceAuto {
|
||||
if cpuCores <= 4 {
|
||||
mode = trainingPerformanceEco
|
||||
} else if cpuCores >= 12 {
|
||||
mode = trainingPerformancePerformance
|
||||
} else {
|
||||
mode = trainingPerformanceBalanced
|
||||
}
|
||||
}
|
||||
|
||||
presetThreads, presetWorkers, presetBatch, presetLowPriority := trainingPresetValues(mode, cpuCores)
|
||||
|
||||
opts := trainingRuntimeOptions{
|
||||
PerformanceMode: mode,
|
||||
PowerSaveMode: mode == trainingPerformanceEco,
|
||||
CPUCoreCount: cpuCores,
|
||||
CPUThreads: presetThreads,
|
||||
Workers: presetWorkers,
|
||||
YoloBatchSize: presetBatch,
|
||||
LowPriority: presetLowPriority,
|
||||
VideoMAEEnabled: s.TrainingVideoMAEEnabled,
|
||||
}
|
||||
|
||||
if mode == trainingPerformanceCustom {
|
||||
opts.PowerSaveMode = s.TrainingPowerSaveMode
|
||||
if s.TrainingCPUThreads > 0 {
|
||||
opts.CPUThreads = clampTrainingInt(s.TrainingCPUThreads, 1, cpuCores)
|
||||
} else {
|
||||
opts.CPUThreads = 0
|
||||
}
|
||||
opts.Workers = clampTrainingInt(s.TrainingWorkers, 0, 16)
|
||||
opts.YoloBatchSize = clampTrainingInt(s.TrainingYoloBatchSize, 0, 64)
|
||||
opts.LowPriority = s.TrainingLowPriority
|
||||
} else {
|
||||
if opts.CPUThreads > cpuCores {
|
||||
opts.CPUThreads = cpuCores
|
||||
}
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
func trainingCommandEnv(opts trainingRuntimeOptions) []string {
|
||||
env := os.Environ()
|
||||
if opts.CPUThreads <= 0 {
|
||||
return env
|
||||
}
|
||||
|
||||
threads := strconv.Itoa(opts.CPUThreads)
|
||||
limitVars := []string{
|
||||
"OMP_NUM_THREADS",
|
||||
"MKL_NUM_THREADS",
|
||||
"OPENBLAS_NUM_THREADS",
|
||||
"NUMEXPR_NUM_THREADS",
|
||||
"VECLIB_MAXIMUM_THREADS",
|
||||
"TORCH_NUM_THREADS",
|
||||
}
|
||||
|
||||
for _, name := range limitVars {
|
||||
env = append(env, name+"="+threads)
|
||||
}
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
var trainingJob = struct {
|
||||
mu sync.Mutex
|
||||
status TrainingJobStatus
|
||||
@ -1241,8 +1523,46 @@ type TrainingImportVideoResponse struct {
|
||||
Samples []TrainingSample `json:"samples,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Analysis map[string]any `json:"analysis,omitempty"`
|
||||
}
|
||||
|
||||
type TrainingNextRequest struct {
|
||||
ForceNew bool
|
||||
RefreshPrediction bool
|
||||
PreferUncertain bool
|
||||
AnalysisRequestID string
|
||||
ExcludeIDs map[string]bool
|
||||
}
|
||||
|
||||
type TrainingNextResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Accepted bool `json:"accepted,omitempty"`
|
||||
Running bool `json:"running,omitempty"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
Sample *TrainingSample `json:"sample,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Analysis map[string]any `json:"analysis,omitempty"`
|
||||
}
|
||||
|
||||
type trainingNextJobState struct {
|
||||
requestID string
|
||||
startedAt time.Time
|
||||
finishedAt time.Time
|
||||
running bool
|
||||
statusCode int
|
||||
response *TrainingNextResponse
|
||||
errorText string
|
||||
}
|
||||
|
||||
var trainingNextJobs = struct {
|
||||
sync.Mutex
|
||||
items map[string]*trainingNextJobState
|
||||
}{
|
||||
items: map[string]*trainingNextJobState{},
|
||||
}
|
||||
|
||||
const trainingNextJobTTL = 2 * time.Hour
|
||||
|
||||
type trainingImportVideoJobState struct {
|
||||
requestID string
|
||||
startedAt time.Time
|
||||
@ -1655,7 +1975,7 @@ func trainingRunImportVideoRequest(req TrainingImportVideoRequest) (TrainingImpo
|
||||
sample := &TrainingSample{
|
||||
SampleID: id,
|
||||
FrameURL: "/api/training/frame?id=" + id,
|
||||
SourceFile: sourceFile,
|
||||
SourceFile: sourceFileWithFrame(i),
|
||||
SourcePath: outPath,
|
||||
SourceSizeBytes: sourceSizeBytes,
|
||||
Second: second,
|
||||
@ -1789,6 +2109,7 @@ func trainingImportVideoJobResponse(requestID string) (TrainingImportVideoRespon
|
||||
Accepted: true,
|
||||
Running: true,
|
||||
RequestID: requestID,
|
||||
Analysis: trainingAnalysisStatusPayload(requestID),
|
||||
}, http.StatusAccepted
|
||||
}
|
||||
|
||||
@ -1796,6 +2117,7 @@ func trainingImportVideoJobResponse(requestID string) (TrainingImportVideoRespon
|
||||
resp := *job.response
|
||||
resp.Running = false
|
||||
resp.RequestID = requestID
|
||||
resp.Analysis = trainingAnalysisStatusPayload(requestID)
|
||||
if resp.Error == "" {
|
||||
resp.Error = job.errorText
|
||||
}
|
||||
@ -1824,6 +2146,32 @@ func trainingImportVideoStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
trainingWriteJSON(w, statusCode, resp)
|
||||
}
|
||||
|
||||
func trainingAnalysisStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := strings.TrimSpace(r.URL.Query().Get("requestId"))
|
||||
if requestID == "" {
|
||||
requestID = strings.TrimSpace(r.URL.Query().Get("id"))
|
||||
}
|
||||
|
||||
payload := trainingAnalysisStatusPayload(requestID)
|
||||
if payload == nil {
|
||||
trainingWriteJSON(w, http.StatusNotFound, map[string]any{
|
||||
"ok": false,
|
||||
"error": "Analyse-Status nicht gefunden.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
trainingWriteJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"analysis": payload,
|
||||
})
|
||||
}
|
||||
|
||||
func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
@ -1898,6 +2246,9 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
seconds := trainingFrameSecondsForVideo(duration, req.Count)
|
||||
sourceFile := filepath.Base(outPath)
|
||||
previewURL := ""
|
||||
sourceFileWithFrame := func(index int) string {
|
||||
return fmt.Sprintf("%s (%d / %d)", sourceFile, index+1, len(seconds))
|
||||
}
|
||||
|
||||
requestID := strings.TrimSpace(req.AnalysisRequestID)
|
||||
if requestID == "" {
|
||||
@ -1933,7 +2284,7 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
startedAtMs,
|
||||
stepBase+1,
|
||||
totalSteps,
|
||||
sourceFile,
|
||||
sourceFileWithFrame(i),
|
||||
previewURL,
|
||||
fmt.Sprintf("Frame %d/%d wird extrahiert…", i+1, len(seconds)),
|
||||
)
|
||||
@ -1954,7 +2305,7 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
startedAtMs,
|
||||
stepBase+2,
|
||||
totalSteps,
|
||||
sourceFile,
|
||||
sourceFileWithFrame(i),
|
||||
previewURL,
|
||||
fmt.Sprintf("Frame %d/%d wird analysiert…", i+1, len(seconds)),
|
||||
)
|
||||
@ -1964,7 +2315,7 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
sample := &TrainingSample{
|
||||
SampleID: id,
|
||||
FrameURL: "/api/training/frame?id=" + id,
|
||||
SourceFile: sourceFile,
|
||||
SourceFile: sourceFileWithFrame(i),
|
||||
SourcePath: outPath,
|
||||
SourceSizeBytes: sourceSizeBytes,
|
||||
Second: second,
|
||||
@ -1977,7 +2328,7 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
startedAtMs,
|
||||
stepBase+3,
|
||||
totalSteps,
|
||||
sourceFile,
|
||||
sourceFileWithFrame(i),
|
||||
previewURL,
|
||||
fmt.Sprintf("Frame %d/%d wird gespeichert…", i+1, len(seconds)),
|
||||
)
|
||||
@ -2034,6 +2385,38 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
req := trainingNextRequestFromHTTP(r)
|
||||
|
||||
if strings.TrimSpace(req.AnalysisRequestID) == "" {
|
||||
req.AnalysisRequestID = trainingMakeSampleID("training-next", float64(time.Now().UnixNano()))
|
||||
}
|
||||
|
||||
async := r.URL.Query().Get("async") == "1" ||
|
||||
strings.EqualFold(r.URL.Query().Get("async"), "true")
|
||||
|
||||
if async {
|
||||
trainingStartNextJob(req)
|
||||
resp, statusCode := trainingNextJobResponse(req.AnalysisRequestID)
|
||||
trainingWriteJSON(w, statusCode, resp)
|
||||
return
|
||||
}
|
||||
|
||||
resp, statusCode, errorText := trainingRunNextRequest(req)
|
||||
if statusCode >= 400 || !resp.OK || resp.Sample == nil {
|
||||
if errorText == "" {
|
||||
errorText = resp.Error
|
||||
}
|
||||
if errorText == "" {
|
||||
errorText = "Trainingsbild konnte nicht geladen werden."
|
||||
}
|
||||
trainingWriteError(w, statusCode, errorText)
|
||||
return
|
||||
}
|
||||
|
||||
trainingWriteJSON(w, http.StatusOK, resp.Sample)
|
||||
}
|
||||
|
||||
func trainingNextRequestFromHTTP(r *http.Request) TrainingNextRequest {
|
||||
forceNew := r.URL.Query().Get("force") == "1" ||
|
||||
strings.EqualFold(r.URL.Query().Get("force"), "true")
|
||||
|
||||
@ -2044,15 +2427,42 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) {
|
||||
preferUncertain := strings.EqualFold(r.URL.Query().Get("mode"), "uncertain") ||
|
||||
strings.EqualFold(r.URL.Query().Get("sampleMode"), "uncertain")
|
||||
|
||||
root, err := trainingRootDir()
|
||||
if err != nil {
|
||||
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
refreshPrediction := r.URL.Query().Get("refresh") == "1" ||
|
||||
strings.EqualFold(r.URL.Query().Get("refresh"), "true")
|
||||
|
||||
return TrainingNextRequest{
|
||||
ForceNew: forceNew,
|
||||
RefreshPrediction: refreshPrediction,
|
||||
PreferUncertain: preferUncertain,
|
||||
AnalysisRequestID: analysisRequestID,
|
||||
ExcludeIDs: excludeIDs,
|
||||
}
|
||||
}
|
||||
|
||||
func trainingRunNextRequest(req TrainingNextRequest) (TrainingNextResponse, int, string) {
|
||||
forceNew := req.ForceNew
|
||||
refreshPrediction := req.RefreshPrediction
|
||||
preferUncertain := req.PreferUncertain
|
||||
analysisRequestID := strings.TrimSpace(req.AnalysisRequestID)
|
||||
if analysisRequestID == "" {
|
||||
analysisRequestID = trainingMakeSampleID("training-next", float64(time.Now().UnixNano()))
|
||||
}
|
||||
|
||||
excludeIDs := req.ExcludeIDs
|
||||
if excludeIDs == nil {
|
||||
excludeIDs = map[string]bool{}
|
||||
}
|
||||
|
||||
root, err := trainingRootDir()
|
||||
if err != nil {
|
||||
return TrainingNextResponse{
|
||||
OK: false,
|
||||
RequestID: analysisRequestID,
|
||||
Error: err.Error(),
|
||||
Analysis: trainingAnalysisStatusPayload(analysisRequestID),
|
||||
}, http.StatusInternalServerError, err.Error()
|
||||
}
|
||||
|
||||
if !forceNew && !preferUncertain {
|
||||
var startedAtMs int64
|
||||
|
||||
@ -2079,8 +2489,12 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) {
|
||||
)
|
||||
}
|
||||
|
||||
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
return TrainingNextResponse{
|
||||
OK: false,
|
||||
RequestID: analysisRequestID,
|
||||
Error: err.Error(),
|
||||
Analysis: trainingAnalysisStatusPayload(analysisRequestID),
|
||||
}, http.StatusInternalServerError, err.Error()
|
||||
} else if ok {
|
||||
if refreshPrediction {
|
||||
trainingPublishAnalysisFinished(
|
||||
@ -2092,20 +2506,23 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) {
|
||||
)
|
||||
}
|
||||
|
||||
trainingWriteJSON(w, http.StatusOK, sample)
|
||||
return
|
||||
return TrainingNextResponse{
|
||||
OK: true,
|
||||
RequestID: analysisRequestID,
|
||||
Sample: sample,
|
||||
Analysis: trainingAnalysisStatusPayload(analysisRequestID),
|
||||
}, http.StatusOK, ""
|
||||
}
|
||||
}
|
||||
|
||||
totalSteps := 4
|
||||
if preferUncertain {
|
||||
totalSteps = trainingUncertainCandidateCount*4 + 1
|
||||
}
|
||||
|
||||
startedAtMs := trainingPublishAnalysisStarted(
|
||||
analysisRequestID,
|
||||
func() int {
|
||||
if preferUncertain {
|
||||
return trainingUncertainCandidateCount*4 + 1
|
||||
}
|
||||
|
||||
return 4
|
||||
}(),
|
||||
totalSteps,
|
||||
"",
|
||||
func() string {
|
||||
if preferUncertain {
|
||||
@ -2132,19 +2549,147 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) {
|
||||
"Trainingsbild konnte nicht erstellt werden.",
|
||||
err,
|
||||
)
|
||||
trainingWriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
return TrainingNextResponse{
|
||||
OK: false,
|
||||
RequestID: analysisRequestID,
|
||||
Error: err.Error(),
|
||||
Analysis: trainingAnalysisStatusPayload(analysisRequestID),
|
||||
}, http.StatusInternalServerError, err.Error()
|
||||
}
|
||||
|
||||
trainingPublishAnalysisFinished(
|
||||
analysisRequestID,
|
||||
startedAtMs,
|
||||
4,
|
||||
totalSteps,
|
||||
sample.SourceFile,
|
||||
"Analyse abgeschlossen.",
|
||||
)
|
||||
|
||||
trainingWriteJSON(w, http.StatusOK, sample)
|
||||
return TrainingNextResponse{
|
||||
OK: true,
|
||||
RequestID: analysisRequestID,
|
||||
Sample: sample,
|
||||
Analysis: trainingAnalysisStatusPayload(analysisRequestID),
|
||||
}, http.StatusOK, ""
|
||||
}
|
||||
|
||||
func trainingPruneNextJobsLocked(now time.Time) {
|
||||
for requestID, job := range trainingNextJobs.items {
|
||||
if job == nil || job.running {
|
||||
continue
|
||||
}
|
||||
if !job.finishedAt.IsZero() && now.Sub(job.finishedAt) > trainingNextJobTTL {
|
||||
delete(trainingNextJobs.items, requestID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func trainingStartNextJob(req TrainingNextRequest) {
|
||||
requestID := strings.TrimSpace(req.AnalysisRequestID)
|
||||
if requestID == "" {
|
||||
requestID = trainingMakeSampleID("training-next", float64(time.Now().UnixNano()))
|
||||
req.AnalysisRequestID = requestID
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
trainingNextJobs.Lock()
|
||||
trainingPruneNextJobsLocked(now)
|
||||
if _, exists := trainingNextJobs.items[requestID]; exists {
|
||||
trainingNextJobs.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
trainingNextJobs.items[requestID] = &trainingNextJobState{
|
||||
requestID: requestID,
|
||||
startedAt: now,
|
||||
running: true,
|
||||
statusCode: http.StatusAccepted,
|
||||
}
|
||||
trainingNextJobs.Unlock()
|
||||
|
||||
go func() {
|
||||
resp, statusCode, errorText := trainingRunNextRequest(req)
|
||||
resp.RequestID = requestID
|
||||
resp.Analysis = trainingAnalysisStatusPayload(requestID)
|
||||
|
||||
trainingNextJobs.Lock()
|
||||
if job := trainingNextJobs.items[requestID]; job != nil {
|
||||
job.running = false
|
||||
job.finishedAt = time.Now().UTC()
|
||||
job.statusCode = statusCode
|
||||
job.response = &resp
|
||||
job.errorText = strings.TrimSpace(errorText)
|
||||
}
|
||||
trainingNextJobs.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
func trainingNextJobResponse(requestID string) (TrainingNextResponse, int) {
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if requestID == "" {
|
||||
return TrainingNextResponse{OK: false, Error: "requestId missing"}, http.StatusBadRequest
|
||||
}
|
||||
|
||||
trainingNextJobs.Lock()
|
||||
job := trainingNextJobs.items[requestID]
|
||||
trainingNextJobs.Unlock()
|
||||
|
||||
if job == nil {
|
||||
return TrainingNextResponse{
|
||||
OK: false,
|
||||
RequestID: requestID,
|
||||
Error: "Analyse-Job nicht gefunden.",
|
||||
Analysis: trainingAnalysisStatusPayload(requestID),
|
||||
}, http.StatusNotFound
|
||||
}
|
||||
|
||||
if job.running {
|
||||
return TrainingNextResponse{
|
||||
OK: true,
|
||||
Accepted: true,
|
||||
Running: true,
|
||||
RequestID: requestID,
|
||||
Analysis: trainingAnalysisStatusPayload(requestID),
|
||||
}, http.StatusAccepted
|
||||
}
|
||||
|
||||
if job.response != nil {
|
||||
resp := *job.response
|
||||
resp.Running = false
|
||||
resp.RequestID = requestID
|
||||
resp.Analysis = trainingAnalysisStatusPayload(requestID)
|
||||
if resp.Error == "" {
|
||||
resp.Error = job.errorText
|
||||
}
|
||||
statusCode := job.statusCode
|
||||
if statusCode < 100 {
|
||||
statusCode = http.StatusOK
|
||||
}
|
||||
return resp, statusCode
|
||||
}
|
||||
|
||||
return TrainingNextResponse{
|
||||
OK: false,
|
||||
RequestID: requestID,
|
||||
Error: job.errorText,
|
||||
Analysis: trainingAnalysisStatusPayload(requestID),
|
||||
}, http.StatusInternalServerError
|
||||
}
|
||||
|
||||
func trainingNextStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := strings.TrimSpace(r.URL.Query().Get("requestId"))
|
||||
if requestID == "" {
|
||||
requestID = strings.TrimSpace(r.URL.Query().Get("id"))
|
||||
}
|
||||
|
||||
resp, statusCode := trainingNextJobResponse(requestID)
|
||||
trainingWriteJSON(w, statusCode, resp)
|
||||
}
|
||||
|
||||
func trainingLatestOpenSample(
|
||||
@ -2912,6 +3457,18 @@ func trainingRunJob(ctx context.Context, root string, count int) {
|
||||
|
||||
python := trainingPythonExe()
|
||||
appLogln("ML-Python für Training:", python)
|
||||
runtimeOpts := trainingRuntimeOptionsFromSettings()
|
||||
appLogf(
|
||||
"Training-Laufzeit: mode=%s cpuCores=%d schonmodus=%v threads=%d workers=%d yoloBatch=%d lowPriority=%v videoMAE=%v",
|
||||
runtimeOpts.PerformanceMode,
|
||||
runtimeOpts.CPUCoreCount,
|
||||
runtimeOpts.PowerSaveMode,
|
||||
runtimeOpts.CPUThreads,
|
||||
runtimeOpts.Workers,
|
||||
runtimeOpts.YoloBatchSize,
|
||||
runtimeOpts.LowPriority,
|
||||
runtimeOpts.VideoMAEEnabled,
|
||||
)
|
||||
|
||||
cleanOutput := func(text string) string {
|
||||
out := strings.TrimSpace(text)
|
||||
@ -2967,7 +3524,24 @@ func trainingRunJob(ctx context.Context, root string, count int) {
|
||||
s.Step = "YOLO26 Detector wird trainiert…"
|
||||
})
|
||||
|
||||
detectorBasePath := "yolo26n.pt"
|
||||
if p, err := trainingMLCacheFilePath("yolo26n.pt"); err == nil {
|
||||
detectorBasePath = p
|
||||
}
|
||||
|
||||
detectorScript := trainingScriptPath("train_detector_model.py")
|
||||
detectorArgs := []string{
|
||||
"--root", root,
|
||||
"--base", detectorBasePath,
|
||||
"--epochs", strconv.Itoa(trainingDetectorEpochs()),
|
||||
"--imgsz", "640",
|
||||
"--workers", strconv.Itoa(runtimeOpts.Workers),
|
||||
"--threads", strconv.Itoa(runtimeOpts.CPUThreads),
|
||||
}
|
||||
if runtimeOpts.YoloBatchSize > 0 {
|
||||
detectorArgs = append(detectorArgs, "--batch", strconv.Itoa(runtimeOpts.YoloBatchSize))
|
||||
}
|
||||
|
||||
detectorOut, detectorErr := trainingRunCommandStreaming(
|
||||
ctx,
|
||||
python,
|
||||
@ -2980,10 +3554,7 @@ func trainingRunJob(ctx context.Context, root string, count int) {
|
||||
"YOLO26 Detector wird trainiert…",
|
||||
)
|
||||
},
|
||||
"--root", root,
|
||||
"--base", "yolo26n.pt",
|
||||
"--epochs", strconv.Itoa(trainingDetectorEpochs()),
|
||||
"--imgsz", "640",
|
||||
detectorArgs...,
|
||||
)
|
||||
|
||||
if errors.Is(detectorErr, errTrainingCancelled) {
|
||||
@ -3077,6 +3648,18 @@ func trainingRunJob(ctx context.Context, root string, count int) {
|
||||
}
|
||||
|
||||
poseScript := trainingScriptPath("train_pose_model.py")
|
||||
poseArgs := []string{
|
||||
"--root", root,
|
||||
"--base", poseBasePath,
|
||||
"--epochs", strconv.Itoa(trainingDetectorEpochs()),
|
||||
"--imgsz", "640",
|
||||
"--workers", strconv.Itoa(runtimeOpts.Workers),
|
||||
"--threads", strconv.Itoa(runtimeOpts.CPUThreads),
|
||||
}
|
||||
if runtimeOpts.YoloBatchSize > 0 {
|
||||
poseArgs = append(poseArgs, "--batch", strconv.Itoa(runtimeOpts.YoloBatchSize))
|
||||
}
|
||||
|
||||
poseOut, poseErr := trainingRunCommandStreaming(
|
||||
ctx,
|
||||
python,
|
||||
@ -3089,10 +3672,7 @@ func trainingRunJob(ctx context.Context, root string, count int) {
|
||||
"YOLO26 Pose wird trainiert...",
|
||||
)
|
||||
},
|
||||
"--root", root,
|
||||
"--base", poseBasePath,
|
||||
"--epochs", strconv.Itoa(trainingDetectorEpochs()),
|
||||
"--imgsz", "640",
|
||||
poseArgs...,
|
||||
)
|
||||
|
||||
if errors.Is(poseErr, errTrainingCancelled) {
|
||||
@ -3132,6 +3712,17 @@ func trainingRunJob(ctx context.Context, root string, count int) {
|
||||
|
||||
poseOutputClean := cleanOutput(poseOutput)
|
||||
|
||||
if !runtimeOpts.VideoMAEEnabled {
|
||||
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
||||
if s.Progress < 84 {
|
||||
s.Progress = 84
|
||||
}
|
||||
s.Step = "VideoMAE wurde in den Settings übersprungen."
|
||||
})
|
||||
videoMAEStatus = "skipped_disabled"
|
||||
videoMAEOutput = "VideoMAE übersprungen: in den Training-Settings deaktiviert."
|
||||
appLogln(videoMAEOutput)
|
||||
} else {
|
||||
trainingSetJobStatus(func(s *TrainingJobStatus) {
|
||||
if s.Progress < 84 {
|
||||
s.Progress = 84
|
||||
@ -3175,6 +3766,8 @@ func trainingRunJob(ctx context.Context, root string, count int) {
|
||||
"--epochs", strconv.Itoa(trainingVideoMAEEpochs()),
|
||||
"--batch-size", strconv.Itoa(trainingVideoMAEBatchSize()),
|
||||
"--num-frames", strconv.Itoa(trainingVideoMAENumFrames),
|
||||
"--workers", strconv.Itoa(runtimeOpts.Workers),
|
||||
"--threads", strconv.Itoa(runtimeOpts.CPUThreads),
|
||||
}
|
||||
if base := strings.TrimSpace(os.Getenv("VIDEOMAE_BASE_MODEL")); base != "" {
|
||||
videoMAEArgs = append(videoMAEArgs, "--base", base)
|
||||
@ -3227,6 +3820,7 @@ func trainingRunJob(ctx context.Context, root string, count int) {
|
||||
)
|
||||
appLogln(videoMAEOutput)
|
||||
}
|
||||
}
|
||||
|
||||
videoMAEOutputClean := cleanOutput(videoMAEOutput)
|
||||
|
||||
@ -3281,6 +3875,10 @@ func trainingRunJob(ctx context.Context, root string, count int) {
|
||||
if detectorStatus == "trained" || poseStatus == "trained" {
|
||||
message += " VideoMAE wurde übersprungen: zu wenige Clip-Beispiele."
|
||||
}
|
||||
} else if videoMAEStatus == "skipped_disabled" {
|
||||
if detectorStatus == "trained" || poseStatus == "trained" {
|
||||
message += " VideoMAE wurde übersprungen: in den Settings deaktiviert."
|
||||
}
|
||||
} else if videoMAEStatus == "failed" {
|
||||
message += " VideoMAE ist fehlgeschlagen."
|
||||
if videoMAEOutputClean != "" {
|
||||
|
||||
46
backend/training_priority_other.go
Normal file
46
backend/training_priority_other.go
Normal 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()
|
||||
}
|
||||
54
backend/training_priority_windows.go
Normal file
54
backend/training_priority_windows.go
Normal 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()
|
||||
}
|
||||
@ -5,7 +5,7 @@ import './App.css'
|
||||
import Button from './components/ui/Button'
|
||||
import CookieModal from './components/ui/CookieModal'
|
||||
import Tabs, { type TabItem } from './components/ui/Tabs'
|
||||
import RecorderSettings from './components/ui/RecorderSettings'
|
||||
import Settings from './components/ui/Settings'
|
||||
import FinishedDownloads from './components/ui/FinishedDownloads'
|
||||
import Player from './components/ui/Player'
|
||||
import type { RecordJob, JobEvent, RecorderSettingsState, TaskStateEvent, AnalysisProgressEvent, BackgroundSplitProgress } from './types'
|
||||
@ -84,11 +84,16 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
|
||||
generateAssetsTeaser: true,
|
||||
generateAssetsSprites: true,
|
||||
generateAssetsAnalyze: true,
|
||||
|
||||
enrichPostworkEnabled: true,
|
||||
|
||||
trainingRecognitionEnabled: true,
|
||||
trainingDetectorEpochs: 60,
|
||||
trainingPerformanceMode: 'auto',
|
||||
trainingPowerSaveMode: false,
|
||||
trainingCpuThreads: 0,
|
||||
trainingWorkers: 2,
|
||||
trainingYoloBatchSize: 0,
|
||||
trainingLowPriority: false,
|
||||
trainingVideoMAEEnabled: true,
|
||||
}
|
||||
|
||||
type StoredModel = {
|
||||
@ -4498,7 +4503,7 @@ export default function App() {
|
||||
{selectedTab === 'categories' ? <CategoriesTab /> : null}
|
||||
|
||||
{selectedTab === 'settings' ? (
|
||||
<RecorderSettings
|
||||
<Settings
|
||||
onAssetsGenerated={bumpAssets}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@ -2314,6 +2314,7 @@ export default function FinishedDownloads({
|
||||
const [deletingKeys, setDeletingKeys] = useState<Set<string>>(() => new Set())
|
||||
const [deletedSuccessKeys, setDeletedSuccessKeys] = 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 [hiddenFiles, setHiddenFiles] = useState<Set<string>>(() => new Set())
|
||||
const [restoredJobsByKey, setRestoredJobsByKey] = useState<Record<string, RecordJob>>({})
|
||||
@ -3156,6 +3157,7 @@ export default function FinishedDownloads({
|
||||
deletingKeys.size > 0 ||
|
||||
deletedSuccessKeys.size > 0 ||
|
||||
keepingKeys.size > 0 ||
|
||||
keptSuccessKeys.size > 0 ||
|
||||
removingKeys.size > 0
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@ -3461,6 +3463,15 @@ export default function FinishedDownloads({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const markKeptSuccess = useCallback((key: string, value: boolean) => {
|
||||
setKeptSuccessKeys((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (value) next.add(key)
|
||||
else next.delete(key)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const markDeleted = useCallback((key: string) => {
|
||||
setDeletedKeys((prev) => {
|
||||
const next = new Set(prev)
|
||||
@ -3612,6 +3623,9 @@ export default function FinishedDownloads({
|
||||
})
|
||||
|
||||
markDeletedSuccess(key, false)
|
||||
markKeptSuccess(key, false)
|
||||
markDeleting(key, false)
|
||||
markKeeping(key, false)
|
||||
markDeleted(key)
|
||||
markRemoving(key, false)
|
||||
|
||||
@ -3619,7 +3633,16 @@ export default function FinishedDownloads({
|
||||
refreshHoverPreviewFromPointer()
|
||||
})
|
||||
},
|
||||
[cancelRemoveTimer, markDeletedSuccess, markDeleted, markRemoving, refreshHoverPreviewFromPointer]
|
||||
[
|
||||
cancelRemoveTimer,
|
||||
markDeletedSuccess,
|
||||
markKeptSuccess,
|
||||
markDeleting,
|
||||
markKeeping,
|
||||
markDeleted,
|
||||
markRemoving,
|
||||
refreshHoverPreviewFromPointer,
|
||||
]
|
||||
)
|
||||
|
||||
const restoreRow = useCallback(
|
||||
@ -3647,6 +3670,7 @@ export default function FinishedDownloads({
|
||||
setDeletingKeys(removeKeyAliases)
|
||||
setDeletedSuccessKeys(removeKeyAliases)
|
||||
setKeepingKeys(removeKeyAliases)
|
||||
setKeptSuccessKeys(removeKeyAliases)
|
||||
|
||||
if (fileAliases.size > 0) {
|
||||
setHiddenFiles((prev) => {
|
||||
@ -3681,6 +3705,78 @@ export default function FinishedDownloads({
|
||||
[cancelRemoveTimer, markDeleting, markRemoving, markDeletedSuccess, removeRowNow]
|
||||
)
|
||||
|
||||
const markDeletedRowSuccess = useCallback(
|
||||
(key: string) => {
|
||||
cancelRemoveTimer(key)
|
||||
markDeleting(key, false)
|
||||
markKeeping(key, false)
|
||||
markRemoving(key, false)
|
||||
markKeptSuccess(key, false)
|
||||
markDeletedSuccess(key, true)
|
||||
},
|
||||
[
|
||||
cancelRemoveTimer,
|
||||
markDeleting,
|
||||
markKeeping,
|
||||
markRemoving,
|
||||
markKeptSuccess,
|
||||
markDeletedSuccess,
|
||||
]
|
||||
)
|
||||
|
||||
const markKeptRowSuccess = useCallback(
|
||||
(key: string) => {
|
||||
cancelRemoveTimer(key)
|
||||
markDeleting(key, false)
|
||||
markKeeping(key, false)
|
||||
markRemoving(key, false)
|
||||
markDeletedSuccess(key, false)
|
||||
markKeptSuccess(key, true)
|
||||
},
|
||||
[
|
||||
cancelRemoveTimer,
|
||||
markDeleting,
|
||||
markKeeping,
|
||||
markRemoving,
|
||||
markDeletedSuccess,
|
||||
markKeptSuccess,
|
||||
]
|
||||
)
|
||||
|
||||
const removeRowsTogether = useCallback(
|
||||
(items: Array<{ key: string; file?: string }>, delayMs = 780) => {
|
||||
const uniqueItems = Array.from(
|
||||
items.reduce((map, item) => {
|
||||
if (!item.key) return map
|
||||
if (!map.has(item.key)) map.set(item.key, item)
|
||||
return map
|
||||
}, new Map<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(
|
||||
(key: string, file?: string) => {
|
||||
if (file) {
|
||||
@ -4445,6 +4541,7 @@ export default function FinishedDownloads({
|
||||
const selectedDeleteFiles = selectedDeleteItems.map((item) => item.file)
|
||||
const deletedKeys = new Set<string>()
|
||||
const failedKeys = new Set<string>()
|
||||
const deletedItems: Array<{ file: string; key: string }> = []
|
||||
|
||||
const clearDeleting = (keys: Iterable<string>) => {
|
||||
setDeletingKeys((prev) => {
|
||||
@ -4459,18 +4556,20 @@ export default function FinishedDownloads({
|
||||
const handleResult = (item: any) => {
|
||||
if (item?.done) return
|
||||
|
||||
const file = String(item?.file || '').trim()
|
||||
const file = baseName(String(item?.file || '').trim())
|
||||
if (!file) return
|
||||
|
||||
const selectedItem = selectedDeleteItems.find((selected) => selected.file === file)
|
||||
const key =
|
||||
selectedItem?.key ||
|
||||
fileToKeyRef.current.get(file) ||
|
||||
selectedDeleteItems.find((selected) => selected.file === file)?.key ||
|
||||
file
|
||||
|
||||
if (item?.ok) {
|
||||
if (!deletedKeys.has(key)) {
|
||||
deletedKeys.add(key)
|
||||
completeDeletedRow(key, file)
|
||||
deletedItems.push({ key, file })
|
||||
markDeletedRowSuccess(key)
|
||||
}
|
||||
return
|
||||
}
|
||||
@ -4561,6 +4660,7 @@ export default function FinishedDownloads({
|
||||
emitCountHint(-deletedCount)
|
||||
|
||||
if (deletedCount > 0) {
|
||||
removeRowsTogether(deletedItems)
|
||||
notify.success?.('Löschen erfolgreich', `${deletedCount} Downloads gelöscht.`)
|
||||
}
|
||||
} catch (e: any) {
|
||||
@ -4570,6 +4670,7 @@ export default function FinishedDownloads({
|
||||
if (deletedKeys.size > 0) {
|
||||
clearSelection()
|
||||
emitCountHint(-deletedKeys.size)
|
||||
removeRowsTogether(deletedItems)
|
||||
notify.success?.('L\u00f6schen erfolgreich', `${deletedKeys.size} Downloads gel\u00f6scht.`)
|
||||
}
|
||||
|
||||
@ -4583,23 +4684,80 @@ export default function FinishedDownloads({
|
||||
selectedJobsForBulk,
|
||||
clearSelection,
|
||||
emitCountHint,
|
||||
completeDeletedRow,
|
||||
markDeletedRowSuccess,
|
||||
removeRowsTogether,
|
||||
notify,
|
||||
])
|
||||
|
||||
const bulkKeepSelected = useCallback(async () => {
|
||||
if (bulkBusy) return
|
||||
if (selectedFiles.length === 0) return
|
||||
if (selectedJobsForBulk.length === 0) return
|
||||
|
||||
const ok = window.confirm(`${selectedFiles.length} Downloads behalten?`)
|
||||
if (!ok) return
|
||||
|
||||
const selectedKeepItems = selectedJobsForBulk
|
||||
.map((job) => {
|
||||
const file = baseName(job.output || '')
|
||||
const cleanFile = String(file || '').trim()
|
||||
if (!cleanFile) return null
|
||||
|
||||
return {
|
||||
file: cleanFile,
|
||||
key: keyFor(job),
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as Array<{ file: string; key: string }>
|
||||
|
||||
if (selectedKeepItems.length === 0) return
|
||||
|
||||
const selectedKeepFiles = selectedKeepItems.map((item) => item.file)
|
||||
const selectedKeepKeys = selectedKeepItems.map((item) => item.key)
|
||||
const keptKeys = new Set<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)
|
||||
try {
|
||||
const res = await fetch('/api/record/keep-many', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ files: selectedFiles }),
|
||||
body: JSON.stringify({ files: selectedKeepFiles }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
@ -4611,27 +4769,64 @@ export default function FinishedDownloads({
|
||||
const results = Array.isArray(data?.results) ? data.results : []
|
||||
|
||||
for (const item of results) {
|
||||
if (!item?.ok) continue
|
||||
|
||||
const file = String(item.file || '').trim()
|
||||
const file = baseName(String(item.file || '').trim())
|
||||
if (!file) continue
|
||||
|
||||
const selectedItem = selectedKeepItems.find((selected) => selected.file === file)
|
||||
const key =
|
||||
selectedItem?.key ||
|
||||
fileToKeyRef.current.get(file) ||
|
||||
file
|
||||
|
||||
animateRemove(key, file)
|
||||
if (item?.ok) {
|
||||
handleKeepSuccess(file)
|
||||
continue
|
||||
}
|
||||
|
||||
const keptCount = Number(data?.kept ?? results.filter((x: any) => x?.ok).length ?? 0)
|
||||
failedKeys.add(key)
|
||||
clearKeeping([key])
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
const keptFromResponse = Number(data?.kept ?? 0)
|
||||
const count = Number.isFinite(keptFromResponse)
|
||||
? Math.min(selectedKeepItems.length, Math.max(0, Math.floor(keptFromResponse)))
|
||||
: 0
|
||||
|
||||
for (const item of selectedKeepItems.slice(0, count)) {
|
||||
handleKeepSuccess(item.file)
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of selectedKeepItems) {
|
||||
if (!keptKeys.has(item.key) && !failedKeys.has(item.key)) {
|
||||
failedKeys.add(item.key)
|
||||
}
|
||||
}
|
||||
|
||||
if (failedKeys.size > 0) {
|
||||
clearKeeping(failedKeys)
|
||||
}
|
||||
|
||||
const keptCount = keptKeys.size
|
||||
|
||||
clearSelection()
|
||||
emitCountHint(includeKeep ? 0 : -keptCount)
|
||||
|
||||
if (keptCount > 0) {
|
||||
removeRowsTogether(keptItems)
|
||||
notify.success?.('Keep erfolgreich', `${keptCount} Downloads behalten.`)
|
||||
}
|
||||
} catch (e: any) {
|
||||
clearKeeping(selectedKeepKeys.filter((key) => !keptKeys.has(key)))
|
||||
|
||||
if (keptKeys.size > 0) {
|
||||
clearSelection()
|
||||
emitCountHint(includeKeep ? 0 : -keptKeys.size)
|
||||
removeRowsTogether(keptItems)
|
||||
notify.success?.('Keep erfolgreich', `${keptKeys.size} Downloads behalten.`)
|
||||
}
|
||||
|
||||
notify.error('Bulk-Keep fehlgeschlagen', String(e?.message || e))
|
||||
} finally {
|
||||
setBulkBusy(false)
|
||||
@ -4639,7 +4834,9 @@ export default function FinishedDownloads({
|
||||
}, [
|
||||
bulkBusy,
|
||||
selectedFiles,
|
||||
animateRemove,
|
||||
selectedJobsForBulk,
|
||||
markKeptRowSuccess,
|
||||
removeRowsTogether,
|
||||
clearSelection,
|
||||
emitCountHint,
|
||||
includeKeep,
|
||||
@ -6609,6 +6806,7 @@ export default function FinishedDownloads({
|
||||
inlinePlay={inlinePlay}
|
||||
deletingKeys={deletingKeys}
|
||||
deletedSuccessKeys={deletedSuccessKeys}
|
||||
keptSuccessKeys={keptSuccessKeys}
|
||||
keepingKeys={keepingKeys}
|
||||
removingKeys={removingKeys}
|
||||
swipeRefs={swipeRefs}
|
||||
@ -6691,6 +6889,7 @@ export default function FinishedDownloads({
|
||||
assetNonceForJob={assetNonceForJob}
|
||||
deletingKeys={deletingKeys}
|
||||
deletedSuccessKeys={deletedSuccessKeys}
|
||||
keptSuccessKeys={keptSuccessKeys}
|
||||
keepingKeys={keepingKeys}
|
||||
removingKeys={removingKeys}
|
||||
modelsByKey={modelsByKey}
|
||||
@ -6743,6 +6942,7 @@ export default function FinishedDownloads({
|
||||
formatBytes={formatBytes}
|
||||
deletingKeys={deletingKeys}
|
||||
deletedSuccessKeys={deletedSuccessKeys}
|
||||
keptSuccessKeys={keptSuccessKeys}
|
||||
keepingKeys={keepingKeys}
|
||||
removingKeys={removingKeys}
|
||||
registerTeaserHost={registerTeaserHost}
|
||||
|
||||
@ -77,6 +77,7 @@ type Props = {
|
||||
|
||||
deletingKeys: Set<string>
|
||||
deletedSuccessKeys: Set<string>
|
||||
keptSuccessKeys: Set<string>
|
||||
keepingKeys: Set<string>
|
||||
removingKeys: Set<string>
|
||||
|
||||
@ -339,6 +340,7 @@ function FinishedDownloadsCardsView({
|
||||
inlinePlay,
|
||||
deletingKeys,
|
||||
deletedSuccessKeys,
|
||||
keptSuccessKeys,
|
||||
keepingKeys,
|
||||
removingKeys,
|
||||
|
||||
@ -530,7 +532,9 @@ function FinishedDownloadsCardsView({
|
||||
})
|
||||
|
||||
const deleteDone = deletedSuccessKeys.has(k)
|
||||
const busy = deletingKeys.has(k) || deleteDone || keepingKeys.has(k) || removingKeys.has(k)
|
||||
const keepDone = keptSuccessKeys.has(k)
|
||||
const keepBusy = keepingKeys.has(k)
|
||||
const busy = deletingKeys.has(k) || deleteDone || keepBusy || keepDone || removingKeys.has(k)
|
||||
|
||||
const model = modelNameFromOutput(j.output)
|
||||
const fileRaw = baseName(j.output || '')
|
||||
@ -637,7 +641,7 @@ function FinishedDownloadsCardsView({
|
||||
busy && 'pointer-events-none opacity-70',
|
||||
deletingKeys.has(k) && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
|
||||
deleteDone && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||||
keepingKeys.has(k) && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||||
(keepBusy || keepDone) && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||||
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
|
||||
]
|
||||
.filter(Boolean)
|
||||
@ -890,6 +894,11 @@ function FinishedDownloadsCardsView({
|
||||
|
||||
{deletingKeys.has(k) || deleteDone ? (
|
||||
<DeletingPreviewOverlay done={deleteDone} />
|
||||
) : keepBusy || keepDone ? (
|
||||
<DeletingPreviewOverlay
|
||||
done={keepDone}
|
||||
label={keepDone ? 'Behalten' : 'Wird behalten'}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@ -1220,6 +1229,7 @@ function FinishedDownloadsCardsView({
|
||||
selectionModeActive={selectionModeActive}
|
||||
isDeleting={deletingKeys.has(k)}
|
||||
isDeleted={deletedSuccessKeys.has(k)}
|
||||
isKept={keptSuccessKeys.has(k)}
|
||||
isKeeping={keepingKeys.has(k)}
|
||||
isRemoving={removingKeys.has(k)}
|
||||
postworkBadge={getPostworkSummaryForOutput(j.output, postworkByFile)}
|
||||
|
||||
@ -50,6 +50,7 @@ export type FinishedDownloadsDesktopCardProps = {
|
||||
selectionModeActive: boolean
|
||||
isDeleting: boolean
|
||||
isDeleted: boolean
|
||||
isKept: boolean
|
||||
isKeeping: boolean
|
||||
isRemoving: boolean
|
||||
postworkBadge?: FinishedPostworkSummary
|
||||
@ -143,6 +144,7 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
selectionModeActive,
|
||||
isDeleting,
|
||||
isDeleted,
|
||||
isKept,
|
||||
isKeeping,
|
||||
isRemoving,
|
||||
postworkBadge,
|
||||
@ -186,7 +188,7 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
renderRatingOverlay,
|
||||
}: FinishedDownloadsDesktopCardProps) {
|
||||
const k = keyFor(j)
|
||||
const busy = isDeleting || isDeleted || isKeeping || isRemoving
|
||||
const busy = isDeleting || isDeleted || isKeeping || isKept || isRemoving
|
||||
const checked = useSelectionChecked(selectionStore, k)
|
||||
const longPressTimerRef = useRef<number | null>(null)
|
||||
const longPressTriggeredRef = useRef(false)
|
||||
@ -338,7 +340,7 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
busy && 'pointer-events-none opacity-70',
|
||||
isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
|
||||
isDeleted && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||||
isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||||
(isKeeping || isKept) && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||||
isRemoving && 'opacity-0 translate-y-2 scale-[0.98]',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={(e) => {
|
||||
@ -494,6 +496,11 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
|
||||
{isDeleting || isDeleted ? (
|
||||
<DeletingPreviewOverlay done={isDeleted} />
|
||||
) : isKeeping || isKept ? (
|
||||
<DeletingPreviewOverlay
|
||||
done={isKept}
|
||||
label={isKept ? 'Behalten' : 'Wird behalten'}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@ -615,6 +622,7 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
prev.job === next.job &&
|
||||
prev.isDeleting === next.isDeleting &&
|
||||
prev.isDeleted === next.isDeleted &&
|
||||
prev.isKept === next.isKept &&
|
||||
prev.isKeeping === next.isKeeping &&
|
||||
prev.isRemoving === next.isRemoving &&
|
||||
samePostworkSummaryLite(prev.postworkBadge, next.postworkBadge) &&
|
||||
|
||||
@ -75,6 +75,7 @@ type Props = {
|
||||
|
||||
deletingKeys: Set<string>
|
||||
deletedSuccessKeys: Set<string>
|
||||
keptSuccessKeys: Set<string>
|
||||
keepingKeys: Set<string>
|
||||
removingKeys: Set<string>
|
||||
|
||||
@ -134,6 +135,7 @@ function FinishedDownloadsGalleryCardInner({
|
||||
activeTagSet,
|
||||
deletingKeys,
|
||||
deletedSuccessKeys,
|
||||
keptSuccessKeys,
|
||||
keepingKeys,
|
||||
removingKeys,
|
||||
registerTeaserHost,
|
||||
@ -307,8 +309,9 @@ function FinishedDownloadsGalleryCardInner({
|
||||
const isRemoving = removingKeys.has(k)
|
||||
const isDeleting = deletingKeys.has(k)
|
||||
const isDeleted = deletedSuccessKeys.has(k)
|
||||
const isKept = keptSuccessKeys.has(k)
|
||||
const isKeeping = keepingKeys.has(k)
|
||||
const busy = isDeleting || isDeleted || isKeeping || isRemoving
|
||||
const busy = isDeleting || isDeleted || isKeeping || isKept || isRemoving
|
||||
|
||||
const sprite = useMemo(
|
||||
() =>
|
||||
@ -408,7 +411,7 @@ function FinishedDownloadsGalleryCardInner({
|
||||
busy && !isRemoving && 'pointer-events-none opacity-70',
|
||||
isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
|
||||
isDeleted && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||||
isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||||
(isKeeping || isKept) && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||||
isRemoving ? 'pointer-events-none opacity-0 translate-y-2 scale-[0.98]' : 'opacity-100 translate-y-0 scale-100',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
@ -565,6 +568,11 @@ function FinishedDownloadsGalleryCardInner({
|
||||
|
||||
{isDeleting || isDeleted ? (
|
||||
<DeletingPreviewOverlay done={isDeleted} />
|
||||
) : isKeeping || isKept ? (
|
||||
<DeletingPreviewOverlay
|
||||
done={isKept}
|
||||
label={isKept ? 'Behalten' : 'Wird behalten'}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -47,6 +47,7 @@ type Props = {
|
||||
|
||||
deletingKeys: Set<string>
|
||||
deletedSuccessKeys: Set<string>
|
||||
keptSuccessKeys: Set<string>
|
||||
keepingKeys: Set<string>
|
||||
removingKeys: Set<string>
|
||||
|
||||
@ -118,6 +119,7 @@ function FinishedDownloadsGalleryView({
|
||||
formatBytes,
|
||||
deletingKeys,
|
||||
deletedSuccessKeys,
|
||||
keptSuccessKeys,
|
||||
keepingKeys,
|
||||
removingKeys,
|
||||
registerTeaserHost,
|
||||
@ -397,6 +399,7 @@ function FinishedDownloadsGalleryView({
|
||||
activeTagSet={activeTagSet}
|
||||
deletingKeys={deletingKeys}
|
||||
deletedSuccessKeys={deletedSuccessKeys}
|
||||
keptSuccessKeys={keptSuccessKeys}
|
||||
keepingKeys={keepingKeys}
|
||||
removingKeys={removingKeys}
|
||||
registerTeaserHost={registerTeaserHostIfNeeded}
|
||||
|
||||
@ -69,6 +69,7 @@ type Props = {
|
||||
// state/flags for actions row + rowClassName
|
||||
deletingKeys: Set<string>
|
||||
deletedSuccessKeys: Set<string>
|
||||
keptSuccessKeys: Set<string>
|
||||
keepingKeys: Set<string>
|
||||
removingKeys: Set<string>
|
||||
|
||||
@ -132,6 +133,7 @@ export default function FinishedDownloadsTableView({
|
||||
|
||||
deletingKeys,
|
||||
deletedSuccessKeys,
|
||||
keptSuccessKeys,
|
||||
keepingKeys,
|
||||
removingKeys,
|
||||
|
||||
@ -313,6 +315,8 @@ export default function FinishedDownloadsTableView({
|
||||
const k = keyFor(j)
|
||||
const isDeleting = deletingKeys.has(k)
|
||||
const isDeleted = deletedSuccessKeys.has(k)
|
||||
const isKeeping = keepingKeys.has(k)
|
||||
const isKept = keptSuccessKeys.has(k)
|
||||
const isPreviewActive =
|
||||
teaserState.activeKey === k || teaserState.hoverKey === k
|
||||
const allowSound = shouldEnableTeaserAudio({
|
||||
@ -390,6 +394,13 @@ export default function FinishedDownloadsTableView({
|
||||
|
||||
{isDeleting || isDeleted ? (
|
||||
<DeletingPreviewOverlay done={isDeleted} compact className="rounded-md" />
|
||||
) : isKeeping || isKept ? (
|
||||
<DeletingPreviewOverlay
|
||||
done={isKept}
|
||||
label={isKept ? 'Behalten' : 'Wird behalten'}
|
||||
compact
|
||||
className="rounded-md"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@ -557,7 +568,12 @@ export default function FinishedDownloadsTableView({
|
||||
srOnlyHeader: true,
|
||||
cell: (j) => {
|
||||
const k = keyFor(j)
|
||||
const busy = deletingKeys.has(k) || deletedSuccessKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
||||
const busy =
|
||||
deletingKeys.has(k) ||
|
||||
deletedSuccessKeys.has(k) ||
|
||||
keepingKeys.has(k) ||
|
||||
keptSuccessKeys.has(k) ||
|
||||
removingKeys.has(k)
|
||||
|
||||
const fileRaw = baseName(j.output || '')
|
||||
const isHot = isHotName(fileRaw)
|
||||
@ -621,6 +637,7 @@ export default function FinishedDownloadsTableView({
|
||||
resolutions,
|
||||
deletingKeys,
|
||||
deletedSuccessKeys,
|
||||
keptSuccessKeys,
|
||||
keepingKeys,
|
||||
removingKeys,
|
||||
previewSpriteInfoOf,
|
||||
@ -680,6 +697,7 @@ export default function FinishedDownloadsTableView({
|
||||
(deletingKeys.has(k) || removingKeys.has(k)) && 'bg-red-50/60 dark:bg-red-500/10 pointer-events-none',
|
||||
deletingKeys.has(k) && 'animate-pulse',
|
||||
deletedSuccessKeys.has(k) && 'bg-emerald-50/60 dark:bg-emerald-500/10 pointer-events-none',
|
||||
keptSuccessKeys.has(k) && 'bg-emerald-50/60 dark:bg-emerald-500/10 pointer-events-none',
|
||||
(keepingKeys.has(k) || removingKeys.has(k)) && 'pointer-events-none',
|
||||
keepingKeys.has(k) && 'bg-emerald-50/60 dark:bg-emerald-500/10 animate-pulse',
|
||||
removingKeys.has(k) && 'opacity-0',
|
||||
@ -687,7 +705,7 @@ export default function FinishedDownloadsTableView({
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
},
|
||||
[keyFor, deletingKeys, deletedSuccessKeys, keepingKeys, removingKeys]
|
||||
[keyFor, deletingKeys, deletedSuccessKeys, keptSuccessKeys, keepingKeys, removingKeys]
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@ -138,7 +138,15 @@ export default function RecordJobActions({
|
||||
|
||||
const btnBase =
|
||||
variant === 'table'
|
||||
? `inline-flex items-center justify-center rounded-md ${pad} hover:bg-gray-100/70 dark:hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/60 disabled:opacity-50 disabled:cursor-not-allowed transition-transform active:scale-95`
|
||||
? [
|
||||
'inline-flex items-center justify-center rounded-md',
|
||||
pad,
|
||||
'bg-gray-50/90 text-gray-700 shadow-sm ring-1 ring-gray-200/80',
|
||||
'hover:bg-white hover:text-gray-950 hover:ring-gray-300',
|
||||
'dark:bg-white/5 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10 dark:hover:text-white',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/60',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50 transition-transform active:scale-95',
|
||||
].join(' ')
|
||||
: 'inline-flex items-center justify-center rounded-md ' +
|
||||
`bg-white/75 ${pad} text-gray-900 backdrop-blur ring-1 ring-black/10 ` +
|
||||
'hover:bg-white/90 ' +
|
||||
@ -152,7 +160,7 @@ export default function RecordJobActions({
|
||||
favOn: 'text-amber-600 dark:text-amber-300',
|
||||
likeOn: 'text-rose-600 dark:text-rose-300',
|
||||
hotOn: 'text-amber-600 dark:text-amber-300',
|
||||
off: 'text-gray-500 dark:text-gray-300',
|
||||
off: 'text-gray-800 dark:text-gray-100',
|
||||
keep: 'text-emerald-600 dark:text-emerald-300',
|
||||
del: 'text-red-600 dark:text-red-300',
|
||||
watchOn: 'text-sky-600 dark:text-sky-300',
|
||||
@ -169,6 +177,8 @@ export default function RecordJobActions({
|
||||
|
||||
|
||||
// ✅ Reihenfolge strikt nach `order` (wenn gesetzt). Keys die nicht im order stehen: niemals anzeigen.
|
||||
const outlineStrokeWidth = variant === 'table' ? 1.75 : undefined
|
||||
|
||||
const actionOrder: ActionKey[] = order ?? ['watch', 'favorite', 'like', 'training', 'split', 'hot', 'keep', 'delete', 'details']
|
||||
const inOrder = (k: ActionKey) => actionOrder.includes(k)
|
||||
|
||||
@ -318,7 +328,7 @@ export default function RecordJobActions({
|
||||
}}
|
||||
>
|
||||
<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 className="sr-only">{detailsLabel}</span>
|
||||
</button>
|
||||
@ -341,7 +351,7 @@ export default function RecordJobActions({
|
||||
{addState === 'ok' ? (
|
||||
<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 className="sr-only">Zu Downloads</span>
|
||||
@ -362,7 +372,7 @@ export default function RecordJobActions({
|
||||
}}
|
||||
>
|
||||
<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 className="sr-only">Ins Training übernehmen</span>
|
||||
</button>
|
||||
@ -386,6 +396,7 @@ export default function RecordJobActions({
|
||||
iconFill,
|
||||
colors.off
|
||||
)}
|
||||
strokeWidth={outlineStrokeWidth}
|
||||
/>
|
||||
<StarSolidIcon
|
||||
className={cn(
|
||||
@ -417,6 +428,7 @@ export default function RecordJobActions({
|
||||
iconFill,
|
||||
colors.off
|
||||
)}
|
||||
strokeWidth={outlineStrokeWidth}
|
||||
/>
|
||||
<HeartSolidIcon
|
||||
className={cn(
|
||||
@ -449,6 +461,7 @@ export default function RecordJobActions({
|
||||
iconFill,
|
||||
colors.off
|
||||
)}
|
||||
strokeWidth={outlineStrokeWidth}
|
||||
/>
|
||||
<FireSolidIcon
|
||||
className={cn(
|
||||
@ -480,6 +493,7 @@ export default function RecordJobActions({
|
||||
iconFill,
|
||||
colors.off
|
||||
)}
|
||||
strokeWidth={outlineStrokeWidth}
|
||||
/>
|
||||
<EyeSolidIcon
|
||||
className={cn(
|
||||
@ -503,7 +517,7 @@ export default function RecordJobActions({
|
||||
onClick={run(onSplit)}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
) : null
|
||||
@ -518,7 +532,7 @@ export default function RecordJobActions({
|
||||
onClick={run(onKeep)}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
) : null
|
||||
@ -533,7 +547,7 @@ export default function RecordJobActions({
|
||||
onClick={run(onDelete)}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
) : null
|
||||
@ -728,7 +742,7 @@ export default function RecordJobActions({
|
||||
fireDetails()
|
||||
}}
|
||||
>
|
||||
<MagnifyingGlassIcon className={cn('size-4', colors.off)} />
|
||||
<MagnifyingGlassIcon className={cn('size-4', colors.off)} strokeWidth={outlineStrokeWidth} />
|
||||
<span className="truncate">Details</span>
|
||||
</button>
|
||||
)
|
||||
@ -748,7 +762,7 @@ export default function RecordJobActions({
|
||||
await doAdd()
|
||||
}}
|
||||
>
|
||||
<ArrowDownTrayIcon className={cn('size-4', colors.off)} />
|
||||
<ArrowDownTrayIcon className={cn('size-4', colors.off)} strokeWidth={outlineStrokeWidth} />
|
||||
<span className="truncate">Zu Downloads</span>
|
||||
</button>
|
||||
)
|
||||
@ -768,7 +782,7 @@ export default function RecordJobActions({
|
||||
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>
|
||||
</button>
|
||||
)
|
||||
@ -788,7 +802,7 @@ export default function RecordJobActions({
|
||||
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>
|
||||
</button>
|
||||
)
|
||||
@ -811,7 +825,7 @@ export default function RecordJobActions({
|
||||
{isFavorite ? (
|
||||
<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>
|
||||
</button>
|
||||
@ -835,7 +849,7 @@ export default function RecordJobActions({
|
||||
{isLiked ? (
|
||||
<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>
|
||||
</button>
|
||||
@ -859,7 +873,7 @@ export default function RecordJobActions({
|
||||
{isWatching ? (
|
||||
<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>
|
||||
</button>
|
||||
@ -883,7 +897,7 @@ export default function RecordJobActions({
|
||||
{isHot ? (
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</button>
|
||||
)
|
||||
@ -967,7 +981,7 @@ export default function RecordJobActions({
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
|
||||
|
||||
@ -12,7 +12,9 @@ import PostgresUrlModal from './PostgresUrlModal'
|
||||
import { CheckIcon, ChevronDownIcon, XMarkIcon } from '@heroicons/react/24/solid'
|
||||
import { startRegistration } from '@simplewebauthn/browser'
|
||||
|
||||
type RecorderSettings = {
|
||||
type TrainingPerformanceMode = 'auto' | 'eco' | 'balanced' | 'performance' | 'custom'
|
||||
|
||||
type Settings = {
|
||||
databaseType?: 'postgres'
|
||||
databaseUrl?: string
|
||||
hasDbPassword?: boolean
|
||||
@ -46,6 +48,19 @@ type RecorderSettings = {
|
||||
|
||||
trainingRecognitionEnabled?: boolean
|
||||
trainingDetectorEpochs?: number
|
||||
trainingPerformanceMode?: TrainingPerformanceMode | string
|
||||
trainingPowerSaveMode?: boolean
|
||||
trainingCpuThreads?: number
|
||||
trainingWorkers?: number
|
||||
trainingYoloBatchSize?: number
|
||||
trainingLowPriority?: boolean
|
||||
trainingVideoMAEEnabled?: boolean
|
||||
trainingCpuCoreCount?: number
|
||||
trainingEffectiveMode?: TrainingPerformanceMode | string
|
||||
trainingEffectiveCpuThreads?: number
|
||||
trainingEffectiveWorkers?: number
|
||||
trainingEffectiveYoloBatchSize?: number
|
||||
trainingEffectiveLowPriority?: boolean
|
||||
}
|
||||
|
||||
type AuthDevice = {
|
||||
@ -79,6 +94,13 @@ type AIServerStatus = {
|
||||
error?: string
|
||||
}
|
||||
|
||||
type TrainingJobStatus = {
|
||||
running?: boolean
|
||||
progress?: number
|
||||
step?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
type AppLog = {
|
||||
ok: boolean
|
||||
exists?: boolean
|
||||
@ -279,7 +301,7 @@ function SettingsSection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
const DEFAULTS: RecorderSettings = {
|
||||
const DEFAULTS: Settings = {
|
||||
databaseType: 'postgres',
|
||||
databaseUrl: '',
|
||||
hasDbPassword: false,
|
||||
@ -313,6 +335,70 @@ const DEFAULTS: RecorderSettings = {
|
||||
|
||||
trainingRecognitionEnabled: true,
|
||||
trainingDetectorEpochs: 60,
|
||||
trainingPerformanceMode: 'auto',
|
||||
trainingPowerSaveMode: false,
|
||||
trainingCpuThreads: 0,
|
||||
trainingWorkers: 2,
|
||||
trainingYoloBatchSize: 0,
|
||||
trainingLowPriority: false,
|
||||
trainingVideoMAEEnabled: true,
|
||||
}
|
||||
|
||||
const TRAINING_PERFORMANCE_MODES: Array<{
|
||||
key: TrainingPerformanceMode
|
||||
title: string
|
||||
description: string
|
||||
}> = [
|
||||
{
|
||||
key: 'auto',
|
||||
title: 'Automatisch',
|
||||
description: 'Wählt anhand der CPU des Servers einen passenden Modus.',
|
||||
},
|
||||
{
|
||||
key: 'eco',
|
||||
title: 'Schonend',
|
||||
description: 'Minimale Last, niedrige Priorität, langsameres Training.',
|
||||
},
|
||||
{
|
||||
key: 'balanced',
|
||||
title: 'Ausgewogen',
|
||||
description: 'Guter Kompromiss aus Tempo und Temperatur.',
|
||||
},
|
||||
{
|
||||
key: 'performance',
|
||||
title: 'Leistung',
|
||||
description: 'Mehr Threads und größere Batches für schnellere Läufe.',
|
||||
},
|
||||
{
|
||||
key: 'custom',
|
||||
title: 'Manuell',
|
||||
description: 'Eigene Werte für Threads, Worker, Batch und Priorität.',
|
||||
},
|
||||
]
|
||||
|
||||
function normalizeTrainingPerformanceMode(value: unknown): TrainingPerformanceMode {
|
||||
const raw = String(value ?? '').trim().toLowerCase()
|
||||
|
||||
if (raw === 'eco' || raw === 'schonend' || raw === 'powersave' || raw === 'power-save') {
|
||||
return 'eco'
|
||||
}
|
||||
if (raw === 'balanced' || raw === 'ausgewogen' || raw === 'normal') {
|
||||
return 'balanced'
|
||||
}
|
||||
if (raw === 'performance' || raw === 'leistung' || raw === 'fast') {
|
||||
return 'performance'
|
||||
}
|
||||
if (raw === 'custom' || raw === 'manual' || raw === 'manuell') {
|
||||
return 'custom'
|
||||
}
|
||||
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
function formatTrainingEffectiveValue(value: unknown, fallback = 'Auto') {
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n) || n <= 0) return fallback
|
||||
return String(Math.round(n))
|
||||
}
|
||||
|
||||
type Props = {
|
||||
@ -678,8 +764,8 @@ function RatingThresholdStars({
|
||||
)
|
||||
}
|
||||
|
||||
export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
|
||||
export default function Settings({ onAssetsGenerated }: Props) {
|
||||
const [value, setValue] = useState<Settings>(DEFAULTS)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState<number>(0)
|
||||
const saveSuccessTimerRef = useRef<number | null>(null)
|
||||
@ -701,6 +787,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
|
||||
const [dbMaintenanceMsg, setDbMaintenanceMsg] = useState<string | null>(null)
|
||||
const [dbMaintenanceErr, setDbMaintenanceErr] = useState<string | null>(null)
|
||||
const [trainingJobStatus, setTrainingJobStatus] = useState<TrainingJobStatus | null>(null)
|
||||
const now = Date.now()
|
||||
const saveSucceeded = saveSuccessUntilMs > now
|
||||
const saveFailed = Boolean(err) && !saving && !saveSucceeded
|
||||
@ -715,6 +802,25 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
const savedDatabaseConfigured = Boolean(loadedDatabaseUrl)
|
||||
const databaseConfigured = savedDatabaseConfigured
|
||||
const dbMaintenanceBusy = dbBackupBusy || dbRestoreBusy
|
||||
const trainingPerformanceMode = normalizeTrainingPerformanceMode(
|
||||
value.trainingPerformanceMode ?? DEFAULTS.trainingPerformanceMode
|
||||
)
|
||||
const trainingEffectiveMode = normalizeTrainingPerformanceMode(
|
||||
value.trainingEffectiveMode ?? trainingPerformanceMode
|
||||
)
|
||||
const trainingManualMode = trainingPerformanceMode === 'custom'
|
||||
const trainingCpuCoreCount = Math.max(0, Math.round(Number(value.trainingCpuCoreCount ?? 0)))
|
||||
const trainingEffectiveCpuThreads = value.trainingEffectiveCpuThreads ?? value.trainingCpuThreads
|
||||
const trainingEffectiveWorkers = value.trainingEffectiveWorkers ?? value.trainingWorkers
|
||||
const trainingEffectiveYoloBatchSize = value.trainingEffectiveYoloBatchSize ?? value.trainingYoloBatchSize
|
||||
const trainingEffectiveLowPriority =
|
||||
value.trainingEffectiveLowPriority ?? (
|
||||
trainingPerformanceMode === 'eco' ||
|
||||
(trainingPerformanceMode === 'custom' && !!value.trainingLowPriority)
|
||||
)
|
||||
const trainingSettingsLocked = Boolean(trainingJobStatus?.running)
|
||||
const enrichPostworkTemporarilyDisabled =
|
||||
trainingSettingsLocked && !!value.enrichPostworkEnabled
|
||||
|
||||
const [authStatus, setAuthStatus] = useState<AuthSecurityStatus | null>(null)
|
||||
const [securityBusy, setSecurityBusy] = useState(false)
|
||||
@ -850,7 +956,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
if (!r.ok) throw new Error(await r.text())
|
||||
return r.json()
|
||||
})
|
||||
.then((data: RecorderSettings) => {
|
||||
.then((data: Settings) => {
|
||||
if (!alive) return
|
||||
const databaseType = normalizeDatabaseType((data as any).databaseType)
|
||||
setValue({
|
||||
@ -904,6 +1010,32 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
(data as any).trainingRecognitionEnabled ?? DEFAULTS.trainingRecognitionEnabled,
|
||||
trainingDetectorEpochs:
|
||||
(data as any).trainingDetectorEpochs ?? DEFAULTS.trainingDetectorEpochs,
|
||||
trainingPerformanceMode:
|
||||
normalizeTrainingPerformanceMode((data as any).trainingPerformanceMode ?? DEFAULTS.trainingPerformanceMode),
|
||||
trainingPowerSaveMode:
|
||||
(data as any).trainingPowerSaveMode ?? DEFAULTS.trainingPowerSaveMode,
|
||||
trainingCpuThreads:
|
||||
(data as any).trainingCpuThreads ?? DEFAULTS.trainingCpuThreads,
|
||||
trainingWorkers:
|
||||
(data as any).trainingWorkers ?? DEFAULTS.trainingWorkers,
|
||||
trainingYoloBatchSize:
|
||||
(data as any).trainingYoloBatchSize ?? DEFAULTS.trainingYoloBatchSize,
|
||||
trainingLowPriority:
|
||||
(data as any).trainingLowPriority ?? DEFAULTS.trainingLowPriority,
|
||||
trainingVideoMAEEnabled:
|
||||
(data as any).trainingVideoMAEEnabled ?? DEFAULTS.trainingVideoMAEEnabled,
|
||||
trainingCpuCoreCount:
|
||||
(data as any).trainingCpuCoreCount ?? DEFAULTS.trainingCpuCoreCount,
|
||||
trainingEffectiveMode:
|
||||
normalizeTrainingPerformanceMode((data as any).trainingEffectiveMode ?? (data as any).trainingPerformanceMode),
|
||||
trainingEffectiveCpuThreads:
|
||||
(data as any).trainingEffectiveCpuThreads ?? DEFAULTS.trainingEffectiveCpuThreads,
|
||||
trainingEffectiveWorkers:
|
||||
(data as any).trainingEffectiveWorkers ?? DEFAULTS.trainingEffectiveWorkers,
|
||||
trainingEffectiveYoloBatchSize:
|
||||
(data as any).trainingEffectiveYoloBatchSize ?? DEFAULTS.trainingEffectiveYoloBatchSize,
|
||||
trainingEffectiveLowPriority:
|
||||
(data as any).trainingEffectiveLowPriority ?? DEFAULTS.trainingEffectiveLowPriority,
|
||||
})
|
||||
setLoadedDatabaseType(databaseType)
|
||||
setLoadedDatabaseUrl(String((data as any).databaseUrl ?? '').trim())
|
||||
@ -916,6 +1048,47 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
|
||||
const loadTrainingStatus = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/training/status', { cache: 'no-store' })
|
||||
const data = await res.json().catch(() => null)
|
||||
|
||||
if (!alive) return
|
||||
|
||||
if (!res.ok || !data) {
|
||||
setTrainingJobStatus(null)
|
||||
return
|
||||
}
|
||||
|
||||
const job = data?.training && typeof data.training === 'object'
|
||||
? data.training
|
||||
: null
|
||||
|
||||
setTrainingJobStatus(job
|
||||
? {
|
||||
running: Boolean(job.running),
|
||||
progress: Number(job.progress || 0),
|
||||
step: String(job.step || ''),
|
||||
message: String(job.message || ''),
|
||||
}
|
||||
: null)
|
||||
} catch {
|
||||
if (alive) setTrainingJobStatus(null)
|
||||
}
|
||||
}
|
||||
|
||||
void loadTrainingStatus()
|
||||
const timer = window.setInterval(loadTrainingStatus, 2500)
|
||||
|
||||
return () => {
|
||||
alive = false
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
|
||||
@ -1418,6 +1591,26 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
1,
|
||||
Math.min(300, Math.floor(Number(value.trainingDetectorEpochs ?? DEFAULTS.trainingDetectorEpochs ?? 60)))
|
||||
)
|
||||
const trainingPerformanceMode = normalizeTrainingPerformanceMode(
|
||||
value.trainingPerformanceMode ?? DEFAULTS.trainingPerformanceMode
|
||||
)
|
||||
const trainingPowerSaveMode =
|
||||
trainingPerformanceMode === 'eco' ||
|
||||
(trainingPerformanceMode === 'custom' && !!value.trainingPowerSaveMode)
|
||||
const trainingCpuThreads = Math.max(
|
||||
0,
|
||||
Math.min(32, Math.floor(Number(value.trainingCpuThreads ?? DEFAULTS.trainingCpuThreads ?? 0)))
|
||||
)
|
||||
const trainingWorkers = Math.max(
|
||||
0,
|
||||
Math.min(16, Math.floor(Number(value.trainingWorkers ?? DEFAULTS.trainingWorkers ?? 2)))
|
||||
)
|
||||
const trainingYoloBatchSize = Math.max(
|
||||
0,
|
||||
Math.min(64, Math.floor(Number(value.trainingYoloBatchSize ?? DEFAULTS.trainingYoloBatchSize ?? 0)))
|
||||
)
|
||||
const trainingLowPriority = !!value.trainingLowPriority
|
||||
const trainingVideoMAEEnabled = value.trainingVideoMAEEnabled !== false
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
@ -1456,12 +1649,20 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
enrichPostworkEnabled,
|
||||
trainingRecognitionEnabled,
|
||||
trainingDetectorEpochs,
|
||||
trainingPerformanceMode,
|
||||
trainingPowerSaveMode,
|
||||
trainingCpuThreads,
|
||||
trainingWorkers,
|
||||
trainingYoloBatchSize,
|
||||
trainingLowPriority,
|
||||
trainingVideoMAEEnabled,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(() => '')
|
||||
throw new Error(t || `HTTP ${res.status}`)
|
||||
}
|
||||
const savedSettings = await res.json().catch(() => null)
|
||||
// Button-Label: "Gespeichert" für 2.5s
|
||||
const until = Date.now() + 2500
|
||||
setSaveSuccessUntilMs(until)
|
||||
@ -1476,8 +1677,15 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
window.dispatchEvent(new CustomEvent('recorder-settings-updated'))
|
||||
setValue((v) => ({
|
||||
...v,
|
||||
...(savedSettings && typeof savedSettings === 'object' ? savedSettings : {}),
|
||||
databaseType,
|
||||
databaseUrl,
|
||||
trainingPerformanceMode: normalizeTrainingPerformanceMode(
|
||||
savedSettings?.trainingPerformanceMode ?? trainingPerformanceMode
|
||||
),
|
||||
trainingEffectiveMode: normalizeTrainingPerformanceMode(
|
||||
savedSettings?.trainingEffectiveMode ?? savedSettings?.trainingPerformanceMode ?? trainingPerformanceMode
|
||||
),
|
||||
}))
|
||||
|
||||
const databaseChanged =
|
||||
@ -2397,17 +2605,28 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
|
||||
<div className="space-y-3">
|
||||
<LabeledSwitch
|
||||
checked={!!value.enrichPostworkEnabled}
|
||||
checked={!trainingSettingsLocked && !!value.enrichPostworkEnabled}
|
||||
onChange={(checked) =>
|
||||
setValue((v) => ({
|
||||
...v,
|
||||
enrichPostworkEnabled: checked,
|
||||
}))
|
||||
}
|
||||
disabled={trainingSettingsLocked}
|
||||
label="EnrichQ-Postwork im Hintergrund"
|
||||
description="Wenn aktiv, werden fehlende Analyse-/Enrich-Assets automatisch im Hintergrund nachgeholt. Wenn deaktiviert, werden keine neuen EnrichQ-Hintergrundjobs gestartet; manuelle Asset-Generierung bleibt möglich."
|
||||
description={
|
||||
trainingSettingsLocked
|
||||
? 'Während das Training läuft, ist EnrichQ temporär pausiert. Nach dem Training gilt wieder die gespeicherte Einstellung.'
|
||||
: 'Wenn aktiv, werden fehlende Analyse-/Enrich-Assets automatisch im Hintergrund nachgeholt. Wenn deaktiviert, werden keine neuen EnrichQ-Hintergrundjobs gestartet; manuelle Asset-Generierung bleibt möglich.'
|
||||
}
|
||||
/>
|
||||
|
||||
{enrichPostworkTemporarilyDisabled ? (
|
||||
<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
|
||||
checked={!!value.autoAddToDownloadList}
|
||||
onChange={(checked) =>
|
||||
@ -2627,6 +2846,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
min={1}
|
||||
max={300}
|
||||
step={1}
|
||||
disabled={trainingSettingsLocked}
|
||||
value={value.trainingDetectorEpochs ?? 60}
|
||||
onChange={(e) =>
|
||||
setValue((v) => ({
|
||||
@ -2635,6 +2855,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
}))
|
||||
}
|
||||
className="h-9 w-32 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
|
||||
disabled:cursor-not-allowed disabled:opacity-60
|
||||
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
|
||||
@ -2644,6 +2865,231 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
</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>
|
||||
|
||||
<LabeledSwitch
|
||||
File diff suppressed because it is too large
Load Diff
@ -282,6 +282,19 @@ export type RecorderSettingsState = {
|
||||
|
||||
trainingRecognitionEnabled?: boolean
|
||||
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 = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user