This commit is contained in:
Linrador 2026-06-19 07:05:34 +02:00
parent 4320f040ff
commit cf86cdd868
37 changed files with 3902 additions and 660 deletions

View File

@ -1,2 +1,2 @@
HTTPS_ENABLED=0
HTTPS_ENABLED=1
AUTH_RP_ORIGINS=https://l14pbbk95100006.tegdssd.de:9999,https://l14pbbk95100006.tegdssd.de:5173,https://localhost:9999,https://127.0.0.1:9999,https://10.0.1.25:9999,http://localhost:5173,http://127.0.0.1:5173,http://10.0.1.25:5173

View File

@ -12,6 +12,52 @@ from ultralytics import YOLO
BASE_DIR = Path(__file__).resolve().parent
KEYPOINT_NAMES = [
"nose",
"left_eye", "right_eye",
"left_ear", "right_ear",
"left_shoulder", "right_shoulder",
"left_elbow", "right_elbow",
"left_wrist", "right_wrist",
"left_hip", "right_hip",
"left_knee", "right_knee",
"left_ankle", "right_ankle",
]
NO_SEX_POSITION_LABEL = "keine"
NO_SEX_POSITION_ALIASES = {
"",
NO_SEX_POSITION_LABEL,
}
def normalize_sex_position_label(value) -> str:
clean = str(value or "").strip().lower()
if clean in NO_SEX_POSITION_ALIASES:
return NO_SEX_POSITION_LABEL
return clean
def is_no_sex_position_label(value) -> bool:
return normalize_sex_position_label(value) == NO_SEX_POSITION_LABEL
def normalize_sex_position_labels(values) -> set[str]:
labels = set()
has_no_position = False
for value in values or []:
clean = normalize_sex_position_label(value)
if is_no_sex_position_label(clean):
has_no_position = True
continue
if clean:
labels.add(clean)
if has_no_position:
labels.add(NO_SEX_POSITION_LABEL)
return labels
def existing_file(path: Path) -> Optional[Path]:
@ -60,6 +106,7 @@ def resolve_training_root() -> Path:
TRAINING_ROOT = resolve_training_root()
DEFAULT_MODEL_PATH = TRAINING_ROOT / "detector" / "model" / "best.pt"
DEFAULT_POSE_MODEL_PATH = TRAINING_ROOT / "pose" / "model" / "best.pt"
def resolve_detection_labels_path() -> Path:
@ -91,12 +138,26 @@ def resolve_model_path() -> str:
raise RuntimeError(f"YOLO model not found: {DEFAULT_MODEL_PATH}")
def resolve_pose_model_path() -> Optional[Path]:
env_path = os.environ.get("YOLO_POSE_MODEL", "").strip()
if env_path:
p = Path(env_path).expanduser().resolve()
if existing_file(p):
return p
raise RuntimeError(f"YOLO_POSE_MODEL not found: {p}")
if existing_file(DEFAULT_POSE_MODEL_PATH):
return DEFAULT_POSE_MODEL_PATH.resolve()
return None
# Server darf auch ohne Labels/Model starten.
DETECTION_LABELS_PATH: Optional[Path] = None
LABEL_GROUPS = {
"people": set(),
"sexPositions": {"unknown"},
"sexPositions": {NO_SEX_POSITION_LABEL},
"bodyParts": set(),
"objects": set(),
"clothing": set(),
@ -114,15 +175,19 @@ PERSON_LABELS = {
_MODEL_PATH = ""
_MODEL_ERROR = ""
_POSE_MODEL_PATH = ""
_POSE_MODEL_ERROR = ""
_LABEL_ERROR = ""
_DEVICE = os.environ.get("YOLO_DEVICE", "")
_CONF = float(os.environ.get("YOLO_CONF", "0.25"))
_POSE_CONF = float(os.environ.get("YOLO_POSE_CONF", "0.30"))
_BATCH = int(os.environ.get("YOLO_BATCH", "16"))
_IMGSZ = int(os.environ.get("YOLO_IMGSZ", "640"))
_HALF = os.environ.get("YOLO_HALF", "0").lower() in {"1", "true", "yes", "on"}
model = None
pose_model = None
app = FastAPI()
@ -174,13 +239,14 @@ def empty_prediction(source: str = "model_missing") -> dict:
return {
"modelAvailable": False,
"source": source,
"sexPosition": "unknown",
"sexPosition": NO_SEX_POSITION_LABEL,
"sexPositionScore": 0.0,
"peoplePresent": [],
"bodyPartsPresent": [],
"objectsPresent": [],
"clothingPresent": [],
"boxes": [],
"persons": [],
}
@ -207,11 +273,7 @@ def load_label_groups_safe() -> None:
for x in data.get("people", [])
if str(x).strip()
),
"sexPositions": set(
str(x).strip().lower()
for x in data.get("sexPositions", [])
if str(x).strip()
),
"sexPositions": normalize_sex_position_labels(data.get("sexPositions", [])),
"bodyParts": set(
str(x).strip().lower()
for x in data.get("bodyParts", [])
@ -230,7 +292,7 @@ def load_label_groups_safe() -> None:
}
if not LABEL_GROUPS["sexPositions"]:
LABEL_GROUPS["sexPositions"] = {"unknown"}
LABEL_GROUPS["sexPositions"] = {NO_SEX_POSITION_LABEL}
_LABEL_ERROR = ""
@ -240,7 +302,7 @@ def load_label_groups_safe() -> None:
LABEL_GROUPS = {
"people": set(),
"sexPositions": {"unknown"},
"sexPositions": {NO_SEX_POSITION_LABEL},
"bodyParts": set(),
"objects": set(),
"clothing": set(),
@ -252,7 +314,7 @@ def load_label_groups_safe() -> None:
POSITION_LABELS = {
label for label in LABEL_GROUPS["sexPositions"]
if label and label != "unknown"
if label and not is_no_sex_position_label(label)
}
PERSON_LABELS = {
@ -289,6 +351,37 @@ def get_model():
return None
def get_pose_model():
global pose_model
global _POSE_MODEL_PATH
global _POSE_MODEL_ERROR
if pose_model is not None:
return pose_model
try:
path = resolve_pose_model_path()
if path is None:
pose_model = None
_POSE_MODEL_PATH = ""
_POSE_MODEL_ERROR = ""
return None
loaded = YOLO(str(path))
pose_model = loaded
_POSE_MODEL_PATH = str(path)
_POSE_MODEL_ERROR = ""
return pose_model
except Exception as exc:
pose_model = None
_POSE_MODEL_PATH = ""
_POSE_MODEL_ERROR = str(exc)
return None
def scored(label: str, score: float) -> dict:
return {
"label": label,
@ -315,9 +408,6 @@ def prediction_from_result(result) -> dict:
objects = []
clothing = []
sex_position = "unknown"
sex_position_score = 0.0
if result.boxes is not None:
xywhn = result.boxes.xywhn.cpu().tolist()
cls_values = result.boxes.cls.cpu().tolist()
@ -341,13 +431,6 @@ def prediction_from_result(result) -> dict:
is_body = label in BODY_LABELS
is_object = label in OBJECT_LABELS
is_clothing = label in CLOTHING_LABELS
is_position = label in POSITION_LABELS
if is_position:
if score > sex_position_score:
sex_position = label
sex_position_score = score
continue
if not (is_person or is_body or is_object or is_clothing):
continue
@ -376,13 +459,165 @@ def prediction_from_result(result) -> dict:
return {
"modelAvailable": True,
"source": f"yolo-server:{Path(_MODEL_PATH).name}",
"sexPosition": sex_position,
"sexPositionScore": sex_position_score,
"sexPosition": NO_SEX_POSITION_LABEL,
"sexPositionScore": 0.0,
"peoplePresent": people_present,
"bodyPartsPresent": body_parts,
"objectsPresent": objects,
"clothingPresent": clothing,
"boxes": boxes_out,
"persons": [],
}
def pose_persons_from_result(result) -> list[dict]:
names = result.names or {}
persons = []
if result.boxes is None:
return persons
kpts_xyn = None
kpts_conf = None
if result.keypoints is not None:
try:
kpts_xyn = result.keypoints.xyn.cpu().tolist()
except Exception:
kpts_xyn = None
try:
kpts_conf = result.keypoints.conf.cpu().tolist()
except Exception:
kpts_conf = None
xywhn_values = result.boxes.xywhn.cpu().tolist()
cls_values = result.boxes.cls.cpu().tolist()
conf_values = result.boxes.conf.cpu().tolist()
for i, (box_xywhn, cls_id, conf) in enumerate(
zip(xywhn_values, cls_values, conf_values)
):
label = str(names.get(int(cls_id), int(cls_id))).strip().lower()
score = float(conf)
cx, cy, w, h = [float(v) for v in box_xywhn]
x = max(0.0, min(1.0, cx - w / 2.0))
y = max(0.0, min(1.0, cy - h / 2.0))
w = max(0.0, min(1.0 - x, w))
h = max(0.0, min(1.0 - y, h))
if w <= 0 or h <= 0:
continue
keypoints = []
if kpts_xyn is not None and i < len(kpts_xyn):
for ki, point in enumerate(kpts_xyn[i]):
if len(point) < 2:
continue
kconf = 0.0
if (
kpts_conf is not None
and i < len(kpts_conf)
and ki < len(kpts_conf[i])
):
kconf = float(kpts_conf[i][ki])
keypoints.append({
"name": KEYPOINT_NAMES[ki] if ki < len(KEYPOINT_NAMES) else str(ki),
"x": float(point[0]),
"y": float(point[1]),
"conf": kconf,
})
persons.append({
"label": label,
"score": score,
"box": {
"x": x,
"y": y,
"w": w,
"h": h,
},
"keypoints": keypoints,
})
return persons
def apply_pose_result_to_prediction(prediction: dict, result) -> dict:
persons = pose_persons_from_result(result)
if not persons:
return prediction
prediction["persons"] = persons
best_position = ""
best_score_value = 0.0
for person in persons:
label = str(person.get("label") or "").strip().lower()
score = float(person.get("score") or 0.0)
if is_no_sex_position_label(label) or label not in POSITION_LABELS:
continue
if score > best_score_value:
best_position = label
best_score_value = score
if best_position:
prediction["sexPosition"] = best_position
prediction["sexPositionScore"] = best_score_value
source = str(prediction.get("source") or "").strip()
prediction["source"] = f"{source}+yolo_pose" if source else "yolo_pose"
return prediction
def apply_pose_batch_to_predictions(paths: list[str], predictions: list[dict], imgsz: int) -> None:
global _POSE_MODEL_ERROR
current_pose_model = get_pose_model()
if current_pose_model is None:
return
try:
pose_results = current_pose_model.predict(
source=paths,
imgsz=imgsz,
conf=_POSE_CONF,
batch=_BATCH,
device=_DEVICE or None,
half=_HALF,
verbose=False,
)
except Exception as exc:
_POSE_MODEL_ERROR = str(exc)
return
for prediction, pose_result in zip(predictions, pose_results):
apply_pose_result_to_prediction(prediction, pose_result)
def pose_model_status() -> dict:
try:
expected = resolve_pose_model_path()
expected_text = str(expected) if expected else str(DEFAULT_POSE_MODEL_PATH)
exists = expected is not None
error = _POSE_MODEL_ERROR
except Exception as exc:
expected_text = str(DEFAULT_POSE_MODEL_PATH)
exists = False
error = str(exc)
return {
"poseModelAvailable": exists,
"poseModelLoaded": pose_model is not None,
"poseModel": _POSE_MODEL_PATH or (expected_text if exists else ""),
"poseModelError": error,
"expectedPoseModel": expected_text,
}
@ -426,6 +661,9 @@ def predict_batch(req: PredictBatchRequest):
predictions = [prediction_from_result(result) for result in results]
if not req.detectorOnly:
apply_pose_batch_to_predictions(paths, predictions, imgsz)
return {
"ok": True,
"predictions": predictions,
@ -444,7 +682,7 @@ def health():
current_model = get_model()
names = getattr(current_model, "names", {}) or {} if current_model is not None else {}
return {
status_payload = {
"ok": True,
"ready": current_model is not None,
"modelAvailable": current_model is not None,
@ -458,22 +696,31 @@ def health():
"labelError": _LABEL_ERROR,
}
status_payload.update(pose_model_status())
return status_payload
@app.post("/reload", dependencies=[Depends(require_ai_server_auth)])
def reload_model():
global model
global pose_model
global _MODEL_PATH
global _MODEL_ERROR
global _POSE_MODEL_PATH
global _POSE_MODEL_ERROR
global DETECTION_LABELS_PATH
model = None
pose_model = None
_MODEL_PATH = ""
_MODEL_ERROR = ""
_POSE_MODEL_PATH = ""
_POSE_MODEL_ERROR = ""
DETECTION_LABELS_PATH = None
current_model = get_model()
names = getattr(current_model, "names", {}) or {} if current_model is not None else {}
return {
status_payload = {
"ok": current_model is not None,
"ready": current_model is not None,
"modelAvailable": current_model is not None,
@ -485,4 +732,7 @@ def reload_model():
"classes": list(names.values())[:80] if isinstance(names, dict) else names,
"labelConfig": str(DETECTION_LABELS_PATH) if DETECTION_LABELS_PATH else "",
"labelError": _LABEL_ERROR,
}
}
status_payload.update(pose_model_status())
return status_payload

View File

@ -135,7 +135,7 @@ func autoSelectedAILabelSet() map[string]struct{} {
add := func(values []string) {
for _, value := range values {
label := strings.ToLower(strings.TrimSpace(value))
if label == "" || label == "unknown" {
if isNoSexPositionLabel(label) {
continue
}
out[label] = struct{}{}
@ -155,7 +155,7 @@ var autoSelectedAILabelsCache map[string]struct{}
func shouldAutoSelectAnalyzeHit(label string) bool {
label = strings.ToLower(strings.TrimSpace(label))
if label == "" || label == "unknown" {
if isNoSexPositionLabel(label) {
return false
}
@ -202,7 +202,7 @@ func isIgnoredNSFWLabel(label string) bool {
func addHighlightResult(best map[string]float64, label string, score float64) {
label = strings.ToLower(strings.TrimSpace(label))
if label == "" || label == "unknown" {
if isNoSexPositionLabel(label) {
return
}
@ -223,7 +223,7 @@ func addScoredHighlightLabels(best map[string]float64, prefix string, items []Tr
for _, item := range items {
label := strings.ToLower(strings.TrimSpace(item.Label))
if label == "" || label == "unknown" {
if isNoSexPositionLabel(label) {
continue
}
@ -235,7 +235,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe
best := map[string]float64{}
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) {
if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) {
addHighlightResult(best, "position:"+sexPosition, pred.SexPositionScore)
}
@ -245,7 +245,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe
for _, box := range pred.Boxes {
label := strings.ToLower(strings.TrimSpace(box.Label))
if label == "" || label == "unknown" {
if isNoSexPositionLabel(label) {
continue
}
@ -264,7 +264,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe
}
// Kombis nur erzeugen, wenn wirklich Position + Zusatz vorhanden ist.
if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) {
if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) {
positionScore := pred.SexPositionScore
if positionScore <= 0 {
positionScore = 1
@ -273,7 +273,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe
addCombo := func(prefix string, items []TrainingScoredLabel) {
for _, item := range items {
label := strings.ToLower(strings.TrimSpace(item.Label))
if label == "" || label == "unknown" {
if isNoSexPositionLabel(label) {
continue
}
@ -316,7 +316,7 @@ func pickHighlightResults(results []NsfwFrameResult) []NsfwFrameResult {
for _, r := range results {
label := strings.ToLower(strings.TrimSpace(r.Label))
if label == "" || label == "unknown" {
if isNoSexPositionLabel(label) {
continue
}
@ -1075,7 +1075,7 @@ type highlightSignal struct {
func normalizeHighlightSignalLabel(label string) string {
label = strings.ToLower(strings.TrimSpace(label))
if label == "" || label == "unknown" {
if isNoSexPositionLabel(label) {
return ""
}
@ -1094,28 +1094,28 @@ func normalizeHighlightSignalLabel(label string) string {
case strings.HasPrefix(label, "body:"):
raw := strings.TrimPrefix(label, "body:")
if raw == "" || raw == "unknown" {
if isNoSexPositionLabel(raw) {
return ""
}
return "body:" + raw
case strings.HasPrefix(label, "object:"):
raw := strings.TrimPrefix(label, "object:")
if raw == "" || raw == "unknown" {
if isNoSexPositionLabel(raw) {
return ""
}
return "object:" + raw
case strings.HasPrefix(label, "clothing:"):
raw := strings.TrimPrefix(label, "clothing:")
if raw == "" || raw == "unknown" {
if isNoSexPositionLabel(raw) {
return ""
}
return "clothing:" + raw
case strings.HasPrefix(label, "position:"):
raw := strings.TrimPrefix(label, "position:")
if raw == "" || raw == "unknown" || !isKnownPositionLabel(raw) {
if isNoSexPositionLabel(raw) || !isKnownPositionLabel(raw) {
return ""
}
return "position:" + raw
@ -1173,7 +1173,7 @@ func rawAnalyzeSignalSeverityWeight(label string) float64 {
}
raw := normalizeSegmentLabel(label)
if raw == "" || raw == "unknown" {
if isNoSexPositionLabel(raw) {
return 0
}
@ -1273,7 +1273,7 @@ func addHighlightSignalsFromScoredLabels(
for _, item := range items {
label := strings.ToLower(strings.TrimSpace(item.Label))
if label == "" || label == "unknown" {
if isNoSexPositionLabel(label) {
continue
}
@ -1302,7 +1302,7 @@ func highlightComboPartOrder(label string) int {
func predictionHasAnyAnalyzeSignal(pred TrainingPrediction) bool {
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
if sexPosition != "" && sexPosition != "unknown" {
if !isNoSexPositionLabel(sexPosition) {
return true
}
@ -1342,7 +1342,7 @@ func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64)
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) {
if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) {
positionScore := pred.SexPositionScore
if positionScore <= 0 {
positionScore = 0.35
@ -1364,7 +1364,7 @@ func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64)
for _, box := range pred.Boxes {
label := strings.ToLower(strings.TrimSpace(box.Label))
if label == "" || label == "unknown" {
if isNoSexPositionLabel(label) {
continue
}
if isIgnoredNSFWLabel(label) {
@ -1540,7 +1540,7 @@ func buildHighlightHitsFromPrediction(pred TrainingPrediction, t float64) []anal
best := map[string]highlightSignal{}
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) {
if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) {
addHighlightSignal(best, "position:"+sexPosition, pred.SexPositionScore)
}
@ -1550,7 +1550,7 @@ func buildHighlightHitsFromPrediction(pred TrainingPrediction, t float64) []anal
for _, box := range pred.Boxes {
label := strings.ToLower(strings.TrimSpace(box.Label))
if label == "" || label == "unknown" {
if isNoSexPositionLabel(label) {
continue
}
if isIgnoredNSFWLabel(label) {
@ -2322,7 +2322,7 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit {
for _, h := range in {
label := strings.ToLower(strings.TrimSpace(h.Label))
if label == "" || label == "unknown" {
if isNoSexPositionLabel(label) {
continue
}
if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) {
@ -2542,7 +2542,7 @@ type analyzeLabelSegmentPoint struct {
func isAllowedAnalyzeSegmentLabel(label string) bool {
label = strings.ToLower(strings.TrimSpace(label))
if label == "" || label == "unknown" {
if isNoSexPositionLabel(label) {
return false
}
if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) {
@ -2559,7 +2559,7 @@ func isAllowedAnalyzeSegmentLabel(label string) bool {
}
raw := normalizeSegmentLabel(label)
if raw == "" || raw == "unknown" {
if isNoSexPositionLabel(raw) {
return false
}

View File

@ -76,20 +76,29 @@ func stopJobsInternal(list []*RecordJob) {
}
pl := make([]payload, 0, len(list))
ids := make([]string, 0, len(list))
nowMs := time.Now().UnixMilli()
jobsMu.Lock()
for _, job := range list {
if job == nil {
continue
}
if job.EndedAt != nil || isTerminalJobStatus(job.Status) || isPostworkJob(job) {
continue
}
job.Phase = "stopping"
job.Progress = 10
job.StopRequestedAtMs = nowMs
job.StopAttempts++
pl = append(pl, payload{
cmd: job.previewCmd,
cancel: job.cancel,
previewCancel: job.previewCancel,
})
ids = append(ids, job.ID)
job.previewCmd = nil
job.previewCancel = nil
@ -98,6 +107,11 @@ func stopJobsInternal(list []*RecordJob) {
}
jobsMu.Unlock()
for _, id := range ids {
_ = publishJobSnapshot(id)
_ = publishModelJobUpsert(id)
}
for _, p := range pl {
if p.previewCancel != nil {
p.previewCancel()
@ -109,6 +123,11 @@ func stopJobsInternal(list []*RecordJob) {
p.cancel()
}
}
for _, id := range ids {
_ = publishJobSnapshot(id)
_ = publishModelJobUpsert(id)
}
}
func stopAllStoppableJobs() int {

View File

@ -8,9 +8,17 @@ import (
"path/filepath"
)
//go:embed ml/*.py ai_server.py .env recorder-cert.pem recorder-key.pem generated/training/detection_labels.json
//go:embed ml/*.py ml/detection_labels.json ai_server.py .env recorder-cert.pem recorder-key.pem yolo26n-pose.pt
var embeddedMLFiles embed.FS
func embeddedWriteFileIfNeeded(dstPath string, b []byte, perm os.FileMode) error {
if fi, err := os.Stat(dstPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() == int64(len(b)) {
return nil
}
return os.WriteFile(dstPath, b, perm)
}
func trainingEmbeddedMLDir() (string, error) {
dir := filepath.Join(os.TempDir(), "nsfwapp-ml")
@ -22,6 +30,8 @@ func trainingEmbeddedMLDir() (string, error) {
"predict_detector_model.py",
"train_detector_model.py",
"detection_labels.json",
"predict_pose_model.py",
"train_pose_model.py",
}
// Falls du die alten Scene-Skripte noch embedded hast, kannst du sie optional mitkopieren.
@ -33,10 +43,6 @@ func trainingEmbeddedMLDir() (string, error) {
for _, name := range append(files, optionalFiles...) {
srcPath := filepath.ToSlash(filepath.Join("ml", name))
if name == "detection_labels.json" {
srcPath = "generated/training/detection_labels.json"
}
b, err := embeddedMLFiles.ReadFile(srcPath)
if err != nil {
// Pflichtdateien müssen vorhanden sein.
@ -51,14 +57,24 @@ func trainingEmbeddedMLDir() (string, error) {
}
dstPath := filepath.Join(dir, name)
if err := os.WriteFile(dstPath, b, 0644); err != nil {
if err := embeddedWriteFileIfNeeded(dstPath, b, 0644); err != nil {
return "", err
}
}
if _, err := embeddedPoseModelPath(); err != nil {
return "", err
}
return dir, nil
}
func embeddedMLScriptExists(name string) bool {
srcPath := filepath.ToSlash(filepath.Join("ml", name))
_, err := embeddedMLFiles.ReadFile(srcPath)
return err == nil
}
func embeddedAIServerDir() (string, error) {
dir := filepath.Join(os.TempDir(), "nsfwapp-ai-server")
@ -72,13 +88,33 @@ func embeddedAIServerDir() (string, error) {
}
dstPath := filepath.Join(dir, "ai_server.py")
if err := os.WriteFile(dstPath, b, 0644); err != nil {
if err := embeddedWriteFileIfNeeded(dstPath, b, 0644); err != nil {
return "", err
}
return dir, nil
}
func embeddedPoseModelPath() (string, error) {
dir := filepath.Join(os.TempDir(), "nsfwapp-ml")
if err := os.MkdirAll(dir, 0755); err != nil {
return "", err
}
b, err := embeddedMLFiles.ReadFile("yolo26n-pose.pt")
if err != nil {
return "", err
}
dstPath := filepath.Join(dir, "yolo26n-pose.pt")
if err := embeddedWriteFileIfNeeded(dstPath, b, 0644); err != nil {
return "", err
}
return dstPath, nil
}
func embeddedDotEnvBytes() ([]byte, error) {
return embeddedMLFiles.ReadFile(".env")
}

View File

@ -103,6 +103,9 @@ func appLogWriter() io.Writer {
func appLogWriteLine(line string) {
line = strings.TrimRight(line, "\r\n")
if appLogLineSuppressed(line) {
return
}
ts := time.Now().Format("2006-01-02 15:04:05")
out := fmt.Sprintf("[%s] %s\n", ts, line)
@ -130,11 +133,71 @@ func appLogf(format string, args ...any) {
// erwartbare und sehr häufige Fehler (z.B. abgelaufene HLS-Edge-URLs während
// eines Stream-Reconnects). Der Fehler wird weiterhin zurückgegeben, damit die
// Retry-/Reconnect-Logik unverändert funktioniert nur die Log-Zeile entfällt.
func appLogLineSuppressed(line string) bool {
m := strings.ToLower(strings.TrimSpace(line))
if m == "" {
return true
}
if strings.Contains(m, "get playlist: context canceled") {
return true
}
if strings.Contains(m, "usernamelookup ohne user.id") {
return true
}
if strings.Contains(m, "mfc status failed") &&
(strings.Contains(m, "user not found") || strings.Contains(m, "usernamelookup")) {
return true
}
if strings.Contains(m, "mfc playlist candidate failed") &&
(strings.Contains(m, "http 403") || strings.Contains(m, "http 404")) {
return true
}
if strings.Contains(m, "mfc lookup:") {
return true
}
isHLS := strings.Contains(m, ".m3u8") ||
strings.Contains(m, "abruf der m3u8") ||
strings.Contains(m, "variant-playlist")
if isHLS && strings.Contains(m, "http 403") {
return true
}
if isHLS && (strings.Contains(m, "http 500") ||
strings.Contains(m, "http 502") ||
strings.Contains(m, "http 503") ||
strings.Contains(m, "http 504")) {
return true
}
return false
}
func appLogFilterText(text string) string {
lines := strings.Split(text, "\n")
out := make([]string, 0, len(lines))
for _, line := range lines {
if appLogLineSuppressed(line) {
continue
}
out = append(out, line)
}
return strings.Join(out, "\n")
}
func appErrorLogSuppressed(msg string) bool {
m := strings.ToLower(msg)
return strings.Contains(m, "leere hls url") ||
strings.Contains(m, "http 403")
return appLogLineSuppressed(msg) ||
strings.Contains(m, "leere hls url")
}
func appErrorf(format string, args ...any) error {
@ -243,7 +306,7 @@ func appLogHandler(w http.ResponseWriter, r *http.Request) {
}
}
logText := string(buf)
logText := appLogFilterText(string(buf))
if offset > 0 {
logText = "… Log gekürzt, zeige die letzten " + fmt.Sprint(maxBytes/1024) + " KB …\n\n" + logText
}

View File

@ -95,8 +95,10 @@ type RecordJob struct {
PreviewUA string `json:"-"` // user-agent
// ✅ Frontend Progress beim Stop/Finalize
Phase string `json:"phase,omitempty"` // stopping | remuxing | moving
Progress int `json:"progress,omitempty"` // 0..100
Phase string `json:"phase,omitempty"` // stopping | remuxing | moving
Progress int `json:"progress,omitempty"` // 0..100
StopRequestedAtMs int64 `json:"stopRequestedAtMs,omitempty"` // ms since epoch
StopAttempts int `json:"stopAttempts,omitempty"`
PostWorkKey string `json:"postWorkKey,omitempty"`
PostWork *PostWorkKeyStatus `json:"postWork,omitempty"`
@ -105,24 +107,26 @@ type RecordJob struct {
}
type jobEventSnapshot struct {
ID string
SourceURL string
Output string
Status JobStatus
StartedAt time.Time
StartedAtMs int64
EndedAt *time.Time
EndedAtMs int64
Error string
Phase string
Progress int
SizeBytes int64
DurationSeconds float64
PreviewState string
PostWorkKey string
PostWork *PostWorkKeyStatus
ModelImageURL string
Avatar string
ID string
SourceURL string
Output string
Status JobStatus
StartedAt time.Time
StartedAtMs int64
EndedAt *time.Time
EndedAtMs int64
Error string
Phase string
Progress int
StopRequestedAtMs int64
StopAttempts int
SizeBytes int64
DurationSeconds float64
PreviewState string
PostWorkKey string
PostWork *PostWorkKeyStatus
ModelImageURL string
Avatar string
}
type dummyResponseWriter struct {
@ -165,25 +169,27 @@ func canonicalAssetIDFromName(name string) string {
func publishJobUpsertSnapshot(s jobEventSnapshot) {
ev := map[string]any{
"type": "job_upsert",
"jobId": s.ID,
"status": string(s.Status),
"sourceUrl": s.SourceURL,
"output": s.Output,
"startedAt": s.StartedAt,
"startedAtMs": s.StartedAtMs,
"endedAt": s.EndedAt,
"endedAtMs": s.EndedAtMs,
"error": s.Error,
"phase": s.Phase,
"progress": s.Progress,
"sizeBytes": s.SizeBytes,
"durationSeconds": s.DurationSeconds,
"previewState": s.PreviewState,
"postWorkKey": s.PostWorkKey,
"ts": time.Now().UnixMilli(),
"modelImageUrl": s.ModelImageURL,
"avatar": s.Avatar,
"type": "job_upsert",
"jobId": s.ID,
"status": string(s.Status),
"sourceUrl": s.SourceURL,
"output": s.Output,
"startedAt": s.StartedAt,
"startedAtMs": s.StartedAtMs,
"endedAt": s.EndedAt,
"endedAtMs": s.EndedAtMs,
"error": s.Error,
"phase": s.Phase,
"progress": s.Progress,
"stopRequestedAtMs": s.StopRequestedAtMs,
"stopAttempts": s.StopAttempts,
"sizeBytes": s.SizeBytes,
"durationSeconds": s.DurationSeconds,
"previewState": s.PreviewState,
"postWorkKey": s.PostWorkKey,
"ts": time.Now().UnixMilli(),
"modelImageUrl": s.ModelImageURL,
"avatar": s.Avatar,
}
if s.PostWork != nil {
@ -222,24 +228,26 @@ func publishJobSnapshot(jobID string) bool {
}
snap := jobEventSnapshot{
ID: job.ID,
SourceURL: job.SourceURL,
Output: job.Output,
Status: job.Status,
StartedAt: job.StartedAt,
StartedAtMs: job.StartedAtMs,
EndedAt: endedAtCopy,
EndedAtMs: job.EndedAtMs,
Error: job.Error,
Phase: job.Phase,
Progress: job.Progress,
SizeBytes: job.SizeBytes,
DurationSeconds: job.DurationSeconds,
PreviewState: job.PreviewState,
PostWorkKey: job.PostWorkKey,
PostWork: pw,
ModelImageURL: job.ModelImageURL,
Avatar: job.Avatar,
ID: job.ID,
SourceURL: job.SourceURL,
Output: job.Output,
Status: job.Status,
StartedAt: job.StartedAt,
StartedAtMs: job.StartedAtMs,
EndedAt: endedAtCopy,
EndedAtMs: job.EndedAtMs,
Error: job.Error,
Phase: job.Phase,
Progress: job.Progress,
StopRequestedAtMs: job.StopRequestedAtMs,
StopAttempts: job.StopAttempts,
SizeBytes: job.SizeBytes,
DurationSeconds: job.DurationSeconds,
PreviewState: job.PreviewState,
PostWorkKey: job.PostWorkKey,
PostWork: pw,
ModelImageURL: job.ModelImageURL,
Avatar: job.Avatar,
}
jobsMu.RUnlock()

View File

@ -0,0 +1,56 @@
{
"people": [
"person_female",
"person_male"
],
"sexPositions": [
"keine",
"missionary",
"doggy",
"cowgirl",
"reverse_cowgirl",
"cunnilingus",
"prone_bone",
"standing",
"standing_doggy",
"spooning",
"facesitting",
"handjob",
"blowjob",
"boobjob",
"toy_play",
"fingering",
"69"
],
"bodyParts": [
"anus",
"ass",
"breasts",
"penis",
"tongue",
"pussy"
],
"objects": [
"blindfold",
"buttplug",
"collar",
"dildo",
"handcuffs",
"shower",
"strapon",
"towel",
"vibrator"
],
"clothing": [
"bikini",
"bra",
"dress",
"heels",
"hotpants",
"lingerie",
"panties",
"skirt",
"stockings",
"croptop"
]
}

View File

@ -13,10 +13,36 @@ def clamp01(v):
return max(0.0, min(1.0, float(v)))
def existing_file(path):
try:
p = Path(path).expanduser().resolve()
if p.exists() and p.is_file() and p.stat().st_size > 0:
return p
except Exception:
pass
return None
def resolve_model_path(root, requested):
if requested:
p = existing_file(requested)
if p:
return p, "yolo26_model"
return Path(requested).expanduser(), "detector_missing"
trained = root / "detector" / "model" / "best.pt"
p = existing_file(trained)
if p:
return p, "yolo26_detector"
return trained, "detector_missing"
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--root", required=True)
parser.add_argument("--image", required=True)
parser.add_argument("--model", default="")
parser.add_argument("--conf", type=float, default=0.30)
parser.add_argument("--imgsz", type=int, default=640)
parser.add_argument("--debug-image", action="store_true")
@ -25,11 +51,11 @@ def main():
root = Path(args.root)
image_path = Path(args.image)
model_path = root / "detector" / "model" / "best.pt"
model_path, model_source = resolve_model_path(root, args.model)
if not model_path.exists():
print(json.dumps({
"available": False,
"source": "detector_missing",
"source": model_source,
"modelPath": str(model_path),
"boxes": [],
}, ensure_ascii=False))
@ -124,7 +150,7 @@ def main():
print(json.dumps({
"available": True,
"source": "yolo26_detector",
"source": model_source,
"modelPath": str(model_path),
"image": str(image_path),
"conf": float(args.conf),
@ -139,4 +165,4 @@ def main():
if __name__ == "__main__":
main()
main()

View File

@ -0,0 +1,210 @@
# backend/ml/predict_pose_model.py
import argparse
import json
from pathlib import Path
from PIL import Image
from ultralytics import YOLO
import torch
KEYPOINT_NAMES = [
"nose",
"left_eye", "right_eye",
"left_ear", "right_ear",
"left_shoulder", "right_shoulder",
"left_elbow", "right_elbow",
"left_wrist", "right_wrist",
"left_hip", "right_hip",
"left_knee", "right_knee",
"left_ankle", "right_ankle",
]
BASE_MODEL_NAME = "yolo26n-pose.pt"
def existing_file(path):
try:
p = Path(path).expanduser().resolve()
if p.exists() and p.is_file() and p.stat().st_size > 0:
return p
except Exception:
pass
return None
def base_model_candidates(root):
script_dir = Path(__file__).resolve().parent
return [
script_dir / BASE_MODEL_NAME,
Path.cwd() / BASE_MODEL_NAME,
root / BASE_MODEL_NAME,
root.parent / BASE_MODEL_NAME,
root.parent.parent / BASE_MODEL_NAME,
]
def resolve_model_path(root, requested):
if requested:
p = existing_file(requested)
if p:
return p, "yolo_pose_model"
return Path(requested).expanduser(), "pose_missing"
trained = root / "pose" / "model" / "best.pt"
p = existing_file(trained)
if p:
return p, "yolo_pose"
for candidate in base_model_candidates(root):
p = existing_file(candidate)
if p:
return p, "yolo26_pose_base"
return trained, "pose_missing"
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--root", required=True)
parser.add_argument("--image", required=True)
parser.add_argument("--model", default="")
parser.add_argument("--conf", type=float, default=0.30)
parser.add_argument("--imgsz", type=int, default=640)
args = parser.parse_args()
root = Path(args.root)
image_path = Path(args.image)
model_path, model_source = resolve_model_path(root, args.model)
if not model_path.exists():
print(json.dumps({
"available": False,
"source": model_source,
"modelPath": str(model_path),
"persons": [],
}, ensure_ascii=False))
return
img = Image.open(image_path).convert("RGB")
img_w, img_h = img.size
try:
model = YOLO(str(model_path))
except Exception as e:
print(json.dumps({
"available": False,
"source": "pose_load_failed",
"modelPath": str(model_path),
"error": repr(e),
"persons": [],
}, ensure_ascii=False))
return
device = 0 if torch.cuda.is_available() else "cpu"
try:
results = model.predict(
source=str(image_path),
conf=float(args.conf),
imgsz=int(args.imgsz),
verbose=False,
device=device,
)
except Exception as e:
print(json.dumps({
"available": False,
"source": "pose_predict_failed",
"modelPath": str(model_path),
"image": str(image_path),
"error": repr(e),
"persons": [],
}, ensure_ascii=False))
return
persons = []
if results:
r = results[0]
names = r.names or {}
kpts_xyn = None
kpts_conf = None
if r.keypoints is not None:
try:
kpts_xyn = r.keypoints.xyn.cpu().numpy()
except Exception:
kpts_xyn = None
try:
kpts_conf = r.keypoints.conf.cpu().numpy()
except Exception:
kpts_conf = None
if r.boxes is not None:
for i, b in enumerate(r.boxes):
score = float(b.conf[0].item())
label = ""
try:
cls_id = int(b.cls[0].item())
label = str(names.get(cls_id, cls_id)).strip().lower()
except Exception:
label = ""
x1, y1, x2, y2 = [float(v) for v in b.xyxy[0].tolist()]
x1 = max(0.0, min(float(img_w), x1))
y1 = max(0.0, min(float(img_h), y1))
x2 = max(0.0, min(float(img_w), x2))
y2 = max(0.0, min(float(img_h), y2))
if x2 <= x1 or y2 <= y1:
continue
box = {
"x": x1 / img_w,
"y": y1 / img_h,
"w": (x2 - x1) / img_w,
"h": (y2 - y1) / img_h,
}
keypoints = []
if kpts_xyn is not None and i < len(kpts_xyn):
person_kpts = kpts_xyn[i]
for ki, (kx, ky) in enumerate(person_kpts):
kconf = 0.0
if kpts_conf is not None and i < len(kpts_conf) and ki < len(kpts_conf[i]):
kconf = float(kpts_conf[i][ki])
name = KEYPOINT_NAMES[ki] if ki < len(KEYPOINT_NAMES) else str(ki)
keypoints.append({
"name": name,
"x": float(kx),
"y": float(ky),
"conf": kconf,
})
persons.append({
"label": label,
"score": score,
"box": box,
"keypoints": keypoints,
})
print(json.dumps({
"available": True,
"source": model_source,
"modelPath": str(model_path),
"image": str(image_path),
"conf": float(args.conf),
"imgsz": int(args.imgsz),
"device": str(device),
"imageWidth": img_w,
"imageHeight": img_h,
"personCount": len(persons),
"persons": persons,
}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,385 @@
# backend/ml/train_pose_model.py
import argparse
import json
import shutil
from datetime import datetime, timezone
from pathlib import Path
from ultralytics import YOLO
from ultralytics.models.yolo.pose.train import PoseTrainer
BASE_MODEL_NAME = "yolo26n-pose.pt"
def emit_progress(stage, progress, message="", **extra):
out = {
"type": "progress",
"stage": stage,
"progress": max(0.0, min(1.0, float(progress))),
"message": message,
}
out.update(extra)
print(json.dumps(out, ensure_ascii=False), flush=True)
def count_yolo_samples(dataset_root: Path, split: str) -> int:
images_dir = dataset_root / "images" / split
labels_dir = dataset_root / "labels" / split
if not images_dir.exists() or not labels_dir.exists():
return 0
image_exts = {".jpg", ".jpeg", ".png", ".webp"}
count = 0
for image_path in images_dir.iterdir():
if not image_path.is_file():
continue
if image_path.suffix.lower() not in image_exts:
continue
label_path = labels_dir / f"{image_path.stem}.txt"
if label_path.exists() and label_path.is_file():
count += 1
return count
def safe_int(value, fallback):
try:
return int(value)
except Exception:
return fallback
def existing_file(path):
try:
p = Path(path).expanduser().resolve()
if p.exists() and p.is_file() and p.stat().st_size > 0:
return p
except Exception:
pass
return None
def base_model_candidates(root):
script_dir = Path(__file__).resolve().parent
return [
script_dir / BASE_MODEL_NAME,
Path.cwd() / BASE_MODEL_NAME,
root / BASE_MODEL_NAME,
root.parent / BASE_MODEL_NAME,
root.parent.parent / BASE_MODEL_NAME,
]
def resolve_base_model(root, requested):
requested = str(requested or "").strip()
if requested and requested != BASE_MODEL_NAME:
p = existing_file(requested)
if p:
return str(p)
return requested
for candidate in base_model_candidates(root):
p = existing_file(candidate)
if p:
return str(p)
return requested or BASE_MODEL_NAME
def batch_sample_ids(batch):
if not isinstance(batch, dict):
return []
image_paths = batch.get("im_file")
if not image_paths:
return []
if isinstance(image_paths, (str, Path)):
image_paths = [image_paths]
out = []
seen = set()
for image_path in image_paths:
stem = Path(str(image_path)).stem.strip()
if stem and stem not in seen:
seen.add(stem)
out.append(stem)
return out
def progress_pose_trainer(train_count, val_count, train_device, fallback_epochs):
class ProgressPoseTrainer(PoseTrainer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._preview_epoch = 0
self._preview_batch = 0
def preprocess_batch(self, batch):
epoch = int(getattr(self, "epoch", 0)) + 1
total_epochs = int(getattr(self, "epochs", fallback_epochs) or fallback_epochs)
if epoch != self._preview_epoch:
self._preview_epoch = epoch
self._preview_batch = 0
self._preview_batch += 1
sample_ids = batch_sample_ids(batch)
if sample_ids:
total_batches = max(1, len(self.train_loader))
completed = (epoch - 1) + min(1.0, self._preview_batch / total_batches)
progress = 0.04 + 0.90 * (completed / max(1, total_epochs))
emit_progress(
"pose",
progress,
"Pose Detector trainiert…",
epoch=epoch,
epochs=total_epochs,
sampleId=sample_ids[0],
trainSamples=train_count,
valSamples=val_count,
device=str(train_device),
)
return super().preprocess_batch(batch)
return ProgressPoseTrainer
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--root", required=True)
parser.add_argument("--base", default="yolo26n-pose.pt")
parser.add_argument("--epochs", default="80")
parser.add_argument("--imgsz", default="640")
parser.add_argument("--device", default="auto")
parser.add_argument("--workers", default="2")
parser.add_argument("--patience", default="20")
args = parser.parse_args()
import torch
root = Path(args.root).resolve()
dataset_root = root / "pose" / "dataset"
yaml_path = dataset_root / "dataset.yaml"
runs_dir = root / "pose" / "runs"
out_dir = root / "pose" / "model"
epochs = max(1, safe_int(args.epochs, 80))
imgsz = max(64, safe_int(args.imgsz, 640))
workers = max(0, safe_int(args.workers, 2))
patience = max(0, safe_int(args.patience, 20))
if str(args.device).lower() == "auto":
train_device = 0 if torch.cuda.is_available() else "cpu"
else:
train_device = args.device
if not yaml_path.exists():
raise SystemExit(f"dataset.yaml not found: {yaml_path}")
train_count = count_yolo_samples(dataset_root, "train")
val_count = count_yolo_samples(dataset_root, "val")
emit_progress(
"pose",
0.01,
"YOLO-Pose-Dataset wird geprüft…",
trainSamples=train_count,
valSamples=val_count,
epochs=epochs,
imgsz=imgsz,
device=str(train_device),
)
if train_count <= 0:
raise SystemExit("no YOLO pose train samples found")
if val_count <= 0:
raise SystemExit("no YOLO pose val samples found")
emit_progress(
"pose",
0.03,
"YOLO-Pose-Basismodell wird geladen…",
base=resolve_base_model(root, args.base),
device=str(train_device),
)
base_model = resolve_base_model(root, args.base)
model = YOLO(base_model)
best_epoch = 0
last_epoch = 0
best_map50 = 0.0
best_map5095 = 0.0
def on_train_epoch_start(trainer):
epoch = int(getattr(trainer, "epoch", 0)) + 1
total = int(getattr(trainer, "epochs", epochs) or epochs)
emit_progress(
"pose",
0.04 + 0.90 * ((epoch - 1) / max(1, total)),
"Pose Detector trainiert…",
epoch=epoch,
epochs=total,
trainSamples=train_count,
valSamples=val_count,
device=str(train_device),
)
def on_train_epoch_end(trainer):
nonlocal last_epoch
epoch = int(getattr(trainer, "epoch", 0)) + 1
total = int(getattr(trainer, "epochs", epochs) or epochs)
last_epoch = max(last_epoch, epoch)
emit_progress(
"pose",
0.04 + 0.90 * (epoch / max(1, total)),
"Pose Detector trainiert…",
epoch=epoch,
epochs=total,
trainSamples=train_count,
valSamples=val_count,
device=str(train_device),
)
def on_fit_epoch_end(trainer):
nonlocal best_epoch, best_map50, best_map5095
epoch = int(getattr(trainer, "epoch", 0)) + 1
total = int(getattr(trainer, "epochs", epochs) or epochs)
metrics = getattr(trainer, "metrics", None) or {}
map50 = (
metrics.get("metrics/mAP50(B)")
or metrics.get("metrics/mAP50")
or metrics.get("mAP50")
)
map5095 = (
metrics.get("metrics/mAP50-95(B)")
or metrics.get("metrics/mAP50-95")
or metrics.get("mAP50-95")
)
if map50 is not None or map5095 is not None:
best_epoch = epoch
m50 = float(map50) if map50 is not None else 0.0
if m50 >= best_map50:
best_map50 = m50
if map5095 is not None:
best_map5095 = float(map5095)
emit_progress(
"pose",
0.04 + 0.90 * (epoch / max(1, total)),
"Pose Detector validiert…",
epoch=epoch,
epochs=total,
mAP50=map50,
mAP5095=map5095,
device=str(train_device),
)
model.add_callback("on_train_epoch_start", on_train_epoch_start)
model.add_callback("on_train_epoch_end", on_train_epoch_end)
model.add_callback("on_fit_epoch_end", on_fit_epoch_end)
emit_progress(
"pose",
0.05,
"Pose Detector Training startet…",
trainSamples=train_count,
valSamples=val_count,
epochs=epochs,
imgsz=imgsz,
device=str(train_device),
)
result = model.train(
trainer=progress_pose_trainer(
train_count,
val_count,
train_device,
epochs,
),
data=str(yaml_path),
epochs=epochs,
imgsz=imgsz,
project=str(runs_dir),
name="pose",
exist_ok=True,
device=train_device,
workers=workers,
patience=patience,
)
emit_progress(
"pose",
0.96,
"Bestes YOLO-Pose-Modell wird übernommen…",
lastEpoch=last_epoch,
bestEpoch=best_epoch,
device=str(train_device),
)
best = runs_dir / "pose" / "weights" / "best.pt"
last = runs_dir / "pose" / "weights" / "last.pt"
if not best.exists():
if last.exists():
best = last
else:
raise SystemExit(f"best.pt not found after training: {best}")
out_dir.mkdir(parents=True, exist_ok=True)
final_model = out_dir / "best.pt"
shutil.copy2(best, final_model)
result_path = runs_dir / "pose"
status = {
"ok": True,
"model": str(final_model),
"sourceModel": str(best),
"runs": str(result_path),
"trainedAt": datetime.now(timezone.utc).isoformat(),
"trainSamples": train_count,
"valSamples": val_count,
"epochs": epochs,
"imgsz": imgsz,
"device": str(train_device),
"mAP50": round(best_map50, 4),
"mAP5095": round(best_map5095, 4),
"bestEpoch": best_epoch,
}
with (out_dir / "status.json").open("w", encoding="utf-8") as f:
json.dump(status, f, ensure_ascii=False, indent=2)
emit_progress(
"pose",
1.0,
"Pose Detector fertig.",
**status,
)
print(json.dumps(status, ensure_ascii=False), flush=True)
if __name__ == "__main__":
main()

View File

@ -121,10 +121,12 @@ func isPersonSegmentLabel(label string) bool {
func isKnownPositionLabel(label string) bool {
label = strings.ToLower(strings.TrimSpace(label))
if isNoSexPositionLabel(label) {
return false
}
switch label {
case "unknown",
"missionary",
case "missionary",
"doggy",
"doggystyle",
"cowgirl",
@ -306,7 +308,7 @@ type ratingSignalSet struct {
func normalizeRatingSignalLabel(label string) string {
label = strings.ToLower(strings.TrimSpace(label))
if label == "" || label == "unknown" {
if isNoSexPositionLabel(label) {
return ""
}
@ -359,7 +361,7 @@ func normalizeRatingSignalLabel(label string) string {
}
raw := normalizeSegmentLabel(label)
if raw == "" || raw == "unknown" {
if isNoSexPositionLabel(raw) {
return ""
}

View File

@ -1540,13 +1540,21 @@ func recordStop(w http.ResponseWriter, r *http.Request) {
jobsMu.RLock()
job, ok := jobs[id]
shouldStop := ok &&
job != nil &&
job.EndedAt == nil &&
!isTerminalJobStatus(job.Status) &&
!isPostworkJob(job)
jobsMu.RUnlock()
if !ok {
http.Error(w, "job nicht gefunden", http.StatusNotFound)
return
}
stopJobsInternal([]*RecordJob{job})
if shouldStop {
stopJobsInternal([]*RecordJob{job})
}
respondJSON(w, job)
}

View File

@ -1206,6 +1206,13 @@ func recordHLSPlaylistSegments(
shouldFinalize := parentCanceled || endOfStream || finalErr == nil
if parentCanceled {
if verboseLogs() {
appLogln("⏹️", logPrefix, "stop requested; deferring audio/video mux to postwork")
}
return ctx.Err()
}
if shouldFinalize {
if !fileExistsNonEmpty(outFile) {
if finalErr != nil {

View File

@ -63,7 +63,11 @@ func RecordStreamMFC(
return appErrorf("mfc status blieb %s für %s", lastStatus, username)
}
time.Sleep(5 * time.Second)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(5 * time.Second):
}
}
if job != nil {

View File

@ -354,8 +354,6 @@ func waitForUsableOutput(path string, timeout time.Duration) (os.FileInfo, error
lastErr = appErrorf("stat returned nil")
} else if fi.IsDir() {
lastErr = appErrorf("path is dir")
} else {
lastErr = appErrorf("file size is 0")
}
if time.Now().After(deadline) {
@ -411,8 +409,6 @@ func waitForStableUsableOutput(path string, timeout time.Duration, stableFor tim
lastErr = appErrorf("stat returned nil")
} else if fi.IsDir() {
lastErr = appErrorf("path is dir")
} else {
lastErr = appErrorf("file size is 0")
}
}

View File

@ -112,6 +112,7 @@ var (
aiServerMu sync.Mutex
aiServerCurrent *aiServerProcess
aiServerCtx context.Context
aiServerRoot string
)
func aiServerAutostartEnabled() bool {
@ -349,13 +350,17 @@ func findTrainingRootDir(scriptDir string) (trainingRoot string, appBaseDir stri
// Wichtig für Entwicklung:
// findAIServerScriptDir() findet meistens dein backend-Verzeichnis.
add(scriptDir)
if scriptDir != "" && !isTempBuildDir(scriptDir) {
add(scriptDir)
}
// Falls du aus repo-root oder backend startest.
add(cwd)
// Wichtig für gebaute EXE.
add(exeDir)
if exeDir != "" && !isTempBuildDir(exeDir) {
add(exeDir)
}
// Bevorzugt den Pfad, wo detection_labels.json liegt.
for _, c := range candidates {
@ -472,6 +477,7 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
}
trainingRoot, appBaseDir := findTrainingRootDir(scriptDir)
aiServerRoot = trainingRoot
mlDir, mlErr := trainingEmbeddedMLDir()
if mlErr != nil {
@ -493,7 +499,8 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
detectionLabelsPath = filepath.Clean(detectionLabelsPath)
}
defaultModelPath := filepath.Join(trainingRoot, "detector", "model", "best.pt")
defaultModel := trainingResolveDetectorModel(trainingRoot)
defaultModelPath := defaultModel.EffectivePath
pythonPath := aiServerPythonPath()
port := aiServerPortFromURL()
@ -550,7 +557,7 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
if fi, err := os.Stat(defaultModelPath); err != nil || fi == nil || fi.IsDir() {
appLogln("⚠️ YOLO Modell nicht gefunden:", defaultModelPath)
} else {
appLogln("✅ YOLO Modell:", defaultModelPath)
appLogln("✅ YOLO Modell:", defaultModelPath, "Quelle:", defaultModel.Source)
}
if strings.TrimSpace(os.Getenv("YOLO_IMGSZ")) == "" {
@ -647,8 +654,10 @@ func restartAIServer() {
return
}
appLogln("🔄 AI Server wird neu gestartet…")
publishAppNotification("info", "AI Server", "AI Server wird neu gestartet…", 2200)
if aiServerCurrent != nil {
appLogln("🔄 AI Server wird neu gestartet…")
aiServerCurrent.Stop()
aiServerCurrent = nil
}
@ -656,6 +665,7 @@ func restartAIServer() {
proc, err := startAIServer(ctx)
if err != nil {
appLogln("⚠️ AI Server Neustart fehlgeschlagen:", err)
publishAppNotification("warning", "AI Server", "Neustart fehlgeschlagen: "+err.Error(), 5000)
return
}
@ -663,6 +673,97 @@ func restartAIServer() {
if proc != nil {
appLogln("✅ AI Server neu gestartet.")
publishAppNotification("success", "AI Server", "AI Server wurde neu gestartet.", 2600)
}
}
type trainingBestModelSnapshot struct {
exists bool
size int64
modTime time.Time
}
func snapshotTrainingBestModel(path string) trainingBestModelSnapshot {
fi, err := os.Stat(path)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return trainingBestModelSnapshot{}
}
return trainingBestModelSnapshot{
exists: true,
size: fi.Size(),
modTime: fi.ModTime(),
}
}
func (s trainingBestModelSnapshot) changed(next trainingBestModelSnapshot) bool {
if s.exists != next.exists {
return true
}
if !s.exists {
return false
}
return s.size != next.size || !s.modTime.Equal(next.modTime)
}
func startTrainingBestModelWatcher(ctx context.Context, trainingRoot string) {
trainingRoot = filepath.Clean(strings.TrimSpace(trainingRoot))
if trainingRoot == "" {
return
}
paths := []string{
filepath.Join(trainingRoot, "detector", "model", "best.pt"),
filepath.Join(trainingRoot, "pose", "model", "best.pt"),
}
snapshots := make(map[string]trainingBestModelSnapshot, len(paths))
for _, path := range paths {
snapshots[path] = snapshotTrainingBestModel(path)
}
appLogln("👁️ Training-Model-Watcher aktiv:", strings.Join(paths, " | "))
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
var lastRestart time.Time
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
changedPaths := []string{}
for _, path := range paths {
previous := snapshots[path]
next := snapshotTrainingBestModel(path)
if previous.changed(next) {
snapshots[path] = next
if next.exists {
changedPaths = append(changedPaths, path)
}
}
}
if len(changedPaths) == 0 {
continue
}
if time.Since(lastRestart) < 5*time.Second {
continue
}
lastRestart = time.Now()
appLogln("🔄 Training-Modell geändert:", strings.Join(changedPaths, " | "))
go restartAIServer()
}
}
}
@ -864,8 +965,11 @@ func main() {
aiServerMu.Lock()
aiServerCtx = appCtx
aiServerCurrent = aiProc
watchedTrainingRoot := aiServerRoot
aiServerMu.Unlock()
go startTrainingBestModelWatcher(appCtx, watchedTrainingRoot)
// ✅ Hier: alte manuelle Autostart-Pause beim echten App-Start zurücksetzen.
resetAutostartPauseOnStartup()

View File

@ -23,25 +23,27 @@ type appSSE struct {
var sseApp *appSSE
type jobEvent struct {
Type string `json:"type"`
Model string `json:"model"`
JobID string `json:"jobId"`
Status JobStatus `json:"status"`
Phase string `json:"phase,omitempty"`
Progress int `json:"progress,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
Output string `json:"output,omitempty"`
StartedAt string `json:"startedAt,omitempty"`
StartedAtMs int64 `json:"startedAtMs,omitempty"`
EndedAt string `json:"endedAt,omitempty"`
EndedAtMs int64 `json:"endedAtMs,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
DurationSeconds float64 `json:"durationSeconds,omitempty"`
PreviewState string `json:"previewState,omitempty"`
RoomStatus string `json:"roomStatus,omitempty"`
IsOnline bool `json:"isOnline,omitempty"`
ModelImageURL string `json:"modelImageUrl,omitempty"`
ModelChatRoomURL string `json:"modelChatRoomUrl,omitempty"`
Type string `json:"type"`
Model string `json:"model"`
JobID string `json:"jobId"`
Status JobStatus `json:"status"`
Phase string `json:"phase,omitempty"`
Progress int `json:"progress,omitempty"`
StopRequestedAtMs int64 `json:"stopRequestedAtMs,omitempty"`
StopAttempts int `json:"stopAttempts,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
Output string `json:"output,omitempty"`
StartedAt string `json:"startedAt,omitempty"`
StartedAtMs int64 `json:"startedAtMs,omitempty"`
EndedAt string `json:"endedAt,omitempty"`
EndedAtMs int64 `json:"endedAtMs,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
DurationSeconds float64 `json:"durationSeconds,omitempty"`
PreviewState string `json:"previewState,omitempty"`
RoomStatus string `json:"roomStatus,omitempty"`
IsOnline bool `json:"isOnline,omitempty"`
ModelImageURL string `json:"modelImageUrl,omitempty"`
ModelChatRoomURL string `json:"modelChatRoomUrl,omitempty"`
PostWorkKey string `json:"postWorkKey,omitempty"`
PostWork any `json:"postWork,omitempty"`
@ -82,6 +84,42 @@ type taskStateEvent struct {
TS int64 `json:"ts"`
}
type appNotificationEvent struct {
Type string `json:"type"` // "notification"
Level string `json:"level"`
Title string `json:"title"`
Message string `json:"message,omitempty"`
DurationMs int `json:"durationMs,omitempty"`
TS int64 `json:"ts"`
}
func publishAppNotification(level string, title string, message string, durationMs int) {
level = strings.TrimSpace(strings.ToLower(level))
if level == "" {
level = "info"
}
ev := appNotificationEvent{
Type: "notification",
Level: level,
Title: strings.TrimSpace(title),
Message: strings.TrimSpace(message),
DurationMs: durationMs,
TS: time.Now().UnixMilli(),
}
if ev.Title == "" {
return
}
b, err := json.Marshal(ev)
if err != nil {
return
}
publishSSE("notification", b)
}
type analysisProgressEvent struct {
Type string `json:"type"`
Scope string `json:"scope,omitempty"`
@ -413,22 +451,24 @@ func visibleJobEventsJSON() []ssePublishItem {
}
payload := jobEvent{
Type: "job_upsert",
Model: eventName,
JobID: j.ID,
Status: j.Status,
Phase: j.Phase,
Progress: j.Progress,
SourceURL: j.SourceURL,
Output: j.Output,
StartedAt: j.StartedAt.Format(time.RFC3339Nano),
StartedAtMs: j.StartedAtMs,
SizeBytes: j.SizeBytes,
DurationSeconds: j.DurationSeconds,
PreviewState: j.PreviewState,
PostWorkKey: strings.TrimSpace(j.PostWorkKey),
PostWork: j.PostWork,
TS: 0, // wichtig: stabil halten, damit Dedupe funktioniert
Type: "job_upsert",
Model: eventName,
JobID: j.ID,
Status: j.Status,
Phase: j.Phase,
Progress: j.Progress,
StopRequestedAtMs: j.StopRequestedAtMs,
StopAttempts: j.StopAttempts,
SourceURL: j.SourceURL,
Output: j.Output,
StartedAt: j.StartedAt.Format(time.RFC3339Nano),
StartedAtMs: j.StartedAtMs,
SizeBytes: j.SizeBytes,
DurationSeconds: j.DurationSeconds,
PreviewState: j.PreviewState,
PostWorkKey: strings.TrimSpace(j.PostWorkKey),
PostWork: j.PostWork,
TS: 0, // wichtig: stabil halten, damit Dedupe funktioniert
}
if sm := sseStoredModelForJob(j); sm != nil {
@ -478,6 +518,69 @@ func publishJobRemove(j *RecordJob) {
publishSSE(eventName, b)
}
func publishModelJobUpsert(jobID string) bool {
jobsMu.RLock()
j := jobs[jobID]
if j == nil || j.Hidden {
jobsMu.RUnlock()
return false
}
eventName := sseModelEventNameForJob(j)
if eventName == "" {
jobsMu.RUnlock()
return false
}
var postWork any
if j.PostWork != nil {
tmp := *j.PostWork
postWork = &tmp
}
payload := jobEvent{
Type: "job_upsert",
Model: eventName,
JobID: j.ID,
Status: j.Status,
Phase: j.Phase,
Progress: j.Progress,
StopRequestedAtMs: j.StopRequestedAtMs,
StopAttempts: j.StopAttempts,
SourceURL: j.SourceURL,
Output: j.Output,
StartedAt: j.StartedAt.Format(time.RFC3339Nano),
StartedAtMs: j.StartedAtMs,
SizeBytes: j.SizeBytes,
DurationSeconds: j.DurationSeconds,
PreviewState: j.PreviewState,
PostWorkKey: strings.TrimSpace(j.PostWorkKey),
PostWork: postWork,
TS: time.Now().UnixMilli(),
}
if sm := sseStoredModelForJob(j); sm != nil {
payload.RoomStatus = strings.ToLower(strings.TrimSpace(sm.RoomStatus))
payload.IsOnline = sm.IsOnline
payload.ModelImageURL = strings.TrimSpace(sm.ImageURL)
payload.ModelChatRoomURL = strings.TrimSpace(sm.ChatRoomURL)
}
if j.EndedAt != nil {
payload.EndedAt = j.EndedAt.Format(time.RFC3339Nano)
payload.EndedAtMs = j.EndedAtMs
}
jobsMu.RUnlock()
b, err := json.Marshal(payload)
if err != nil {
return false
}
publishSSE(eventName, b)
return true
}
func sseModelEventNameForJob(j *RecordJob) string {
if j == nil {
return ""

File diff suppressed because it is too large Load Diff

View File

@ -18,6 +18,56 @@ type TrainingGroupedLabels struct {
Clothing []string `json:"clothing"`
}
const trainingNoSexPositionLabel = "keine"
func isNoSexPositionLabel(label string) bool {
switch strings.ToLower(strings.TrimSpace(label)) {
case "", trainingNoSexPositionLabel:
return true
default:
return false
}
}
func normalizeSexPositionLabel(label string) string {
clean := strings.TrimSpace(label)
if isNoSexPositionLabel(clean) {
return trainingNoSexPositionLabel
}
return clean
}
func normalizeSexPositionLabels(values []string) []string {
seen := map[string]bool{}
out := []string{}
hasNoPosition := false
for _, value := range values {
label := strings.TrimSpace(value)
if label == "" {
continue
}
if isNoSexPositionLabel(label) {
hasNoPosition = true
continue
}
if seen[label] {
continue
}
seen[label] = true
out = append(out, label)
}
if hasNoPosition {
out = append([]string{trainingNoSexPositionLabel}, out...)
}
return out
}
func trainingDetectionLabelsPath() string {
if p, err := ensureTrainingDetectionLabelsFile(); err == nil {
return p
@ -114,13 +164,13 @@ func trainingGroupedLabels() (TrainingGroupedLabels, error) {
}
grouped.People = uniqueNonEmptyLabels(grouped.People)
grouped.SexPositions = uniqueNonEmptyLabels(grouped.SexPositions)
grouped.SexPositions = normalizeSexPositionLabels(grouped.SexPositions)
grouped.BodyParts = uniqueNonEmptyLabels(grouped.BodyParts)
grouped.Objects = uniqueNonEmptyLabels(grouped.Objects)
grouped.Clothing = uniqueNonEmptyLabels(grouped.Clothing)
if len(grouped.SexPositions) == 0 {
grouped.SexPositions = []string{"unknown"}
grouped.SexPositions = []string{trainingNoSexPositionLabel}
}
if len(grouped.People)+len(grouped.SexPositions)+len(grouped.BodyParts)+len(grouped.Objects)+len(grouped.Clothing) == 0 {
@ -146,7 +196,7 @@ func trainingDetectorLabels() ([]string, error) {
for _, label := range grouped.SexPositions {
clean := strings.TrimSpace(label)
if clean == "" || clean == "unknown" {
if isNoSexPositionLabel(clean) {
continue
}
@ -205,11 +255,65 @@ func trainingDetectorDatasetNamesYAML() (string, error) {
return b.String(), nil
}
func trainingPoseLabels() ([]string, error) {
grouped, err := trainingGroupedLabels()
if err != nil {
return nil, err
}
labels := []string{}
seen := map[string]bool{}
for _, value := range grouped.SexPositions {
label := strings.TrimSpace(value)
if isNoSexPositionLabel(label) || seen[label] {
continue
}
seen[label] = true
labels = append(labels, label)
}
if len(labels) == 0 {
return nil, fmt.Errorf("no pose sex position labels configured")
}
return labels, nil
}
func trainingPoseClassMap() (map[string]int, error) {
labels, err := trainingPoseLabels()
if err != nil {
return nil, err
}
out := map[string]int{}
for i, label := range labels {
out[label] = i
}
return out, nil
}
func trainingPoseDatasetNamesYAML() (string, error) {
labels, err := trainingPoseLabels()
if err != nil {
return "", err
}
var b strings.Builder
for i, label := range labels {
b.WriteString(fmt.Sprintf(" %d: %s\n", i, label))
}
return b.String(), nil
}
func defaultTrainingLabelsFromJSON() TrainingLabels {
grouped, err := trainingGroupedLabels()
if err != nil {
grouped = TrainingGroupedLabels{
SexPositions: []string{"unknown"},
SexPositions: []string{trainingNoSexPositionLabel},
}
}

View File

@ -124,8 +124,8 @@ func TestTrainingEffectiveCorrectionClearsNegativeAnnotation(t *testing.T) {
},
})
if effective.SexPosition != "unknown" {
t.Fatalf("sex position = %q, want unknown", effective.SexPosition)
if effective.SexPosition != trainingNoSexPositionLabel {
t.Fatalf("sex position = %q, want %s", effective.SexPosition, trainingNoSexPositionLabel)
}
if len(effective.Boxes) != 0 {
t.Fatalf("negative annotation has %d boxes, want 0", len(effective.Boxes))

BIN
backend/yolo26n-pose.pt Normal file

Binary file not shown.

Binary file not shown.

View File

@ -2956,6 +2956,40 @@ export default function App() {
}
}
const onNotification = (ev: MessageEvent) => {
try {
const data = JSON.parse(String(ev.data ?? 'null'))
if (data?.type !== 'notification') return
const title = String(data?.title ?? '').trim()
if (!title) return
const message = String(data?.message ?? '').trim() || undefined
const durationRaw = Number(data?.durationMs ?? 0)
const opts = Number.isFinite(durationRaw) && durationRaw > 0
? { durationMs: durationRaw }
: undefined
switch (String(data?.level ?? 'info').trim().toLowerCase()) {
case 'success':
notifyRef.current?.success(title, message, opts)
break
case 'warning':
notifyRef.current?.warning(title, message, opts)
break
case 'error':
notifyRef.current?.error(title, message, opts)
break
default:
notifyRef.current?.info(title, message, opts)
break
}
} catch {
// ignore
}
}
void loadJobs()
void loadDoneCount()
void loadTaskStateOnce()
@ -2981,6 +3015,7 @@ export default function App() {
es.addEventListener('taskState', onTaskState as any)
es.addEventListener('training', onTraining as any)
es.addEventListener('analysisProgress', onAnalysisProgress as any)
es.addEventListener('notification', onNotification as any)
const onVis = () => {
if (document.hidden) return
@ -3008,6 +3043,7 @@ export default function App() {
es.removeEventListener('taskState', onTaskState as any)
es.removeEventListener('training', onTraining as any)
es.removeEventListener('analysisProgress', onAnalysisProgress as any)
es.removeEventListener('notification', onNotification as any)
const handler = onModelJobEventRef.current
if (handler) {
@ -3110,6 +3146,7 @@ export default function App() {
async function stopJob(id: string) {
try {
await apiJSON(`/api/record/stop?id=${encodeURIComponent(id)}`, { method: 'POST' })
void loadJobs()
} catch (e: any) {
notify.error('Stop fehlgeschlagen', e?.message ?? String(e))
}
@ -3118,6 +3155,7 @@ export default function App() {
async function stopAllJobs() {
try {
await apiJSON('/api/record/stop-all', { method: 'POST' })
void loadJobs()
} catch (e: any) {
notify.error('Alle stoppen fehlgeschlagen', e?.message ?? String(e))
}
@ -3887,6 +3925,7 @@ export default function App() {
}, [autoAddEnabled, autoStartEnabled, startUrl])
const isTrainingTab = selectedTab === 'training'
const [trainingTabMounted, setTrainingTabMounted] = useState(() => isTrainingTab)
useEffect(() => {
if (!isTrainingTab) {
@ -3894,6 +3933,12 @@ export default function App() {
}
}, [isTrainingTab])
useEffect(() => {
if (isTrainingTab) {
setTrainingTabMounted(true)
}
}, [isTrainingTab])
if (!authChecked) {
return <div className="min-h-[100dvh] grid place-items-center">Lade</div>
}
@ -4236,11 +4281,17 @@ export default function App() {
<ModelsTab onAddToDownloads={handleAddToDownloads} />
) : null}
{selectedTab === 'training' ? (
<TrainingTab
onTrainingRunningChange={setTrainingTabRunning}
onImageExpandedChange={setTrainingImageExpanded}
/>
{trainingTabMounted ? (
<div
className={isTrainingTab ? undefined : 'hidden'}
aria-hidden={isTrainingTab ? undefined : true}
>
<TrainingTab
active={isTrainingTab}
onTrainingRunningChange={setTrainingTabRunning}
onImageExpandedChange={setTrainingImageExpanded}
/>
</div>
) : null}
{selectedTab === 'categories' ? <CategoriesTab /> : null}

View File

@ -8,8 +8,10 @@ export type AiLabelGroup =
| 'clothing'
| 'unknown'
const NO_POSITION_LABELS = new Set(['', 'keine'])
export const AI_POSITION_LABELS = [
'unknown',
'keine',
'missionary',
'doggy',
'doggystyle',
@ -109,7 +111,7 @@ export const AI_LABEL_FALLBACKS: Record<string, string> = {
stockings: 'Strümpfe',
croptop: 'Crop Top',
unknown: 'Unbekannt',
keine: 'Keine',
missionary: 'Missionary',
doggy: 'Doggy',
doggystyle: 'Doggy',
@ -237,7 +239,7 @@ export function prettyAiLabel(value: unknown): string {
export function aiLabelGroup(value: unknown): AiLabelGroup {
const label = normalizeAiLabel(value)
if (!label || label === 'unknown') return 'unknown'
if (NO_POSITION_LABELS.has(label)) return 'unknown'
if (PERSON_SET.has(label)) return 'people'
if (POSITION_SET.has(label)) return 'position'
if (BODY_SET.has(label)) return 'body'
@ -248,7 +250,8 @@ export function aiLabelGroup(value: unknown): AiLabelGroup {
}
export function isKnownFrontendPositionLabel(value: unknown): boolean {
return POSITION_SET.has(normalizeAiLabel(value))
const label = normalizeAiLabel(value)
return !NO_POSITION_LABELS.has(label) && POSITION_SET.has(label)
}
export function isKnownAiContentLabel(value: unknown): boolean {

View File

@ -308,7 +308,7 @@ const addedAtMsOf = (r: DownloadRow): number => {
const phaseLabel = (p?: string) => {
switch ((p ?? '').toLowerCase()) {
case 'stopping':
return 'Stop wird angefordert…'
return 'Stoppe Aufnahme…'
case 'probe':
return 'Analysiere Datei (Dauer/Streams)…'
case 'remuxing':
@ -326,6 +326,45 @@ const phaseLabel = (p?: string) => {
}
}
const STOP_RETRY_AFTER_MS = 15_000
const stopRequestedAtMsOf = (job: RecordJob): number => {
return toMs((job as any)?.stopRequestedAtMs)
}
const isFreshStoppingPhase = (job: RecordJob, nowMs: number): boolean => {
const phase = String((job as any)?.phase ?? '').trim().toLowerCase()
if (phase !== 'stopping') return false
const startedAt = stopRequestedAtMsOf(job)
return startedAt > 0 && Math.max(0, nowMs - startedAt) < STOP_RETRY_AFTER_MS
}
const canRetryStoppingJob = (job: RecordJob, nowMs: number): boolean => {
const phase = String((job as any)?.phase ?? '').trim().toLowerCase()
const status = String((job as any)?.status ?? '').trim().toLowerCase()
return phase === 'stopping' && status === 'running' && !isFreshStoppingPhase(job, nowMs)
}
const isStopWaitingForJob = (
job: RecordJob,
localStopRequested: boolean,
nowMs: number
): boolean => {
return localStopRequested || isFreshStoppingPhase(job, nowMs)
}
const isStoppingForUi = (
job: RecordJob,
localStopRequested: boolean,
nowMs: number
): boolean => {
const phase = String((job as any)?.phase ?? '').trim().toLowerCase()
const status = String((job as any)?.status ?? '').trim().toLowerCase()
const isBusyPhase = phase !== '' && phase !== 'recording' && phase !== 'stopping'
return isBusyPhase || status !== 'running' || isStopWaitingForJob(job, localStopRequested, nowMs)
}
function postWorkLabel(
job: RecordJob,
override?: { pos?: number; total?: number }
@ -370,9 +409,11 @@ function postWorkLabel(
function StatusCell({
job,
postworkInfo,
nowMs,
}: {
job: RecordJob
postworkInfo?: { pos?: number; total?: number }
nowMs: number
}) {
const anyJ = job as any
const phaseRaw = String(anyJ?.phase ?? '').trim()
@ -398,7 +439,11 @@ function StatusCell({
}
if (!text) {
text = phaseRaw ? (phaseLabel(phaseRaw) || phaseRaw) : String(anyJ?.status ?? '').trim().toLowerCase()
if (phase === 'stopping' && canRetryStoppingJob(job, nowMs)) {
text = 'Stop dauert zu lange - erneut stoppen'
} else {
text = phaseRaw ? (phaseLabel(phaseRaw) || phaseRaw) : String(anyJ?.status ?? '').trim().toLowerCase()
}
}
const showBar =
@ -720,30 +765,59 @@ export default function Downloads({
const [stopRequestedIds, setStopRequestedIds] = useState<Record<string, true>>({})
const [stopInitiatedIds, setStopInitiatedIds] = useState<Record<string, true>>({})
const stopRequestClearTimersRef = useRef<Record<string, number>>({})
const [removePendingRequestedKeys, setRemovePendingRequestedKeys] = useState<Record<string, true>>({})
const [removeQueuedPostworkRequestedIds, setRemoveQueuedPostworkRequestedIds] = useState<Record<string, true>>({})
const markStopRequested = useCallback((ids: string | string[]) => {
const arr = Array.isArray(ids) ? ids : [ids]
const cleanIds = arr.map((id) => String(id || '').trim()).filter(Boolean)
for (const id of cleanIds) {
const oldTimer = stopRequestClearTimersRef.current[id]
if (oldTimer) {
window.clearTimeout(oldTimer)
}
stopRequestClearTimersRef.current[id] = window.setTimeout(() => {
delete stopRequestClearTimersRef.current[id]
setStopRequestedIds((prev) => {
if (!prev[id]) return prev
const next = { ...prev }
delete next[id]
return next
})
}, 12_000)
}
setStopRequestedIds((prev) => {
const next = { ...prev }
for (const id of arr) {
if (id) next[id] = true
for (const id of cleanIds) {
next[id] = true
}
return next
})
setStopInitiatedIds((prev) => {
const next = { ...prev }
for (const id of arr) {
if (id) next[id] = true
for (const id of cleanIds) {
next[id] = true
}
return next
})
}, [])
useEffect(() => {
return () => {
for (const timer of Object.values(stopRequestClearTimersRef.current)) {
window.clearTimeout(timer)
}
stopRequestClearTimersRef.current = {}
}
}, [])
const markRemovePendingRequested = useCallback((key: string) => {
if (!key) return
setRemovePendingRequestedKeys((prev) => ({ ...prev, [key]: true }))
@ -791,7 +865,10 @@ export default function Downloads({
}
const phaseLower = String((j as any).phase ?? '').trim().toLowerCase()
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
const isBusyPhase =
phaseLower !== '' &&
phaseLower !== 'recording' &&
phaseLower !== 'stopping'
const isStopping = isBusyPhase || j.status !== 'running'
if (isStopping) {
@ -994,15 +1071,12 @@ export default function Downloads({
if (isPostworkJob(j)) return false
if ((j as any).endedAt) return false
const phase = String((j as any).phase ?? '').trim()
const isStopRequested = Boolean(stopRequestedIds[j.id])
const phaseLower = phase.trim().toLowerCase()
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
const isStopping = isStoppingForUi(j, isStopRequested, nowMs)
return !isStopping
})
.map((j) => j.id)
}, [jobs, stopRequestedIds])
}, [jobs, stopRequestedIds, nowMs])
const columns = useMemo<Column<DownloadRow>[]>(() => {
return [
@ -1187,7 +1261,7 @@ export default function Downloads({
cell: (r) => {
if (r.kind === 'job') {
const j = r.job
return <StatusCell job={j} postworkInfo={postworkInfoOf(j)} />
return <StatusCell job={j} postworkInfo={postworkInfoOf(j)} nowMs={nowMs} />
}
const p = r.pending
@ -1308,16 +1382,14 @@ export default function Downloads({
}
const j = r.job
const phase = String((j as any).phase ?? '').trim()
const isStopRequested = Boolean(stopRequestedIds[j.id])
const phaseLower = phase.trim().toLowerCase()
const postworkState = getEffectivePostworkState(j)
const isQueuedPostwork = postworkState === 'queued'
const isRemovingQueuedPostwork = Boolean(removeQueuedPostworkRequestedIds[j.id])
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
const canRetryStop = canRetryStoppingJob(j, nowMs) && !isStopRequested
const isStopping = isStoppingForUi(j, isStopRequested, nowMs)
const buttonBusy = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping
const disableStopButton = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping
@ -1375,7 +1447,9 @@ export default function Downloads({
>
{isQueuedPostwork
? (isRemovingQueuedPostwork ? 'Entferne…' : 'Entfernen')
: (isStopping ? 'Stoppe…' : 'Stoppen')}
: canRetryStop
? 'Stop erneut'
: (isStopping ? 'Stoppe…' : 'Stoppen')}
</Button>
</div>
)
@ -1519,15 +1593,16 @@ export default function Downloads({
const stopRequested = Boolean(stopRequestedIds[j.id])
const removingQueued = Boolean(removeQueuedPostworkRequestedIds[j.id])
const isQueuedPostwork = getEffectivePostworkState(j) === 'queued'
const stopWaiting = isStopWaitingForJob(j, stopRequested, nowMs)
return [
'transition-all duration-300',
stopRequested && !isQueuedPostwork && 'bg-indigo-50/70 dark:bg-indigo-500/10 pointer-events-none animate-pulse',
stopWaiting && !isQueuedPostwork && 'bg-indigo-50/70 dark:bg-indigo-500/10 pointer-events-none animate-pulse',
removingQueued && isQueuedPostwork && 'bg-red-50/70 dark:bg-red-500/10 pointer-events-none animate-pulse',
]
.filter(Boolean)
.join(' ')
}, [removePendingRequestedKeys, stopRequestedIds, removeQueuedPostworkRequestedIds])
}, [removePendingRequestedKeys, stopRequestedIds, removeQueuedPostworkRequestedIds, nowMs])
return (
<div className="grid gap-2">
@ -1707,17 +1782,16 @@ export default function Downloads({
if (r.kind !== 'job') return null
const j = r.job
const phase = String((j as any).phase ?? '').trim().toLowerCase()
const isStopRequested = Boolean(stopRequestedIds[j.id])
const postworkState = getEffectivePostworkState(j)
const isQueuedPostwork = postworkState === 'queued'
const isBusyPhase = phase !== '' && phase !== 'recording'
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
const canRetryStop = canRetryStoppingJob(j, nowMs) && !isStopRequested
const isStopping = isStoppingForUi(j, isStopRequested, nowMs)
return (
<SwipeActionRow
key={`dl:${j.id}`}
actionLabel={isStopping ? 'Stoppe…' : 'Stoppen'}
actionLabel={canRetryStop ? 'Stop erneut' : isStopping ? 'Stoppe…' : 'Stoppen'}
actionColor="indigo"
disabled={isStopping || isQueuedPostwork}
onAction={async () => {
@ -1822,20 +1896,21 @@ export default function Downloads({
if (r.kind !== 'job') return null
const j = r.job
const phase = String((j as any).phase ?? '').trim().toLowerCase()
const isStopRequested = Boolean(stopRequestedIds[j.id])
const postworkState = getEffectivePostworkState(j)
const isQueuedPostwork = postworkState === 'queued'
const isBusyPhase = phase !== '' && phase !== 'recording'
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
const canRetryStop = canRetryStoppingJob(j, nowMs) && !isStopRequested
const isStopping = isStoppingForUi(j, isStopRequested, nowMs)
const isRemovingQueuedPostwork = Boolean(removeQueuedPostworkRequestedIds[j.id])
const label = isQueuedPostwork
? (isRemovingQueuedPostwork ? 'Entferne…' : 'Entfernen')
: (isStopping ? 'Stoppe…' : 'Stoppen')
: canRetryStop
? 'Stop erneut'
: (isStopping ? 'Stoppe…' : 'Stoppen')
const color = isQueuedPostwork ? 'red' : 'indigo'
const disabled = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping
@ -1979,4 +2054,4 @@ export default function Downloads({
)}
</div>
)
}
}

View File

@ -320,7 +320,7 @@ const formatBytes = (bytes: number | null): string => {
const phaseLabel = (p?: string) => {
switch ((p ?? '').toLowerCase()) {
case 'stopping':
return 'Stop wird angefordert…'
return 'Stoppe Aufnahme…'
case 'probe':
return 'Analysiere Datei (Dauer/Streams)…'
case 'remuxing':
@ -338,6 +338,45 @@ const phaseLabel = (p?: string) => {
}
}
const STOP_RETRY_AFTER_MS = 15_000
const stopRequestedAtMsOf = (job: RecordJob): number => {
return toMs((job as any)?.stopRequestedAtMs)
}
const isFreshStoppingPhase = (job: RecordJob, nowMs: number): boolean => {
const phase = String((job as any)?.phase ?? '').trim().toLowerCase()
if (phase !== 'stopping') return false
const startedAt = stopRequestedAtMsOf(job)
return startedAt > 0 && Math.max(0, nowMs - startedAt) < STOP_RETRY_AFTER_MS
}
const canRetryStoppingJob = (job: RecordJob, nowMs: number): boolean => {
const phase = String((job as any)?.phase ?? '').trim().toLowerCase()
const status = String((job as any)?.status ?? '').trim().toLowerCase()
return phase === 'stopping' && status === 'running' && !isFreshStoppingPhase(job, nowMs)
}
const isStopWaitingForJob = (
job: RecordJob,
localStopRequested: boolean,
nowMs: number
): boolean => {
return localStopRequested || isFreshStoppingPhase(job, nowMs)
}
const isStoppingForUi = (
job: RecordJob,
localStopRequested: boolean,
nowMs: number
): boolean => {
const phase = String((job as any)?.phase ?? '').trim().toLowerCase()
const status = String((job as any)?.status ?? '').trim().toLowerCase()
const isBusyPhase = phase !== '' && phase !== 'recording' && phase !== 'stopping'
return isBusyPhase || status !== 'running' || isStopWaitingForJob(job, localStopRequested, nowMs)
}
const isTerminalStatus = (status?: unknown) => {
const s = String(status ?? '').trim().toLowerCase()
return (
@ -734,13 +773,12 @@ export default function DownloadsCardRow({
const isRecording = phaseLower === 'recording'
const isStopRequested = Boolean(stopRequestedIds[j.id])
const rawStatus = String(j.status ?? '').toLowerCase()
const postworkState = getEffectivePostworkState(j)
const isQueuedPostwork = postworkState === 'queued'
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
const isStopping = isBusyPhase || rawStatus !== 'running' || isStopRequested
const canRetryStop = canRetryStoppingJob(j, nowMs) && !isStopRequested
const isStopping = isStoppingForUi(j, isStopRequested, nowMs)
const showRemoveQueuedButton = isQueuedPostwork
const disableStopButton = showRemoveQueuedButton ? false : isStopping
@ -768,7 +806,11 @@ export default function DownloadsCardRow({
phaseText = phaseLabel(phase) || postWorkLabel(j, postworkInfoOf(j))
}
} else {
phaseText = phase ? (phaseLabel(phase) || phase) : ''
if (phaseLower === 'stopping' && canRetryStop) {
phaseText = 'Stop dauert zu lange - erneut stoppen'
} else {
phaseText = phase ? (phaseLabel(phase) || phase) : ''
}
}
const progressLabel = phaseText || roomStatus
@ -979,7 +1021,11 @@ export default function DownloadsCardRow({
await onStopJob(j.id)
}}
>
{showRemoveQueuedButton ? 'Entfernen' : (isStopping ? 'Stoppe…' : 'Stop')}
{showRemoveQueuedButton
? 'Entfernen'
: canRetryStop
? 'Stop erneut'
: (isStopping ? 'Stoppe…' : 'Stop')}
</Button>
</div>
</div>
@ -987,4 +1033,4 @@ export default function DownloadsCardRow({
</div>
</div>
)
}
}

View File

@ -120,6 +120,24 @@ const baseName = (p: string) => {
return parts[parts.length - 1] || ''
}
function resolveRenamedFile(file: string, renamedFiles: Record<string, string>) {
let current = String(file || '').trim()
if (!current) return current
const seen = new Set<string>()
while (current && !seen.has(current)) {
seen.add(current)
const next = String(renamedFiles[current] || '').trim()
if (!next || next === current) break
current = next
}
return current
}
const stemWithoutExt = (p: string) => {
const f = baseName(p || '')
return stripHotPrefix(f.replace(/\.[^.]+$/, ''))
@ -2585,8 +2603,8 @@ export default function FinishedDownloads({
(job: RecordJob): RecordJob => {
const out = norm(job.output || '')
const file = baseName(out)
const override = renamedFiles[file]
if (!override) return job
const override = resolveRenamedFile(file, renamedFiles)
if (!override || override === file) return job
const idx = out.lastIndexOf('/')
const dir = idx >= 0 ? out.slice(0, idx + 1) : ''
@ -2897,8 +2915,11 @@ export default function FinishedDownloads({
}, [activeVisibleRows, keyFor, selectionStore, selectionVersion])
const hasSearchQuery = (searchQuery || '').trim() !== ''
const selectionActionsVisible = selectedCount > 0 && selectionActionsOpen
const selectionModeActive = selectedCount > 0
const selectionActionsVisible = selectionActionsOpen && activeVisibleRows.length > 0
const selectionModeActive = selectionActionsOpen || selectedCount > 0
const selectionButtonDisabled = activeVisibleRows.length === 0
const selectionButtonLabel = selectedCount > 0 ? `${selectedCount} ausgewählt` : 'Auswählen'
const selectedBulkActionsDisabled = bulkBusy || selectedCount === 0
const selectedKeys = useMemo(() => {
const next = new Set<string>()
@ -3241,6 +3262,28 @@ export default function FinishedDownloads({
}
}, [])
const removeRowNow = useCallback(
(key: string, file?: string) => {
cancelRemoveTimer(key)
if (file) {
setHiddenFiles((prev) => {
const next = new Set(prev)
next.add(file)
return next
})
}
markDeleted(key)
markRemoving(key, false)
requestAnimationFrame(() => {
refreshHoverPreviewFromPointer()
})
},
[cancelRemoveTimer, markDeleted, markRemoving, refreshHoverPreviewFromPointer]
)
const restoreRow = useCallback(
(key: string, file?: string) => {
cancelRemoveTimer(key)
@ -3470,7 +3513,7 @@ export default function FinishedDownloads({
rowKey: key,
setBusy: (v) => markDeleting(key, v),
isBusyNow: () => deletingKeys.has(key),
optimisticRemove: true,
optimisticRemove: false,
alreadyRemoved: opts?.alreadyRemoved,
labels: {
invalidTitle: 'Löschen nicht möglich',
@ -3501,6 +3544,7 @@ export default function FinishedDownloads({
},
onSuccess: async (result: any) => {
selectionStore.deselect(key)
removeRowNow(key, file)
const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
const from = typeof result?.from === 'string' ? result.from : undefined
@ -3525,6 +3569,7 @@ export default function FinishedDownloads({
runFileMutation,
emitCountHint,
selectionStore,
removeRowNow,
]
)
@ -3600,10 +3645,28 @@ export default function FinishedDownloads({
setRenamedFiles((prev) => {
const next: Record<string, string> = { ...prev }
const sources = new Set<string>([oldFile])
for (const [k, v] of Object.entries(next)) {
if (k === oldFile || k === newFile || v === oldFile || v === newFile) delete next[k]
const final = resolveRenamedFile(k, prev)
if (final === oldFile || v === oldFile) {
sources.add(k)
}
if (k === newFile || v === newFile) {
delete next[k]
}
}
next[oldFile] = newFile
for (const source of sources) {
if (!source || source === newFile) continue
next[source] = newFile
}
delete next[newFile]
return next
})
@ -3614,9 +3677,33 @@ export default function FinishedDownloads({
if (!a && !b) return
setRenamedFiles((prev) => {
const next: Record<string, string> = { ...prev }
for (const [k, v] of Object.entries(next)) {
if (k === a || k === b || v === a || v === b) delete next[k]
const final = resolveRenamedFile(k, prev)
if (k === b) {
delete next[k]
continue
}
if (final === b || v === b) {
if (k && k !== a) {
next[k] = a
} else {
delete next[k]
}
continue
}
if (k === a || v === a) {
delete next[k]
}
}
if (a && next[a] === a) {
delete next[a]
}
return next
})
}, [])
@ -4025,7 +4112,7 @@ export default function FinishedDownloads({
if (item?.ok) {
deletedKeys.add(key)
animateRemove(key, file)
removeRowNow(key, file)
} else {
failedKeys.add(key)
}
@ -4089,9 +4176,9 @@ export default function FinishedDownloads({
}, [
bulkBusy,
selectedFiles,
animateRemove,
clearSelection,
emitCountHint,
removeRowNow,
notify,
])
@ -5031,10 +5118,10 @@ export default function FinishedDownloads({
}, [isSmall])
useEffect(() => {
if (selectedCount === 0 && selectionActionsOpen) {
if (activeVisibleRows.length === 0 && selectionActionsOpen) {
setSelectionActionsOpen(false)
}
}, [selectedCount, selectionActionsOpen])
}, [activeVisibleRows.length, selectionActionsOpen])
const desktopToolbarControlsClass = [
'flex flex-col gap-3',
@ -5278,24 +5365,23 @@ export default function FinishedDownloads({
</div>
</div>
{selectedCount > 0 ? (
<Button
size="sm"
variant="soft"
onClick={() => setSelectionActionsOpen((v) => !v)}
title={
selectionActionsOpen
? 'Auswahl-Aktionen ausblenden'
: 'Auswahl-Aktionen anzeigen'
}
className={[
'shrink-0 whitespace-nowrap font-semibold',
selectionActionsOpen ? 'ring-2 ring-indigo-500/60' : '',
].join(' ')}
>
{selectedCount} ausgewählt
</Button>
) : null}
<Button
size="sm"
variant="soft"
disabled={selectionButtonDisabled}
onClick={() => setSelectionActionsOpen((v) => !v)}
title={
selectionActionsOpen
? 'Auswahl-Aktionen ausblenden'
: 'Auswahlmodus öffnen'
}
className={[
'shrink-0 whitespace-nowrap font-semibold',
selectionActionsOpen ? 'ring-2 ring-indigo-500/60' : '',
].join(' ')}
>
{selectionButtonLabel}
</Button>
</div>
<div
@ -5340,7 +5426,7 @@ export default function FinishedDownloads({
size="sm"
variant="secondary"
leadingIcon={<XMarkIcon className="size-4 shrink-0" />}
disabled={bulkBusy}
disabled={selectedBulkActionsDisabled}
onClick={clearSelection}
className="w-full justify-center"
>
@ -5352,7 +5438,7 @@ export default function FinishedDownloads({
variant="soft"
color="emerald"
leadingIcon={<ArchiveBoxIcon className="size-4 shrink-0" />}
disabled={bulkBusy}
disabled={selectedBulkActionsDisabled}
onClick={() => void bulkKeepSelected()}
className="w-full justify-center"
>
@ -5364,7 +5450,7 @@ export default function FinishedDownloads({
variant="soft"
color="red"
leadingIcon={<TrashIcon className="size-4 shrink-0" />}
disabled={bulkBusy}
disabled={selectedBulkActionsDisabled}
onClick={() => void bulkDeleteSelected()}
className="w-full justify-center"
>
@ -5396,7 +5482,7 @@ export default function FinishedDownloads({
size="sm"
variant="secondary"
leadingIcon={<XMarkIcon className="size-4 shrink-0" />}
disabled={bulkBusy}
disabled={selectedBulkActionsDisabled}
onClick={clearSelection}
>
Auswahl aufheben
@ -5409,7 +5495,7 @@ export default function FinishedDownloads({
variant="soft"
color="emerald"
leadingIcon={<ArchiveBoxIcon className="size-4 shrink-0" />}
disabled={bulkBusy}
disabled={selectedBulkActionsDisabled}
onClick={() => void bulkKeepSelected()}
>
Behalten
@ -5420,7 +5506,7 @@ export default function FinishedDownloads({
variant="soft"
color="red"
leadingIcon={<TrashIcon className="size-4 shrink-0" />}
disabled={bulkBusy}
disabled={selectedBulkActionsDisabled}
onClick={() => void bulkDeleteSelected()}
>
Löschen
@ -5610,11 +5696,28 @@ export default function FinishedDownloads({
<div className="lg:hidden">
<div className="flex items-center gap-2 px-3 pt-2.5 pb-1.5">
<div className="min-w-0 flex flex-1 items-center gap-2">
<div className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
Abgeschlossene Downloads
</div>
<Button
size="sm"
variant={lastAction ? 'soft' : 'secondary'}
color="emerald"
className="h-7 shrink-0 px-2 text-xs"
disabled={!lastAction || undoing}
onClick={undoLastAction}
leadingIcon={<ArrowUturnLeftIcon className="size-3.5 shrink-0" />}
title={
!lastAction
? 'Keine Aktion fuer Undo'
: `Letzte Aktion rueckgaengig machen (${lastAction.kind})`
}
>
Undo
</Button>
{bulkBusy ? (
<LoadingSpinner
size="sm"
@ -5646,7 +5749,7 @@ export default function FinishedDownloads({
size="md"
variant={lastAction ? 'soft' : 'secondary'}
color="emerald"
className="flex-1"
className="!hidden"
disabled={!lastAction || undoing}
onClick={undoLastAction}
leadingIcon={<ArrowUturnLeftIcon className="size-4 shrink-0" />}
@ -5699,23 +5802,22 @@ export default function FinishedDownloads({
<span className="sr-only">Report</span>
</Button>
{selectedCount > 0 ? (
<Button
size="md"
variant="soft"
onClick={() => {
setSelectionActionsOpen((v) => !v)
setMobileOptionsOpen(false)
}}
title={selectionActionsOpen ? 'Auswahl-Aktionen ausblenden' : 'Auswahl-Aktionen anzeigen'}
className={[
'shrink-0 whitespace-nowrap px-3 font-semibold',
selectionActionsOpen ? 'ring-2 ring-indigo-500/60' : '',
].join(' ')}
>
{selectedCount} ausgewählt
</Button>
) : null}
<Button
size="md"
variant="soft"
disabled={selectionButtonDisabled}
onClick={() => {
setSelectionActionsOpen((v) => !v)
setMobileOptionsOpen(false)
}}
title={selectionActionsOpen ? 'Auswahl-Aktionen ausblenden' : 'Auswahlmodus öffnen'}
className={[
'shrink-0 whitespace-nowrap px-3 font-semibold',
selectionActionsOpen ? 'ring-2 ring-indigo-500/60' : '',
].join(' ')}
>
{selectionButtonLabel}
</Button>
<button
type="button"
@ -5779,7 +5881,7 @@ export default function FinishedDownloads({
size="sm"
variant="secondary"
leadingIcon={<XMarkIcon className="size-4 shrink-0" />}
disabled={bulkBusy}
disabled={selectedBulkActionsDisabled}
onClick={clearSelection}
className="w-full justify-center"
>
@ -5791,7 +5893,7 @@ export default function FinishedDownloads({
variant="soft"
color="emerald"
leadingIcon={<ArchiveBoxIcon className="size-4 shrink-0" />}
disabled={bulkBusy}
disabled={selectedBulkActionsDisabled}
onClick={() => void bulkKeepSelected()}
className="w-full justify-center"
>
@ -5803,7 +5905,7 @@ export default function FinishedDownloads({
variant="primary"
color="red"
leadingIcon={<TrashIcon className="size-4 shrink-0" />}
disabled={bulkBusy}
disabled={selectedBulkActionsDisabled}
onClick={() => void bulkDeleteSelected()}
className="w-full justify-center"
>
@ -6069,6 +6171,7 @@ export default function FinishedDownloads({
<FinishedDownloadsCardsView
rows={cardRows}
selectionStore={selectionStore}
selectionModeActive={selectionModeActive}
onToggleSelected={toggleSelected}
onToggleSelectAllPage={handleToggleSelectAllPage}
onSelectOnly={selectOnly}

View File

@ -8,6 +8,7 @@ import {
useMemo,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from 'react'
import Card from './Card'
@ -63,6 +64,7 @@ type Props = {
isLoading?: boolean
isSmall: boolean
selectionStore: FinishedSelectionStore
selectionModeActive: boolean
onToggleSelected: (job: RecordJob) => void
onToggleSelectAllPage: (checked: boolean) => void
onSelectOnly: (job: RecordJob) => void
@ -320,6 +322,7 @@ function FinishedDownloadsCardsView({
rows,
isSmall,
selectionStore,
selectionModeActive,
onToggleSelected,
isLoading,
blurPreviews,
@ -450,7 +453,7 @@ function FinishedDownloadsCardsView({
const k = keyFor(j)
const handleSelectOrOpen = () => {
if (selectionStore.hasAnySelection()) {
if (selectionModeActive) {
onToggleSelected(j)
return true
}
@ -869,7 +872,7 @@ function FinishedDownloadsCardsView({
if (isSmall) return
if (selectionStore.hasAnySelection()) {
if (selectionModeActive) {
onToggleSelected(j)
return
}
@ -982,6 +985,36 @@ function FinishedDownloadsCardsView({
}
const [skippedMobileKeys, setSkippedMobileKeys] = useState<string[]>([])
const mobileStackFrameRef = useRef<HTMLDivElement | null>(null)
const mobileStackHeightTimerRef = useRef<number | null>(null)
const [mobileStackMinHeight, setMobileStackMinHeight] = useState(0)
const lockMobileStackHeight = useCallback((durationMs = 520) => {
if (!isSmall) return
const height = mobileStackFrameRef.current?.getBoundingClientRect().height ?? 0
if (height > 0) {
setMobileStackMinHeight(Math.ceil(height))
}
if (mobileStackHeightTimerRef.current != null) {
window.clearTimeout(mobileStackHeightTimerRef.current)
}
mobileStackHeightTimerRef.current = window.setTimeout(() => {
setMobileStackMinHeight(0)
mobileStackHeightTimerRef.current = null
}, durationMs)
}, [isSmall])
useEffect(() => {
return () => {
if (mobileStackHeightTimerRef.current != null) {
window.clearTimeout(mobileStackHeightTimerRef.current)
mobileStackHeightTimerRef.current = null
}
}
}, [])
const mobileOrderedRows = useMemo(() => {
if (!isSmall || skippedMobileKeys.length === 0) return rows
@ -1007,13 +1040,14 @@ function FinishedDownloadsCardsView({
const topKey = keyFor(topRow)
lockMobileStackHeight()
setSpeedHoldKey((prev) => (prev === topKey ? null : prev))
setSkippedMobileKeys((prev) => [
...prev.filter((key) => key !== topKey),
topKey,
])
}, [isSmall, mobileOrderedRows, keyFor])
}, [isSmall, mobileOrderedRows, keyFor, lockMobileStackHeight])
const mobileStackDepth = 3
@ -1125,6 +1159,11 @@ function FinishedDownloadsCardsView({
// weil wir nach OBEN stacken, brauchen wir oben Platz
const stackExtraTopPx = Math.max(0, Math.min(mobileVisibleStackRows.length, mobileStackDepth) - 1)
const mobileStackShellStyle = useMemo(() => ({
overflowX: 'clip',
overflowAnchor: 'none',
minHeight: mobileStackMinHeight > 0 ? `${mobileStackMinHeight}px` : undefined,
}) as CSSProperties, [mobileStackMinHeight])
return (
@ -1139,6 +1178,7 @@ function FinishedDownloadsCardsView({
key={`${k}__desktop`}
job={j}
selectionStore={selectionStore}
selectionModeActive={selectionModeActive}
isDeleting={deletingKeys.has(k)}
isKeeping={keepingKeys.has(k)}
isRemoving={removingKeys.has(k)}
@ -1196,8 +1236,9 @@ function FinishedDownloadsCardsView({
) : null
) : (
<div
ref={mobileStackFrameRef}
className="relative mx-auto w-full max-w-[560px] overflow-y-visible"
style={{ overflowX: 'clip' }}
style={mobileStackShellStyle}
>
{/* feste Höhe für den Stapel (damit die unteren Karten sichtbar “rausgucken”) */}
<div
@ -1295,14 +1336,14 @@ function FinishedDownloadsCardsView({
renderRole: 'top',
})
const selectionModeActive = selectionStore.hasAnySelection()
const topCardSelectionModeActive = selectionModeActive
const canSpeedHold =
!inlineActive &&
!busy &&
(
allowTeaserAnimation ||
(selectionModeActive && teaserState.mode !== 'still')
(topCardSelectionModeActive && teaserState.mode !== 'still')
)
return (
@ -1331,7 +1372,7 @@ function FinishedDownloadsCardsView({
onTap={() => {
if (!mobileTopTapEnabled) return
if (selectionStore.hasAnySelection()) {
if (topCardSelectionModeActive) {
onToggleSelected(j)
return
}
@ -1350,10 +1391,12 @@ function FinishedDownloadsCardsView({
})
}}
onSwipeLeft={async () => {
lockMobileStackHeight()
setMobileTopTeaserEnabled(false)
return await deleteVideo(j)
}}
onSwipeRight={async () => {
lockMobileStackHeight()
setMobileTopTeaserEnabled(false)
return await keepVideo(j)
}}
@ -1411,4 +1454,4 @@ function FinishedDownloadsCardsView({
}
const MemoFinishedDownloadsCardsView = memo(FinishedDownloadsCardsView)
export default MemoFinishedDownloadsCardsView
export default MemoFinishedDownloadsCardsView

View File

@ -46,6 +46,7 @@ import { autoTagsForJob, mergeTags } from './autoTags'
export type FinishedDownloadsDesktopCardProps = {
job: RecordJob
selectionStore: FinishedSelectionStore
selectionModeActive: boolean
isDeleting: boolean
isKeeping: boolean
isRemoving: boolean
@ -137,6 +138,7 @@ const FinishedDownloadsDesktopCard = memo(
function FinishedDownloadsDesktopCard({
job: j,
selectionStore,
selectionModeActive,
isDeleting,
isKeeping,
isRemoving,
@ -199,12 +201,12 @@ const FinishedDownloadsDesktopCard = memo(
})
const handleSelectOrOpen = useCallback(() => {
if (selectionStore.hasAnySelection()) {
if (selectionModeActive) {
onToggleSelected(j)
return true
}
return false
}, [selectionStore, onToggleSelected, j])
}, [selectionModeActive, onToggleSelected, j])
const clearLongPressTimer = useCallback(() => {
if (longPressTimerRef.current !== null) {
@ -216,14 +218,14 @@ const FinishedDownloadsDesktopCard = memo(
const startLongPressSelect = useCallback(() => {
clearLongPressTimer()
if (selectionStore.hasAnySelection()) return
if (selectionModeActive) return
longPressTimerRef.current = window.setTimeout(() => {
longPressTimerRef.current = null
longPressTriggeredRef.current = true
onToggleSelected(j)
}, 450)
}, [clearLongPressTimer, selectionStore, onToggleSelected, j])
}, [clearLongPressTimer, selectionModeActive, onToggleSelected, j])
const previewMuted = forcePreviewMuted ? true : !allowSound
@ -378,7 +380,7 @@ const FinishedDownloadsDesktopCard = memo(
<div
className={[
'absolute left-3 top-3 z-40 hidden transition-opacity duration-150 lg:block',
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
checked || selectionModeActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
].join(' ')}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
@ -624,4 +626,4 @@ const FinishedDownloadsDesktopCard = memo(
}
)
export default FinishedDownloadsDesktopCard
export default FinishedDownloadsDesktopCard

View File

@ -90,6 +90,60 @@ function baseName(path: string) {
return (path || '').split(/[\\/]/).pop() || ''
}
function isMobilePreviewConstrained() {
if (typeof window === 'undefined') return false
const coarsePointer =
typeof window.matchMedia === 'function' &&
(
window.matchMedia('(pointer: coarse)').matches ||
window.matchMedia('(hover: none)').matches
)
return coarsePointer
}
function useMobilePreviewConstrained() {
const [constrained, setConstrained] = useState(isMobilePreviewConstrained)
useEffect(() => {
if (typeof window === 'undefined') return
if (typeof window.matchMedia !== 'function') return
const queries = [
window.matchMedia('(pointer: coarse)'),
window.matchMedia('(hover: none)'),
]
const update = () => setConstrained(isMobilePreviewConstrained())
update()
const cleanups: Array<() => void> = []
for (const query of queries) {
if (typeof query.addEventListener === 'function') {
query.addEventListener('change', update)
cleanups.push(() => query.removeEventListener('change', update))
} else {
query.addListener?.(update)
cleanups.push(() => query.removeListener?.(update))
}
}
window.addEventListener('resize', update)
window.addEventListener('orientationchange', update)
return () => {
for (const cleanup of cleanups) cleanup()
window.removeEventListener('resize', update)
window.removeEventListener('orientationchange', update)
}
}, [])
return constrained
}
export default function FinishedVideoPreview({
job,
getFileName,
@ -319,8 +373,14 @@ export default function FinishedVideoPreview({
return !document.hidden
})
const mobilePreviewConstrained = useMobilePreviewConstrained()
const effectiveInView = forceActive || inView
const effectiveNearView = forceActive || nearView
const metadataLoadRangeActive = mobilePreviewConstrained
? inView
: forceActive || effectiveNearView || effectiveInView
const teaserRetryDelayMs = mobilePreviewConstrained ? 450 : 120
const teaserWatchdogMs = mobilePreviewConstrained ? 700 : 180
const inlineMode: 'never' | 'always' | 'hover' =
inlineVideo === true || inlineVideo === 'always' ? 'always' : inlineVideo === 'hover' ? 'hover' : 'never'
@ -517,7 +577,7 @@ export default function FinishedVideoPreview({
return { ratio, globalSec: Math.max(0, globalSec), vvDur }
}
const hardStop = (v: HTMLVideoElement | null) => {
const hardStop = useCallback((v: HTMLVideoElement | null) => {
if (!v) return
try {
v.pause()
@ -528,7 +588,7 @@ export default function FinishedVideoPreview({
v.src = ''
v.load()
} catch {}
}
}, [])
useEffect(() => {
return () => {
@ -581,7 +641,7 @@ export default function FinishedVideoPreview({
window.removeEventListener('player:release', onRelease as EventListener)
window.removeEventListener('player:close', onRelease as EventListener)
}
}, [file])
}, [file, hardStop])
// --- IntersectionObserver: echtes inView (für Playback)
useEffect(() => {
@ -716,7 +776,8 @@ export default function FinishedVideoPreview({
// ✅ Teaser/Clips "vorwärmen": schon in nearView erlauben
const shouldPreloadAnimatedAssets =
forceActive || effectiveNearView || effectiveInView || (wantsHover && hovered)
!mobilePreviewConstrained &&
(forceActive || effectiveNearView || effectiveInView || (wantsHover && hovered))
useEffect(() => {
if (animatedMode !== 'teaser' || showingInlineVideo || !teaserActive) {
@ -808,10 +869,12 @@ export default function FinishedVideoPreview({
}
// ✅ aktive Asset-Nutzung (z.B. poster etc.)
const shouldLoadAssets = shouldPreloadAnimatedAssets
const shouldLoadAssets =
shouldPreloadAnimatedAssets || effectiveInView || showingInlineVideo || teaserActive
const teaserCanPrewarm =
animated &&
!mobilePreviewConstrained &&
animatedMode === 'teaser' &&
Boolean(teaserSrc) &&
!showingInlineVideo &&
@ -1037,12 +1100,12 @@ export default function FinishedVideoPreview({
const onLoadedData = () => {
setTeaserReady(true)
tryPlay()
scheduleRetry(120)
scheduleRetry(teaserRetryDelayMs)
}
const onCanPlay = () => {
tryPlay()
scheduleRetry(120)
scheduleRetry(teaserRetryDelayMs)
}
const onPause = () => {
@ -1053,12 +1116,12 @@ export default function FinishedVideoPreview({
if (el.ended) return
// iOS-Fall: erster Frame sichtbar, aber Playback stoppt direkt wieder
scheduleRetry(120)
scheduleRetry(teaserRetryDelayMs)
}
const onWaiting = () => {
if (cancelled) return
scheduleRetry(120)
scheduleRetry(teaserRetryDelayMs)
}
vv.addEventListener('playing', onPlaying)
@ -1090,15 +1153,15 @@ export default function FinishedVideoPreview({
const stalledFor = now - teaserLastProgressTimeRef.current
const ct = Number(el.currentTime)
if ((!Number.isFinite(ct) || ct <= 0.05) && stalledFor > 180) {
if ((!Number.isFinite(ct) || ct <= 0.05) && stalledFor > teaserWatchdogMs) {
tryPlay({ resetToStart: true })
return
}
if (el.paused && !el.ended && stalledFor > 180) {
if (el.paused && !el.ended && stalledFor > teaserWatchdogMs) {
tryPlay()
}
}, 180)
}, teaserWatchdogMs)
return () => {
cancelled = true
@ -1114,7 +1177,15 @@ export default function FinishedVideoPreview({
if (typeof raf2 === 'number') cancelAnimationFrame(raf2)
window.clearInterval(watchdog)
}
}, [teaserActive, animatedMode, teaserSrc, pageVisible])
}, [
teaserActive,
animatedMode,
teaserSrc,
pageVisible,
teaserRetryDelayMs,
teaserWatchdogMs,
teaserSpeedHold,
])
useEffect(() => {
if (!activationNonce) return
@ -1213,7 +1284,7 @@ export default function FinishedVideoPreview({
sync()
// ✅ Sekundentakt (robust, unabhängig von raf/play-events)
timer = window.setInterval(sync, 100)
timer = window.setInterval(sync, mobilePreviewConstrained ? 250 : 100)
// optional: bei metadata/timeupdate sofort einmal syncen
const onLoaded = () => sync()
@ -1230,7 +1301,7 @@ export default function FinishedVideoPreview({
vv.removeEventListener('durationchange', onLoaded)
vv.removeEventListener('timeupdate', onTime)
}
}, [showProgressBar, progressVideoRef, progressTotalSeconds, previewClipMapKey, progressKind, previewClipMap, progressMountTick])
}, [showProgressBar, progressVideoRef, progressTotalSeconds, previewClipMapKey, progressKind, previewClipMap, progressMountTick, mobilePreviewConstrained])
useEffect(() => {
if (!showingInlineVideo) return
@ -1287,7 +1358,7 @@ export default function FinishedVideoPreview({
// ✅ brauchen wir noch hidden-metadata-load?
const needHiddenMeta =
(forceActive || effectiveNearView || effectiveInView) &&
metadataLoadRangeActive &&
(onDuration || onResolution) &&
!metaLoaded &&
!showingInlineVideo &&
@ -1437,7 +1508,7 @@ export default function FinishedVideoPreview({
playsInline
autoPlay
loop
preload="auto"
preload={mobilePreviewConstrained ? 'metadata' : 'auto'}
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
onLoadedData={() => {
setTeaserOk(true)

View File

@ -1100,8 +1100,8 @@ const SEGMENT_LABEL_META: SegmentLabelMeta[] = [
icon: DoggyIcon,
},
{
match: ['unknown'],
text: 'Unbekannt',
match: ['keine'],
text: 'Keine',
icon: UnknownContentIcon,
},
// People
@ -1697,4 +1697,4 @@ export function getHighestPrioritySegmentLabelItem(
if (pa !== pb) return pa - pb
return a.text.localeCompare(b.text, 'de')
})[0]
}
}

View File

@ -451,8 +451,6 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
thresholdRef.current ||
Math.min(thresholdPx, (cardRef.current?.offsetWidth || 360) * thresholdRatio)
const reveal = clamp(absDx / Math.max(1, activeThreshold), 0, 1)
const revealSoft = clamp(absDx / Math.max(1, activeThreshold * 1.35), 0, 1)
const tiltDeg = clamp(dx / 34, -ROTATE_MAX_DEG, ROTATE_MAX_DEG)
@ -478,17 +476,14 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
transform:
dx !== 0
? `translate3d(${dx}px,0,0) rotate(${tiltDeg}deg) scale(${dragScale})`
: undefined,
: 'translate3d(0,0,0)',
transition: animMs
? `transform ${animMs}ms cubic-bezier(0.22, 1, 0.36, 1)`
: undefined,
touchAction: baseTouchAction,
willChange: dx !== 0 ? 'transform' : undefined,
willChange: 'transform',
backfaceVisibility: 'hidden',
borderRadius: dx !== 0 ? '12px' : undefined,
filter:
dx !== 0
? `saturate(${1 + reveal * 0.06}) brightness(${1 + reveal * 0.015})`
: undefined,
}}
onPointerDown={(e) => {
if (!enabled || disabled) return
@ -879,4 +874,4 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
)
})
export default SwipeCard
export default SwipeCard

View File

@ -84,6 +84,19 @@ function clampPercent(value: number) {
return Math.max(0, Math.min(100, value))
}
const NO_SEX_POSITION_LABEL = 'keine'
const NO_SEX_POSITION_ALIASES = new Set([
'',
NO_SEX_POSITION_LABEL,
])
function normalizeSexPositionValue(value?: string | null) {
const clean = String(value ?? '').trim()
return NO_SEX_POSITION_ALIASES.has(clean.toLowerCase())
? NO_SEX_POSITION_LABEL
: clean
}
function normalizeBox(box: TrainingFeedbackBox): TrainingFeedbackBox {
const x = clamp01(Number(box.x))
const y = clamp01(Number(box.y))
@ -104,7 +117,7 @@ function annotationEffectiveCorrection(
): TrainingFeedbackCorrection {
if (annotation.negative) {
return {
sexPosition: 'unknown',
sexPosition: NO_SEX_POSITION_LABEL,
peoplePresent: [],
bodyPartsPresent: [],
objectsPresent: [],
@ -114,11 +127,14 @@ function annotationEffectiveCorrection(
}
if (annotation.correction) {
return annotation.correction
return {
...annotation.correction,
sexPosition: normalizeSexPositionValue(annotation.correction.sexPosition),
}
}
return {
sexPosition: annotation.prediction.sexPosition || 'unknown',
sexPosition: normalizeSexPositionValue(annotation.prediction.sexPosition),
peoplePresent: (annotation.prediction.peoplePresent ?? []).map((item) => item.label),
bodyPartsPresent: (annotation.prediction.bodyPartsPresent ?? []).map((item) => item.label),
objectsPresent: (annotation.prediction.objectsPresent ?? []).map((item) => item.label),
@ -685,7 +701,7 @@ function FeedbackListItem(props: {
</span>
<span className="max-w-full truncate rounded-full bg-gray-50 px-2 py-0.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
{effective.sexPosition || 'unknown'}
{normalizeSexPositionValue(effective.sexPosition)}
</span>
</div>
</div>
@ -850,7 +866,7 @@ function ResultLabelsCard(props: {
const groups = [
{
title: 'Position',
values: [props.effective.sexPosition || 'unknown'],
values: [normalizeSexPositionValue(props.effective.sexPosition)],
},
{
title: 'Personen',

File diff suppressed because it is too large Load Diff

View File

@ -190,6 +190,8 @@ export type RecordJob = {
phase?: string
progress?: number
stopRequestedAtMs?: number
stopAttempts?: number
postWorkKey?: string
postWork?: PostWorkKeyStatus
@ -741,4 +743,4 @@ export type VideoHeatmapSegment = {
intensity: number
label?: string
source?: VideoHeatmapSource
}
}