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