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 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 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]: def existing_file(path: Path) -> Optional[Path]:
@ -60,6 +106,7 @@ def resolve_training_root() -> Path:
TRAINING_ROOT = resolve_training_root() TRAINING_ROOT = resolve_training_root()
DEFAULT_MODEL_PATH = TRAINING_ROOT / "detector" / "model" / "best.pt" 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: 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}") 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. # Server darf auch ohne Labels/Model starten.
DETECTION_LABELS_PATH: Optional[Path] = None DETECTION_LABELS_PATH: Optional[Path] = None
LABEL_GROUPS = { LABEL_GROUPS = {
"people": set(), "people": set(),
"sexPositions": {"unknown"}, "sexPositions": {NO_SEX_POSITION_LABEL},
"bodyParts": set(), "bodyParts": set(),
"objects": set(), "objects": set(),
"clothing": set(), "clothing": set(),
@ -114,15 +175,19 @@ PERSON_LABELS = {
_MODEL_PATH = "" _MODEL_PATH = ""
_MODEL_ERROR = "" _MODEL_ERROR = ""
_POSE_MODEL_PATH = ""
_POSE_MODEL_ERROR = ""
_LABEL_ERROR = "" _LABEL_ERROR = ""
_DEVICE = os.environ.get("YOLO_DEVICE", "") _DEVICE = os.environ.get("YOLO_DEVICE", "")
_CONF = float(os.environ.get("YOLO_CONF", "0.25")) _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")) _BATCH = int(os.environ.get("YOLO_BATCH", "16"))
_IMGSZ = int(os.environ.get("YOLO_IMGSZ", "640")) _IMGSZ = int(os.environ.get("YOLO_IMGSZ", "640"))
_HALF = os.environ.get("YOLO_HALF", "0").lower() in {"1", "true", "yes", "on"} _HALF = os.environ.get("YOLO_HALF", "0").lower() in {"1", "true", "yes", "on"}
model = None model = None
pose_model = None
app = FastAPI() app = FastAPI()
@ -174,13 +239,14 @@ def empty_prediction(source: str = "model_missing") -> dict:
return { return {
"modelAvailable": False, "modelAvailable": False,
"source": source, "source": source,
"sexPosition": "unknown", "sexPosition": NO_SEX_POSITION_LABEL,
"sexPositionScore": 0.0, "sexPositionScore": 0.0,
"peoplePresent": [], "peoplePresent": [],
"bodyPartsPresent": [], "bodyPartsPresent": [],
"objectsPresent": [], "objectsPresent": [],
"clothingPresent": [], "clothingPresent": [],
"boxes": [], "boxes": [],
"persons": [],
} }
@ -207,11 +273,7 @@ def load_label_groups_safe() -> None:
for x in data.get("people", []) for x in data.get("people", [])
if str(x).strip() if str(x).strip()
), ),
"sexPositions": set( "sexPositions": normalize_sex_position_labels(data.get("sexPositions", [])),
str(x).strip().lower()
for x in data.get("sexPositions", [])
if str(x).strip()
),
"bodyParts": set( "bodyParts": set(
str(x).strip().lower() str(x).strip().lower()
for x in data.get("bodyParts", []) for x in data.get("bodyParts", [])
@ -230,7 +292,7 @@ def load_label_groups_safe() -> None:
} }
if not LABEL_GROUPS["sexPositions"]: if not LABEL_GROUPS["sexPositions"]:
LABEL_GROUPS["sexPositions"] = {"unknown"} LABEL_GROUPS["sexPositions"] = {NO_SEX_POSITION_LABEL}
_LABEL_ERROR = "" _LABEL_ERROR = ""
@ -240,7 +302,7 @@ def load_label_groups_safe() -> None:
LABEL_GROUPS = { LABEL_GROUPS = {
"people": set(), "people": set(),
"sexPositions": {"unknown"}, "sexPositions": {NO_SEX_POSITION_LABEL},
"bodyParts": set(), "bodyParts": set(),
"objects": set(), "objects": set(),
"clothing": set(), "clothing": set(),
@ -252,7 +314,7 @@ def load_label_groups_safe() -> None:
POSITION_LABELS = { POSITION_LABELS = {
label for label in LABEL_GROUPS["sexPositions"] label for label in LABEL_GROUPS["sexPositions"]
if label and label != "unknown" if label and not is_no_sex_position_label(label)
} }
PERSON_LABELS = { PERSON_LABELS = {
@ -289,6 +351,37 @@ def get_model():
return None 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: def scored(label: str, score: float) -> dict:
return { return {
"label": label, "label": label,
@ -315,9 +408,6 @@ def prediction_from_result(result) -> dict:
objects = [] objects = []
clothing = [] clothing = []
sex_position = "unknown"
sex_position_score = 0.0
if result.boxes is not None: if result.boxes is not None:
xywhn = result.boxes.xywhn.cpu().tolist() xywhn = result.boxes.xywhn.cpu().tolist()
cls_values = result.boxes.cls.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_body = label in BODY_LABELS
is_object = label in OBJECT_LABELS is_object = label in OBJECT_LABELS
is_clothing = label in CLOTHING_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): if not (is_person or is_body or is_object or is_clothing):
continue continue
@ -376,13 +459,165 @@ def prediction_from_result(result) -> dict:
return { return {
"modelAvailable": True, "modelAvailable": True,
"source": f"yolo-server:{Path(_MODEL_PATH).name}", "source": f"yolo-server:{Path(_MODEL_PATH).name}",
"sexPosition": sex_position, "sexPosition": NO_SEX_POSITION_LABEL,
"sexPositionScore": sex_position_score, "sexPositionScore": 0.0,
"peoplePresent": people_present, "peoplePresent": people_present,
"bodyPartsPresent": body_parts, "bodyPartsPresent": body_parts,
"objectsPresent": objects, "objectsPresent": objects,
"clothingPresent": clothing, "clothingPresent": clothing,
"boxes": boxes_out, "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] predictions = [prediction_from_result(result) for result in results]
if not req.detectorOnly:
apply_pose_batch_to_predictions(paths, predictions, imgsz)
return { return {
"ok": True, "ok": True,
"predictions": predictions, "predictions": predictions,
@ -444,7 +682,7 @@ def health():
current_model = get_model() current_model = get_model()
names = getattr(current_model, "names", {}) or {} if current_model is not None else {} names = getattr(current_model, "names", {}) or {} if current_model is not None else {}
return { status_payload = {
"ok": True, "ok": True,
"ready": current_model is not None, "ready": current_model is not None,
"modelAvailable": current_model is not None, "modelAvailable": current_model is not None,
@ -458,22 +696,31 @@ def health():
"labelError": _LABEL_ERROR, "labelError": _LABEL_ERROR,
} }
status_payload.update(pose_model_status())
return status_payload
@app.post("/reload", dependencies=[Depends(require_ai_server_auth)]) @app.post("/reload", dependencies=[Depends(require_ai_server_auth)])
def reload_model(): def reload_model():
global model global model
global pose_model
global _MODEL_PATH global _MODEL_PATH
global _MODEL_ERROR global _MODEL_ERROR
global _POSE_MODEL_PATH
global _POSE_MODEL_ERROR
global DETECTION_LABELS_PATH global DETECTION_LABELS_PATH
model = None model = None
pose_model = None
_MODEL_PATH = "" _MODEL_PATH = ""
_MODEL_ERROR = "" _MODEL_ERROR = ""
_POSE_MODEL_PATH = ""
_POSE_MODEL_ERROR = ""
DETECTION_LABELS_PATH = None DETECTION_LABELS_PATH = None
current_model = get_model() current_model = get_model()
names = getattr(current_model, "names", {}) or {} if current_model is not None else {} names = getattr(current_model, "names", {}) or {} if current_model is not None else {}
return { status_payload = {
"ok": current_model is not None, "ok": current_model is not None,
"ready": current_model is not None, "ready": current_model is not None,
"modelAvailable": current_model is not None, "modelAvailable": current_model is not None,
@ -486,3 +733,6 @@ def reload_model():
"labelConfig": str(DETECTION_LABELS_PATH) if DETECTION_LABELS_PATH else "", "labelConfig": str(DETECTION_LABELS_PATH) if DETECTION_LABELS_PATH else "",
"labelError": _LABEL_ERROR, "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) { add := func(values []string) {
for _, value := range values { for _, value := range values {
label := strings.ToLower(strings.TrimSpace(value)) label := strings.ToLower(strings.TrimSpace(value))
if label == "" || label == "unknown" { if isNoSexPositionLabel(label) {
continue continue
} }
out[label] = struct{}{} out[label] = struct{}{}
@ -155,7 +155,7 @@ var autoSelectedAILabelsCache map[string]struct{}
func shouldAutoSelectAnalyzeHit(label string) bool { func shouldAutoSelectAnalyzeHit(label string) bool {
label = strings.ToLower(strings.TrimSpace(label)) label = strings.ToLower(strings.TrimSpace(label))
if label == "" || label == "unknown" { if isNoSexPositionLabel(label) {
return false return false
} }
@ -202,7 +202,7 @@ func isIgnoredNSFWLabel(label string) bool {
func addHighlightResult(best map[string]float64, label string, score float64) { func addHighlightResult(best map[string]float64, label string, score float64) {
label = strings.ToLower(strings.TrimSpace(label)) label = strings.ToLower(strings.TrimSpace(label))
if label == "" || label == "unknown" { if isNoSexPositionLabel(label) {
return return
} }
@ -223,7 +223,7 @@ func addScoredHighlightLabels(best map[string]float64, prefix string, items []Tr
for _, item := range items { for _, item := range items {
label := strings.ToLower(strings.TrimSpace(item.Label)) label := strings.ToLower(strings.TrimSpace(item.Label))
if label == "" || label == "unknown" { if isNoSexPositionLabel(label) {
continue continue
} }
@ -235,7 +235,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe
best := map[string]float64{} best := map[string]float64{}
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition)) sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) { if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) {
addHighlightResult(best, "position:"+sexPosition, pred.SexPositionScore) addHighlightResult(best, "position:"+sexPosition, pred.SexPositionScore)
} }
@ -245,7 +245,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe
for _, box := range pred.Boxes { for _, box := range pred.Boxes {
label := strings.ToLower(strings.TrimSpace(box.Label)) label := strings.ToLower(strings.TrimSpace(box.Label))
if label == "" || label == "unknown" { if isNoSexPositionLabel(label) {
continue continue
} }
@ -264,7 +264,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe
} }
// Kombis nur erzeugen, wenn wirklich Position + Zusatz vorhanden ist. // Kombis nur erzeugen, wenn wirklich Position + Zusatz vorhanden ist.
if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) { if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) {
positionScore := pred.SexPositionScore positionScore := pred.SexPositionScore
if positionScore <= 0 { if positionScore <= 0 {
positionScore = 1 positionScore = 1
@ -273,7 +273,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe
addCombo := func(prefix string, items []TrainingScoredLabel) { addCombo := func(prefix string, items []TrainingScoredLabel) {
for _, item := range items { for _, item := range items {
label := strings.ToLower(strings.TrimSpace(item.Label)) label := strings.ToLower(strings.TrimSpace(item.Label))
if label == "" || label == "unknown" { if isNoSexPositionLabel(label) {
continue continue
} }
@ -316,7 +316,7 @@ func pickHighlightResults(results []NsfwFrameResult) []NsfwFrameResult {
for _, r := range results { for _, r := range results {
label := strings.ToLower(strings.TrimSpace(r.Label)) label := strings.ToLower(strings.TrimSpace(r.Label))
if label == "" || label == "unknown" { if isNoSexPositionLabel(label) {
continue continue
} }
@ -1075,7 +1075,7 @@ type highlightSignal struct {
func normalizeHighlightSignalLabel(label string) string { func normalizeHighlightSignalLabel(label string) string {
label = strings.ToLower(strings.TrimSpace(label)) label = strings.ToLower(strings.TrimSpace(label))
if label == "" || label == "unknown" { if isNoSexPositionLabel(label) {
return "" return ""
} }
@ -1094,28 +1094,28 @@ func normalizeHighlightSignalLabel(label string) string {
case strings.HasPrefix(label, "body:"): case strings.HasPrefix(label, "body:"):
raw := strings.TrimPrefix(label, "body:") raw := strings.TrimPrefix(label, "body:")
if raw == "" || raw == "unknown" { if isNoSexPositionLabel(raw) {
return "" return ""
} }
return "body:" + raw return "body:" + raw
case strings.HasPrefix(label, "object:"): case strings.HasPrefix(label, "object:"):
raw := strings.TrimPrefix(label, "object:") raw := strings.TrimPrefix(label, "object:")
if raw == "" || raw == "unknown" { if isNoSexPositionLabel(raw) {
return "" return ""
} }
return "object:" + raw return "object:" + raw
case strings.HasPrefix(label, "clothing:"): case strings.HasPrefix(label, "clothing:"):
raw := strings.TrimPrefix(label, "clothing:") raw := strings.TrimPrefix(label, "clothing:")
if raw == "" || raw == "unknown" { if isNoSexPositionLabel(raw) {
return "" return ""
} }
return "clothing:" + raw return "clothing:" + raw
case strings.HasPrefix(label, "position:"): case strings.HasPrefix(label, "position:"):
raw := strings.TrimPrefix(label, "position:") raw := strings.TrimPrefix(label, "position:")
if raw == "" || raw == "unknown" || !isKnownPositionLabel(raw) { if isNoSexPositionLabel(raw) || !isKnownPositionLabel(raw) {
return "" return ""
} }
return "position:" + raw return "position:" + raw
@ -1173,7 +1173,7 @@ func rawAnalyzeSignalSeverityWeight(label string) float64 {
} }
raw := normalizeSegmentLabel(label) raw := normalizeSegmentLabel(label)
if raw == "" || raw == "unknown" { if isNoSexPositionLabel(raw) {
return 0 return 0
} }
@ -1273,7 +1273,7 @@ func addHighlightSignalsFromScoredLabels(
for _, item := range items { for _, item := range items {
label := strings.ToLower(strings.TrimSpace(item.Label)) label := strings.ToLower(strings.TrimSpace(item.Label))
if label == "" || label == "unknown" { if isNoSexPositionLabel(label) {
continue continue
} }
@ -1302,7 +1302,7 @@ func highlightComboPartOrder(label string) int {
func predictionHasAnyAnalyzeSignal(pred TrainingPrediction) bool { func predictionHasAnyAnalyzeSignal(pred TrainingPrediction) bool {
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition)) sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
if sexPosition != "" && sexPosition != "unknown" { if !isNoSexPositionLabel(sexPosition) {
return true return true
} }
@ -1342,7 +1342,7 @@ func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64)
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition)) sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) { if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) {
positionScore := pred.SexPositionScore positionScore := pred.SexPositionScore
if positionScore <= 0 { if positionScore <= 0 {
positionScore = 0.35 positionScore = 0.35
@ -1364,7 +1364,7 @@ func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64)
for _, box := range pred.Boxes { for _, box := range pred.Boxes {
label := strings.ToLower(strings.TrimSpace(box.Label)) label := strings.ToLower(strings.TrimSpace(box.Label))
if label == "" || label == "unknown" { if isNoSexPositionLabel(label) {
continue continue
} }
if isIgnoredNSFWLabel(label) { if isIgnoredNSFWLabel(label) {
@ -1540,7 +1540,7 @@ func buildHighlightHitsFromPrediction(pred TrainingPrediction, t float64) []anal
best := map[string]highlightSignal{} best := map[string]highlightSignal{}
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition)) sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) { if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) {
addHighlightSignal(best, "position:"+sexPosition, pred.SexPositionScore) addHighlightSignal(best, "position:"+sexPosition, pred.SexPositionScore)
} }
@ -1550,7 +1550,7 @@ func buildHighlightHitsFromPrediction(pred TrainingPrediction, t float64) []anal
for _, box := range pred.Boxes { for _, box := range pred.Boxes {
label := strings.ToLower(strings.TrimSpace(box.Label)) label := strings.ToLower(strings.TrimSpace(box.Label))
if label == "" || label == "unknown" { if isNoSexPositionLabel(label) {
continue continue
} }
if isIgnoredNSFWLabel(label) { if isIgnoredNSFWLabel(label) {
@ -2322,7 +2322,7 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit {
for _, h := range in { for _, h := range in {
label := strings.ToLower(strings.TrimSpace(h.Label)) label := strings.ToLower(strings.TrimSpace(h.Label))
if label == "" || label == "unknown" { if isNoSexPositionLabel(label) {
continue continue
} }
if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) { if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) {
@ -2542,7 +2542,7 @@ type analyzeLabelSegmentPoint struct {
func isAllowedAnalyzeSegmentLabel(label string) bool { func isAllowedAnalyzeSegmentLabel(label string) bool {
label = strings.ToLower(strings.TrimSpace(label)) label = strings.ToLower(strings.TrimSpace(label))
if label == "" || label == "unknown" { if isNoSexPositionLabel(label) {
return false return false
} }
if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) { if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) {
@ -2559,7 +2559,7 @@ func isAllowedAnalyzeSegmentLabel(label string) bool {
} }
raw := normalizeSegmentLabel(label) raw := normalizeSegmentLabel(label)
if raw == "" || raw == "unknown" { if isNoSexPositionLabel(raw) {
return false return false
} }

View File

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

View File

@ -8,9 +8,17 @@ import (
"path/filepath" "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 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) { func trainingEmbeddedMLDir() (string, error) {
dir := filepath.Join(os.TempDir(), "nsfwapp-ml") dir := filepath.Join(os.TempDir(), "nsfwapp-ml")
@ -22,6 +30,8 @@ func trainingEmbeddedMLDir() (string, error) {
"predict_detector_model.py", "predict_detector_model.py",
"train_detector_model.py", "train_detector_model.py",
"detection_labels.json", "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. // 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...) { for _, name := range append(files, optionalFiles...) {
srcPath := filepath.ToSlash(filepath.Join("ml", name)) srcPath := filepath.ToSlash(filepath.Join("ml", name))
if name == "detection_labels.json" {
srcPath = "generated/training/detection_labels.json"
}
b, err := embeddedMLFiles.ReadFile(srcPath) b, err := embeddedMLFiles.ReadFile(srcPath)
if err != nil { if err != nil {
// Pflichtdateien müssen vorhanden sein. // Pflichtdateien müssen vorhanden sein.
@ -51,14 +57,24 @@ func trainingEmbeddedMLDir() (string, error) {
} }
dstPath := filepath.Join(dir, name) dstPath := filepath.Join(dir, name)
if err := os.WriteFile(dstPath, b, 0644); err != nil { if err := embeddedWriteFileIfNeeded(dstPath, b, 0644); err != nil {
return "", err return "", err
} }
} }
if _, err := embeddedPoseModelPath(); err != nil {
return "", err
}
return dir, nil 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) { func embeddedAIServerDir() (string, error) {
dir := filepath.Join(os.TempDir(), "nsfwapp-ai-server") dir := filepath.Join(os.TempDir(), "nsfwapp-ai-server")
@ -72,13 +88,33 @@ func embeddedAIServerDir() (string, error) {
} }
dstPath := filepath.Join(dir, "ai_server.py") 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 "", err
} }
return dir, nil 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) { func embeddedDotEnvBytes() ([]byte, error) {
return embeddedMLFiles.ReadFile(".env") return embeddedMLFiles.ReadFile(".env")
} }

View File

@ -103,6 +103,9 @@ func appLogWriter() io.Writer {
func appLogWriteLine(line string) { func appLogWriteLine(line string) {
line = strings.TrimRight(line, "\r\n") line = strings.TrimRight(line, "\r\n")
if appLogLineSuppressed(line) {
return
}
ts := time.Now().Format("2006-01-02 15:04:05") ts := time.Now().Format("2006-01-02 15:04:05")
out := fmt.Sprintf("[%s] %s\n", ts, line) 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 // 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 // eines Stream-Reconnects). Der Fehler wird weiterhin zurückgegeben, damit die
// Retry-/Reconnect-Logik unverändert funktioniert nur die Log-Zeile entfällt. // 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 { func appErrorLogSuppressed(msg string) bool {
m := strings.ToLower(msg) m := strings.ToLower(msg)
return strings.Contains(m, "leere hls url") || return appLogLineSuppressed(msg) ||
strings.Contains(m, "http 403") strings.Contains(m, "leere hls url")
} }
func appErrorf(format string, args ...any) error { 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 { if offset > 0 {
logText = "… Log gekürzt, zeige die letzten " + fmt.Sprint(maxBytes/1024) + " KB …\n\n" + logText 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 PreviewUA string `json:"-"` // user-agent
// ✅ Frontend Progress beim Stop/Finalize // ✅ Frontend Progress beim Stop/Finalize
Phase string `json:"phase,omitempty"` // stopping | remuxing | moving Phase string `json:"phase,omitempty"` // stopping | remuxing | moving
Progress int `json:"progress,omitempty"` // 0..100 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"` PostWorkKey string `json:"postWorkKey,omitempty"`
PostWork *PostWorkKeyStatus `json:"postWork,omitempty"` PostWork *PostWorkKeyStatus `json:"postWork,omitempty"`
@ -105,24 +107,26 @@ type RecordJob struct {
} }
type jobEventSnapshot struct { type jobEventSnapshot struct {
ID string ID string
SourceURL string SourceURL string
Output string Output string
Status JobStatus Status JobStatus
StartedAt time.Time StartedAt time.Time
StartedAtMs int64 StartedAtMs int64
EndedAt *time.Time EndedAt *time.Time
EndedAtMs int64 EndedAtMs int64
Error string Error string
Phase string Phase string
Progress int Progress int
SizeBytes int64 StopRequestedAtMs int64
DurationSeconds float64 StopAttempts int
PreviewState string SizeBytes int64
PostWorkKey string DurationSeconds float64
PostWork *PostWorkKeyStatus PreviewState string
ModelImageURL string PostWorkKey string
Avatar string PostWork *PostWorkKeyStatus
ModelImageURL string
Avatar string
} }
type dummyResponseWriter struct { type dummyResponseWriter struct {
@ -165,25 +169,27 @@ func canonicalAssetIDFromName(name string) string {
func publishJobUpsertSnapshot(s jobEventSnapshot) { func publishJobUpsertSnapshot(s jobEventSnapshot) {
ev := map[string]any{ ev := map[string]any{
"type": "job_upsert", "type": "job_upsert",
"jobId": s.ID, "jobId": s.ID,
"status": string(s.Status), "status": string(s.Status),
"sourceUrl": s.SourceURL, "sourceUrl": s.SourceURL,
"output": s.Output, "output": s.Output,
"startedAt": s.StartedAt, "startedAt": s.StartedAt,
"startedAtMs": s.StartedAtMs, "startedAtMs": s.StartedAtMs,
"endedAt": s.EndedAt, "endedAt": s.EndedAt,
"endedAtMs": s.EndedAtMs, "endedAtMs": s.EndedAtMs,
"error": s.Error, "error": s.Error,
"phase": s.Phase, "phase": s.Phase,
"progress": s.Progress, "progress": s.Progress,
"sizeBytes": s.SizeBytes, "stopRequestedAtMs": s.StopRequestedAtMs,
"durationSeconds": s.DurationSeconds, "stopAttempts": s.StopAttempts,
"previewState": s.PreviewState, "sizeBytes": s.SizeBytes,
"postWorkKey": s.PostWorkKey, "durationSeconds": s.DurationSeconds,
"ts": time.Now().UnixMilli(), "previewState": s.PreviewState,
"modelImageUrl": s.ModelImageURL, "postWorkKey": s.PostWorkKey,
"avatar": s.Avatar, "ts": time.Now().UnixMilli(),
"modelImageUrl": s.ModelImageURL,
"avatar": s.Avatar,
} }
if s.PostWork != nil { if s.PostWork != nil {
@ -222,24 +228,26 @@ func publishJobSnapshot(jobID string) bool {
} }
snap := jobEventSnapshot{ snap := jobEventSnapshot{
ID: job.ID, ID: job.ID,
SourceURL: job.SourceURL, SourceURL: job.SourceURL,
Output: job.Output, Output: job.Output,
Status: job.Status, Status: job.Status,
StartedAt: job.StartedAt, StartedAt: job.StartedAt,
StartedAtMs: job.StartedAtMs, StartedAtMs: job.StartedAtMs,
EndedAt: endedAtCopy, EndedAt: endedAtCopy,
EndedAtMs: job.EndedAtMs, EndedAtMs: job.EndedAtMs,
Error: job.Error, Error: job.Error,
Phase: job.Phase, Phase: job.Phase,
Progress: job.Progress, Progress: job.Progress,
SizeBytes: job.SizeBytes, StopRequestedAtMs: job.StopRequestedAtMs,
DurationSeconds: job.DurationSeconds, StopAttempts: job.StopAttempts,
PreviewState: job.PreviewState, SizeBytes: job.SizeBytes,
PostWorkKey: job.PostWorkKey, DurationSeconds: job.DurationSeconds,
PostWork: pw, PreviewState: job.PreviewState,
ModelImageURL: job.ModelImageURL, PostWorkKey: job.PostWorkKey,
Avatar: job.Avatar, PostWork: pw,
ModelImageURL: job.ModelImageURL,
Avatar: job.Avatar,
} }
jobsMu.RUnlock() 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))) 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(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--root", required=True) parser.add_argument("--root", required=True)
parser.add_argument("--image", 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("--conf", type=float, default=0.30)
parser.add_argument("--imgsz", type=int, default=640) parser.add_argument("--imgsz", type=int, default=640)
parser.add_argument("--debug-image", action="store_true") parser.add_argument("--debug-image", action="store_true")
@ -25,11 +51,11 @@ def main():
root = Path(args.root) root = Path(args.root)
image_path = Path(args.image) 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(): if not model_path.exists():
print(json.dumps({ print(json.dumps({
"available": False, "available": False,
"source": "detector_missing", "source": model_source,
"modelPath": str(model_path), "modelPath": str(model_path),
"boxes": [], "boxes": [],
}, ensure_ascii=False)) }, ensure_ascii=False))
@ -124,7 +150,7 @@ def main():
print(json.dumps({ print(json.dumps({
"available": True, "available": True,
"source": "yolo26_detector", "source": model_source,
"modelPath": str(model_path), "modelPath": str(model_path),
"image": str(image_path), "image": str(image_path),
"conf": float(args.conf), "conf": float(args.conf),

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

View File

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

View File

@ -1206,6 +1206,13 @@ func recordHLSPlaylistSegments(
shouldFinalize := parentCanceled || endOfStream || finalErr == nil 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 shouldFinalize {
if !fileExistsNonEmpty(outFile) { if !fileExistsNonEmpty(outFile) {
if finalErr != nil { if finalErr != nil {

View File

@ -63,7 +63,11 @@ func RecordStreamMFC(
return appErrorf("mfc status blieb %s für %s", lastStatus, username) 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 { if job != nil {

View File

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

View File

@ -112,6 +112,7 @@ var (
aiServerMu sync.Mutex aiServerMu sync.Mutex
aiServerCurrent *aiServerProcess aiServerCurrent *aiServerProcess
aiServerCtx context.Context aiServerCtx context.Context
aiServerRoot string
) )
func aiServerAutostartEnabled() bool { func aiServerAutostartEnabled() bool {
@ -349,13 +350,17 @@ func findTrainingRootDir(scriptDir string) (trainingRoot string, appBaseDir stri
// Wichtig für Entwicklung: // Wichtig für Entwicklung:
// findAIServerScriptDir() findet meistens dein backend-Verzeichnis. // findAIServerScriptDir() findet meistens dein backend-Verzeichnis.
add(scriptDir) if scriptDir != "" && !isTempBuildDir(scriptDir) {
add(scriptDir)
}
// Falls du aus repo-root oder backend startest. // Falls du aus repo-root oder backend startest.
add(cwd) add(cwd)
// Wichtig für gebaute EXE. // Wichtig für gebaute EXE.
add(exeDir) if exeDir != "" && !isTempBuildDir(exeDir) {
add(exeDir)
}
// Bevorzugt den Pfad, wo detection_labels.json liegt. // Bevorzugt den Pfad, wo detection_labels.json liegt.
for _, c := range candidates { for _, c := range candidates {
@ -472,6 +477,7 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
} }
trainingRoot, appBaseDir := findTrainingRootDir(scriptDir) trainingRoot, appBaseDir := findTrainingRootDir(scriptDir)
aiServerRoot = trainingRoot
mlDir, mlErr := trainingEmbeddedMLDir() mlDir, mlErr := trainingEmbeddedMLDir()
if mlErr != nil { if mlErr != nil {
@ -493,7 +499,8 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
detectionLabelsPath = filepath.Clean(detectionLabelsPath) detectionLabelsPath = filepath.Clean(detectionLabelsPath)
} }
defaultModelPath := filepath.Join(trainingRoot, "detector", "model", "best.pt") defaultModel := trainingResolveDetectorModel(trainingRoot)
defaultModelPath := defaultModel.EffectivePath
pythonPath := aiServerPythonPath() pythonPath := aiServerPythonPath()
port := aiServerPortFromURL() 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() { if fi, err := os.Stat(defaultModelPath); err != nil || fi == nil || fi.IsDir() {
appLogln("⚠️ YOLO Modell nicht gefunden:", defaultModelPath) appLogln("⚠️ YOLO Modell nicht gefunden:", defaultModelPath)
} else { } else {
appLogln("✅ YOLO Modell:", defaultModelPath) appLogln("✅ YOLO Modell:", defaultModelPath, "Quelle:", defaultModel.Source)
} }
if strings.TrimSpace(os.Getenv("YOLO_IMGSZ")) == "" { if strings.TrimSpace(os.Getenv("YOLO_IMGSZ")) == "" {
@ -647,8 +654,10 @@ func restartAIServer() {
return return
} }
appLogln("🔄 AI Server wird neu gestartet…")
publishAppNotification("info", "AI Server", "AI Server wird neu gestartet…", 2200)
if aiServerCurrent != nil { if aiServerCurrent != nil {
appLogln("🔄 AI Server wird neu gestartet…")
aiServerCurrent.Stop() aiServerCurrent.Stop()
aiServerCurrent = nil aiServerCurrent = nil
} }
@ -656,6 +665,7 @@ func restartAIServer() {
proc, err := startAIServer(ctx) proc, err := startAIServer(ctx)
if err != nil { if err != nil {
appLogln("⚠️ AI Server Neustart fehlgeschlagen:", err) appLogln("⚠️ AI Server Neustart fehlgeschlagen:", err)
publishAppNotification("warning", "AI Server", "Neustart fehlgeschlagen: "+err.Error(), 5000)
return return
} }
@ -663,6 +673,97 @@ func restartAIServer() {
if proc != nil { if proc != nil {
appLogln("✅ AI Server neu gestartet.") 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() aiServerMu.Lock()
aiServerCtx = appCtx aiServerCtx = appCtx
aiServerCurrent = aiProc aiServerCurrent = aiProc
watchedTrainingRoot := aiServerRoot
aiServerMu.Unlock() aiServerMu.Unlock()
go startTrainingBestModelWatcher(appCtx, watchedTrainingRoot)
// ✅ Hier: alte manuelle Autostart-Pause beim echten App-Start zurücksetzen. // ✅ Hier: alte manuelle Autostart-Pause beim echten App-Start zurücksetzen.
resetAutostartPauseOnStartup() resetAutostartPauseOnStartup()

View File

@ -23,25 +23,27 @@ type appSSE struct {
var sseApp *appSSE var sseApp *appSSE
type jobEvent struct { type jobEvent struct {
Type string `json:"type"` Type string `json:"type"`
Model string `json:"model"` Model string `json:"model"`
JobID string `json:"jobId"` JobID string `json:"jobId"`
Status JobStatus `json:"status"` Status JobStatus `json:"status"`
Phase string `json:"phase,omitempty"` Phase string `json:"phase,omitempty"`
Progress int `json:"progress,omitempty"` Progress int `json:"progress,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"` StopRequestedAtMs int64 `json:"stopRequestedAtMs,omitempty"`
Output string `json:"output,omitempty"` StopAttempts int `json:"stopAttempts,omitempty"`
StartedAt string `json:"startedAt,omitempty"` SourceURL string `json:"sourceUrl,omitempty"`
StartedAtMs int64 `json:"startedAtMs,omitempty"` Output string `json:"output,omitempty"`
EndedAt string `json:"endedAt,omitempty"` StartedAt string `json:"startedAt,omitempty"`
EndedAtMs int64 `json:"endedAtMs,omitempty"` StartedAtMs int64 `json:"startedAtMs,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"` EndedAt string `json:"endedAt,omitempty"`
DurationSeconds float64 `json:"durationSeconds,omitempty"` EndedAtMs int64 `json:"endedAtMs,omitempty"`
PreviewState string `json:"previewState,omitempty"` SizeBytes int64 `json:"sizeBytes,omitempty"`
RoomStatus string `json:"roomStatus,omitempty"` DurationSeconds float64 `json:"durationSeconds,omitempty"`
IsOnline bool `json:"isOnline,omitempty"` PreviewState string `json:"previewState,omitempty"`
ModelImageURL string `json:"modelImageUrl,omitempty"` RoomStatus string `json:"roomStatus,omitempty"`
ModelChatRoomURL string `json:"modelChatRoomUrl,omitempty"` IsOnline bool `json:"isOnline,omitempty"`
ModelImageURL string `json:"modelImageUrl,omitempty"`
ModelChatRoomURL string `json:"modelChatRoomUrl,omitempty"`
PostWorkKey string `json:"postWorkKey,omitempty"` PostWorkKey string `json:"postWorkKey,omitempty"`
PostWork any `json:"postWork,omitempty"` PostWork any `json:"postWork,omitempty"`
@ -82,6 +84,42 @@ type taskStateEvent struct {
TS int64 `json:"ts"` 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 analysisProgressEvent struct {
Type string `json:"type"` Type string `json:"type"`
Scope string `json:"scope,omitempty"` Scope string `json:"scope,omitempty"`
@ -413,22 +451,24 @@ func visibleJobEventsJSON() []ssePublishItem {
} }
payload := jobEvent{ payload := jobEvent{
Type: "job_upsert", Type: "job_upsert",
Model: eventName, Model: eventName,
JobID: j.ID, JobID: j.ID,
Status: j.Status, Status: j.Status,
Phase: j.Phase, Phase: j.Phase,
Progress: j.Progress, Progress: j.Progress,
SourceURL: j.SourceURL, StopRequestedAtMs: j.StopRequestedAtMs,
Output: j.Output, StopAttempts: j.StopAttempts,
StartedAt: j.StartedAt.Format(time.RFC3339Nano), SourceURL: j.SourceURL,
StartedAtMs: j.StartedAtMs, Output: j.Output,
SizeBytes: j.SizeBytes, StartedAt: j.StartedAt.Format(time.RFC3339Nano),
DurationSeconds: j.DurationSeconds, StartedAtMs: j.StartedAtMs,
PreviewState: j.PreviewState, SizeBytes: j.SizeBytes,
PostWorkKey: strings.TrimSpace(j.PostWorkKey), DurationSeconds: j.DurationSeconds,
PostWork: j.PostWork, PreviewState: j.PreviewState,
TS: 0, // wichtig: stabil halten, damit Dedupe funktioniert PostWorkKey: strings.TrimSpace(j.PostWorkKey),
PostWork: j.PostWork,
TS: 0, // wichtig: stabil halten, damit Dedupe funktioniert
} }
if sm := sseStoredModelForJob(j); sm != nil { if sm := sseStoredModelForJob(j); sm != nil {
@ -478,6 +518,69 @@ func publishJobRemove(j *RecordJob) {
publishSSE(eventName, b) 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 { func sseModelEventNameForJob(j *RecordJob) string {
if j == nil { if j == nil {
return "" return ""

File diff suppressed because it is too large Load Diff

View File

@ -18,6 +18,56 @@ type TrainingGroupedLabels struct {
Clothing []string `json:"clothing"` 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 { func trainingDetectionLabelsPath() string {
if p, err := ensureTrainingDetectionLabelsFile(); err == nil { if p, err := ensureTrainingDetectionLabelsFile(); err == nil {
return p return p
@ -114,13 +164,13 @@ func trainingGroupedLabels() (TrainingGroupedLabels, error) {
} }
grouped.People = uniqueNonEmptyLabels(grouped.People) grouped.People = uniqueNonEmptyLabels(grouped.People)
grouped.SexPositions = uniqueNonEmptyLabels(grouped.SexPositions) grouped.SexPositions = normalizeSexPositionLabels(grouped.SexPositions)
grouped.BodyParts = uniqueNonEmptyLabels(grouped.BodyParts) grouped.BodyParts = uniqueNonEmptyLabels(grouped.BodyParts)
grouped.Objects = uniqueNonEmptyLabels(grouped.Objects) grouped.Objects = uniqueNonEmptyLabels(grouped.Objects)
grouped.Clothing = uniqueNonEmptyLabels(grouped.Clothing) grouped.Clothing = uniqueNonEmptyLabels(grouped.Clothing)
if len(grouped.SexPositions) == 0 { 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 { 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 { for _, label := range grouped.SexPositions {
clean := strings.TrimSpace(label) clean := strings.TrimSpace(label)
if clean == "" || clean == "unknown" { if isNoSexPositionLabel(clean) {
continue continue
} }
@ -205,11 +255,65 @@ func trainingDetectorDatasetNamesYAML() (string, error) {
return b.String(), nil 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 { func defaultTrainingLabelsFromJSON() TrainingLabels {
grouped, err := trainingGroupedLabels() grouped, err := trainingGroupedLabels()
if err != nil { if err != nil {
grouped = TrainingGroupedLabels{ grouped = TrainingGroupedLabels{
SexPositions: []string{"unknown"}, SexPositions: []string{trainingNoSexPositionLabel},
} }
} }

View File

@ -124,8 +124,8 @@ func TestTrainingEffectiveCorrectionClearsNegativeAnnotation(t *testing.T) {
}, },
}) })
if effective.SexPosition != "unknown" { if effective.SexPosition != trainingNoSexPositionLabel {
t.Fatalf("sex position = %q, want unknown", effective.SexPosition) t.Fatalf("sex position = %q, want %s", effective.SexPosition, trainingNoSexPositionLabel)
} }
if len(effective.Boxes) != 0 { if len(effective.Boxes) != 0 {
t.Fatalf("negative annotation has %d boxes, want 0", len(effective.Boxes)) 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 loadJobs()
void loadDoneCount() void loadDoneCount()
void loadTaskStateOnce() void loadTaskStateOnce()
@ -2981,6 +3015,7 @@ export default function App() {
es.addEventListener('taskState', onTaskState as any) es.addEventListener('taskState', onTaskState as any)
es.addEventListener('training', onTraining as any) es.addEventListener('training', onTraining as any)
es.addEventListener('analysisProgress', onAnalysisProgress as any) es.addEventListener('analysisProgress', onAnalysisProgress as any)
es.addEventListener('notification', onNotification as any)
const onVis = () => { const onVis = () => {
if (document.hidden) return if (document.hidden) return
@ -3008,6 +3043,7 @@ export default function App() {
es.removeEventListener('taskState', onTaskState as any) es.removeEventListener('taskState', onTaskState as any)
es.removeEventListener('training', onTraining as any) es.removeEventListener('training', onTraining as any)
es.removeEventListener('analysisProgress', onAnalysisProgress as any) es.removeEventListener('analysisProgress', onAnalysisProgress as any)
es.removeEventListener('notification', onNotification as any)
const handler = onModelJobEventRef.current const handler = onModelJobEventRef.current
if (handler) { if (handler) {
@ -3110,6 +3146,7 @@ export default function App() {
async function stopJob(id: string) { async function stopJob(id: string) {
try { try {
await apiJSON(`/api/record/stop?id=${encodeURIComponent(id)}`, { method: 'POST' }) await apiJSON(`/api/record/stop?id=${encodeURIComponent(id)}`, { method: 'POST' })
void loadJobs()
} catch (e: any) { } catch (e: any) {
notify.error('Stop fehlgeschlagen', e?.message ?? String(e)) notify.error('Stop fehlgeschlagen', e?.message ?? String(e))
} }
@ -3118,6 +3155,7 @@ export default function App() {
async function stopAllJobs() { async function stopAllJobs() {
try { try {
await apiJSON('/api/record/stop-all', { method: 'POST' }) await apiJSON('/api/record/stop-all', { method: 'POST' })
void loadJobs()
} catch (e: any) { } catch (e: any) {
notify.error('Alle stoppen fehlgeschlagen', e?.message ?? String(e)) notify.error('Alle stoppen fehlgeschlagen', e?.message ?? String(e))
} }
@ -3887,6 +3925,7 @@ export default function App() {
}, [autoAddEnabled, autoStartEnabled, startUrl]) }, [autoAddEnabled, autoStartEnabled, startUrl])
const isTrainingTab = selectedTab === 'training' const isTrainingTab = selectedTab === 'training'
const [trainingTabMounted, setTrainingTabMounted] = useState(() => isTrainingTab)
useEffect(() => { useEffect(() => {
if (!isTrainingTab) { if (!isTrainingTab) {
@ -3894,6 +3933,12 @@ export default function App() {
} }
}, [isTrainingTab]) }, [isTrainingTab])
useEffect(() => {
if (isTrainingTab) {
setTrainingTabMounted(true)
}
}, [isTrainingTab])
if (!authChecked) { if (!authChecked) {
return <div className="min-h-[100dvh] grid place-items-center">Lade</div> return <div className="min-h-[100dvh] grid place-items-center">Lade</div>
} }
@ -4236,11 +4281,17 @@ export default function App() {
<ModelsTab onAddToDownloads={handleAddToDownloads} /> <ModelsTab onAddToDownloads={handleAddToDownloads} />
) : null} ) : null}
{selectedTab === 'training' ? ( {trainingTabMounted ? (
<TrainingTab <div
onTrainingRunningChange={setTrainingTabRunning} className={isTrainingTab ? undefined : 'hidden'}
onImageExpandedChange={setTrainingImageExpanded} aria-hidden={isTrainingTab ? undefined : true}
/> >
<TrainingTab
active={isTrainingTab}
onTrainingRunningChange={setTrainingTabRunning}
onImageExpandedChange={setTrainingImageExpanded}
/>
</div>
) : null} ) : null}
{selectedTab === 'categories' ? <CategoriesTab /> : null} {selectedTab === 'categories' ? <CategoriesTab /> : null}

View File

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

View File

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

View File

@ -320,7 +320,7 @@ const formatBytes = (bytes: number | null): string => {
const phaseLabel = (p?: string) => { const phaseLabel = (p?: string) => {
switch ((p ?? '').toLowerCase()) { switch ((p ?? '').toLowerCase()) {
case 'stopping': case 'stopping':
return 'Stop wird angefordert…' return 'Stoppe Aufnahme…'
case 'probe': case 'probe':
return 'Analysiere Datei (Dauer/Streams)…' return 'Analysiere Datei (Dauer/Streams)…'
case 'remuxing': 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 isTerminalStatus = (status?: unknown) => {
const s = String(status ?? '').trim().toLowerCase() const s = String(status ?? '').trim().toLowerCase()
return ( return (
@ -734,13 +773,12 @@ export default function DownloadsCardRow({
const isRecording = phaseLower === 'recording' const isRecording = phaseLower === 'recording'
const isStopRequested = Boolean(stopRequestedIds[j.id]) const isStopRequested = Boolean(stopRequestedIds[j.id])
const rawStatus = String(j.status ?? '').toLowerCase()
const postworkState = getEffectivePostworkState(j) const postworkState = getEffectivePostworkState(j)
const isQueuedPostwork = postworkState === 'queued' const isQueuedPostwork = postworkState === 'queued'
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording' const canRetryStop = canRetryStoppingJob(j, nowMs) && !isStopRequested
const isStopping = isBusyPhase || rawStatus !== 'running' || isStopRequested const isStopping = isStoppingForUi(j, isStopRequested, nowMs)
const showRemoveQueuedButton = isQueuedPostwork const showRemoveQueuedButton = isQueuedPostwork
const disableStopButton = showRemoveQueuedButton ? false : isStopping const disableStopButton = showRemoveQueuedButton ? false : isStopping
@ -768,7 +806,11 @@ export default function DownloadsCardRow({
phaseText = phaseLabel(phase) || postWorkLabel(j, postworkInfoOf(j)) phaseText = phaseLabel(phase) || postWorkLabel(j, postworkInfoOf(j))
} }
} else { } 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 const progressLabel = phaseText || roomStatus
@ -979,7 +1021,11 @@ export default function DownloadsCardRow({
await onStopJob(j.id) await onStopJob(j.id)
}} }}
> >
{showRemoveQueuedButton ? 'Entfernen' : (isStopping ? 'Stoppe…' : 'Stop')} {showRemoveQueuedButton
? 'Entfernen'
: canRetryStop
? 'Stop erneut'
: (isStopping ? 'Stoppe…' : 'Stop')}
</Button> </Button>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

@ -1100,8 +1100,8 @@ const SEGMENT_LABEL_META: SegmentLabelMeta[] = [
icon: DoggyIcon, icon: DoggyIcon,
}, },
{ {
match: ['unknown'], match: ['keine'],
text: 'Unbekannt', text: 'Keine',
icon: UnknownContentIcon, icon: UnknownContentIcon,
}, },
// People // People

View File

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

View File

@ -84,6 +84,19 @@ function clampPercent(value: number) {
return Math.max(0, Math.min(100, value)) 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 { function normalizeBox(box: TrainingFeedbackBox): TrainingFeedbackBox {
const x = clamp01(Number(box.x)) const x = clamp01(Number(box.x))
const y = clamp01(Number(box.y)) const y = clamp01(Number(box.y))
@ -104,7 +117,7 @@ function annotationEffectiveCorrection(
): TrainingFeedbackCorrection { ): TrainingFeedbackCorrection {
if (annotation.negative) { if (annotation.negative) {
return { return {
sexPosition: 'unknown', sexPosition: NO_SEX_POSITION_LABEL,
peoplePresent: [], peoplePresent: [],
bodyPartsPresent: [], bodyPartsPresent: [],
objectsPresent: [], objectsPresent: [],
@ -114,11 +127,14 @@ function annotationEffectiveCorrection(
} }
if (annotation.correction) { if (annotation.correction) {
return annotation.correction return {
...annotation.correction,
sexPosition: normalizeSexPositionValue(annotation.correction.sexPosition),
}
} }
return { return {
sexPosition: annotation.prediction.sexPosition || 'unknown', sexPosition: normalizeSexPositionValue(annotation.prediction.sexPosition),
peoplePresent: (annotation.prediction.peoplePresent ?? []).map((item) => item.label), peoplePresent: (annotation.prediction.peoplePresent ?? []).map((item) => item.label),
bodyPartsPresent: (annotation.prediction.bodyPartsPresent ?? []).map((item) => item.label), bodyPartsPresent: (annotation.prediction.bodyPartsPresent ?? []).map((item) => item.label),
objectsPresent: (annotation.prediction.objectsPresent ?? []).map((item) => item.label), objectsPresent: (annotation.prediction.objectsPresent ?? []).map((item) => item.label),
@ -685,7 +701,7 @@ function FeedbackListItem(props: {
</span> </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"> <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> </span>
</div> </div>
</div> </div>
@ -850,7 +866,7 @@ function ResultLabelsCard(props: {
const groups = [ const groups = [
{ {
title: 'Position', title: 'Position',
values: [props.effective.sexPosition || 'unknown'], values: [normalizeSexPositionValue(props.effective.sexPosition)],
}, },
{ {
title: 'Personen', title: 'Personen',

File diff suppressed because it is too large Load Diff

View File

@ -190,6 +190,8 @@ export type RecordJob = {
phase?: string phase?: string
progress?: number progress?: number
stopRequestedAtMs?: number
stopAttempts?: number
postWorkKey?: string postWorkKey?: string
postWork?: PostWorkKeyStatus postWork?: PostWorkKeyStatus