diff --git a/backend/ai_server.py b/backend/ai_server.py index 77a63c1..cb952ee 100644 --- a/backend/ai_server.py +++ b/backend/ai_server.py @@ -1,6 +1,7 @@ # backend\ai_server.py import json +import math import os import secrets from pathlib import Path @@ -182,6 +183,9 @@ _LABEL_ERROR = "" _DEVICE = os.environ.get("YOLO_DEVICE", "") _CONF = float(os.environ.get("YOLO_CONF", "0.25")) _POSE_CONF = float(os.environ.get("YOLO_POSE_CONF", "0.30")) +_POSE_RELIABLE_MIN_SCORE = float(os.environ.get("YOLO_POSE_RELIABLE_MIN_SCORE", "0.45")) +_POSE_RELIABLE_MIN_KEYPOINTS = int(os.environ.get("YOLO_POSE_RELIABLE_MIN_KEYPOINTS", "6")) +_POSE_RELIABLE_MIN_QUALITY = float(os.environ.get("YOLO_POSE_RELIABLE_MIN_QUALITY", "0.45")) _BATCH = int(os.environ.get("YOLO_BATCH", "16")) _IMGSZ = int(os.environ.get("YOLO_IMGSZ", "640")) _HALF = os.environ.get("YOLO_HALF", "0").lower() in {"1", "true", "yes", "on"} @@ -530,7 +534,7 @@ def pose_persons_from_result(result) -> list[dict]: "conf": kconf, }) - persons.append({ + persons.append(annotate_pose_person_quality({ "label": label, "score": score, "box": { @@ -540,11 +544,679 @@ def pose_persons_from_result(result) -> list[dict]: "h": h, }, "keypoints": keypoints, - }) + })) return persons +def clamp01(value) -> float: + try: + n = float(value) + except Exception: + return 0.0 + + if not math.isfinite(n): + return 0.0 + + return max(0.0, min(1.0, n)) + + +def is_finite01(value) -> bool: + try: + n = float(value) + except Exception: + return False + + return math.isfinite(n) and 0.0 <= n <= 1.0 + + +def normalized_box(box: dict | None) -> dict | None: + if not isinstance(box, dict): + return None + + x = clamp01(box.get("x", 0.0)) + y = clamp01(box.get("y", 0.0)) + w = clamp01(box.get("w", 0.0)) + h = clamp01(box.get("h", 0.0)) + + if w <= 0 or h <= 0: + return None + + if x + w > 1: + w = 1 - x + if y + h > 1: + h = 1 - y + + if w <= 0 or h <= 0: + return None + + out = dict(box) + out.update({"x": x, "y": y, "w": w, "h": h}) + return out + + +def box_area(box: dict) -> float: + clean = normalized_box(box) + if not clean: + return 0.0 + return float(clean["w"]) * float(clean["h"]) + + +def box_center(box: dict) -> tuple[float, float] | None: + clean = normalized_box(box) + if not clean: + return None + return float(clean["x"]) + float(clean["w"]) / 2.0, float(clean["y"]) + float(clean["h"]) / 2.0 + + +def box_gap(a: dict, b: dict) -> float: + a = normalized_box(a) + b = normalized_box(b) + if not a or not b: + return 1.0 + + dx = max(0.0, max(float(a["x"]) - (float(b["x"]) + float(b["w"])), float(b["x"]) - (float(a["x"]) + float(a["w"])))) + dy = max(0.0, max(float(a["y"]) - (float(b["y"]) + float(b["h"])), float(b["y"]) - (float(a["y"]) + float(a["h"])))) + return math.sqrt(dx * dx + dy * dy) + + +def box_overlap_ratio(a: dict, b: dict) -> float: + a = normalized_box(a) + b = normalized_box(b) + if not a or not b: + return 0.0 + + left = max(float(a["x"]), float(b["x"])) + top = max(float(a["y"]), float(b["y"])) + right = min(float(a["x"]) + float(a["w"]), float(b["x"]) + float(b["w"])) + bottom = min(float(a["y"]) + float(a["h"]), float(b["y"]) + float(b["h"])) + + if right <= left or bottom <= top: + return 0.0 + + min_area = min(box_area(a), box_area(b)) + if min_area <= 0: + return 0.0 + + return clamp01(((right - left) * (bottom - top)) / min_area) + + +def box_horizontal_overlap_ratio(a: dict, b: dict) -> float: + a = normalized_box(a) + b = normalized_box(b) + if not a or not b: + return 0.0 + + left = max(float(a["x"]), float(b["x"])) + right = min(float(a["x"]) + float(a["w"]), float(b["x"]) + float(b["w"])) + if right <= left: + return 0.0 + + min_width = min(float(a["w"]), float(b["w"])) + if min_width <= 0: + return 0.0 + + return clamp01((right - left) / min_width) + + +def is_person_like_label(label: str) -> bool: + clean = str(label or "").strip().lower() + return clean == "person" or clean.startswith("person_") + + +def boxes_by_label(prediction: dict, *labels: str) -> list[dict]: + wanted = {str(label).strip().lower() for label in labels if str(label).strip()} + out = [] + + for box in prediction.get("boxes", []) or []: + label = str(box.get("label", "")).strip().lower() if isinstance(box, dict) else "" + if label not in wanted: + continue + + clean = normalized_box(box) + if clean: + out.append(clean) + + return out + + +def any_boxes_near(left_boxes: list[dict], right_boxes: list[dict], margin: float) -> bool: + for left in left_boxes: + for right in right_boxes: + if box_gap(left, right) <= margin or box_overlap_ratio(left, right) > 0: + return True + return False + + +def point_near_box(x: float, y: float, box: dict, margin: float) -> bool: + if not is_finite01(x) or not is_finite01(y): + return False + + clean = normalized_box(box) + if not clean: + return False + + return ( + float(clean["x"]) - margin <= x <= float(clean["x"]) + float(clean["w"]) + margin + and float(clean["y"]) - margin <= y <= float(clean["y"]) + float(clean["h"]) + margin + ) + + +def any_pose_keypoint_near_boxes( + persons: list[dict], + keypoint_names: list[str], + boxes: list[dict], + margin: float, +) -> bool: + if not boxes: + return False + + wanted = {str(name).strip().lower() for name in keypoint_names} + + for person in reliable_pose_persons(persons): + for point in person.get("keypoints", []) or []: + name = str(point.get("name", "")).strip().lower() + if name not in wanted: + continue + if float(point.get("conf") or 0.0) < 0.20: + continue + + x = float(point.get("x") or 0.0) + y = float(point.get("y") or 0.0) + + for box in boxes: + if point_near_box(x, y, box, margin): + return True + + return False + + +def pose_keypoint_stats(person: dict) -> tuple[int, float]: + keypoints = person.get("keypoints", []) or [] + if not keypoints: + return 0, 0.0 + + visible = 0 + total_conf = 0.0 + + for point in keypoints: + x = float(point.get("x") or 0.0) + y = float(point.get("y") or 0.0) + conf = float(point.get("conf") or 0.0) + + if conf < 0.20 or not is_finite01(x) or not is_finite01(y): + continue + + visible += 1 + total_conf += clamp01(conf) + + if visible == 0: + return 0, 0.0 + + coverage = clamp01(visible / max(1, len(KEYPOINT_NAMES))) + avg_conf = clamp01(total_conf / visible) + return visible, clamp01(coverage * 0.45 + avg_conf * 0.55) + + +def pose_keypoint_quality(person: dict) -> float: + _, quality = pose_keypoint_stats(person) + return quality + + +def annotate_pose_person_quality(person: dict) -> dict: + visible, quality = pose_keypoint_stats(person) + score = clamp01(float(person.get("score") or 0.0)) + + person["visibleKeypoints"] = visible + person["quality"] = quality + person["reliable"] = ( + score >= _POSE_RELIABLE_MIN_SCORE + and visible >= _POSE_RELIABLE_MIN_KEYPOINTS + and quality >= _POSE_RELIABLE_MIN_QUALITY + ) + + return person + + +def reliable_pose_persons(persons: list[dict]) -> list[dict]: + out = [] + + for person in persons: + if not isinstance(person, dict): + continue + + if "reliable" not in person: + annotate_pose_person_quality(person) + + if bool(person.get("reliable")): + out.append(person) + + return out + + +def scene_person_boxes(prediction: dict, persons: list[dict]) -> list[dict]: + detector_boxes = [] + pose_boxes = [] + + for box in prediction.get("boxes", []) or []: + if not isinstance(box, dict) or not is_person_like_label(box.get("label", "")): + continue + + clean = normalized_box(box) + if clean: + detector_boxes.append(clean) + + for person in reliable_pose_persons(persons): + clean = normalized_box(person.get("box")) + if clean: + pose_boxes.append(clean) + + if detector_boxes and len(detector_boxes) >= len(pose_boxes): + return detector_boxes + + return pose_boxes + + +def scene_person_pair_signals(boxes: list[dict]) -> dict: + signals = { + "close": False, + "overlap": False, + "horizontal": False, + "vertical": False, + "stacked": False, + } + + for i, left in enumerate(boxes): + left = normalized_box(left) + if not left: + continue + + for raw_right in boxes[i + 1:]: + right = normalized_box(raw_right) + if not right: + continue + + gap = box_gap(left, right) + overlap = box_overlap_ratio(left, right) + close = gap <= 0.08 or overlap >= 0.08 + + if close: + signals["close"] = True + if overlap >= 0.18: + signals["overlap"] = True + if close and float(left["w"]) > float(left["h"]) * 1.15 and float(right["w"]) > float(right["h"]) * 1.15: + signals["horizontal"] = True + if close and float(left["h"]) > float(left["w"]) * 1.25 and float(right["h"]) > float(right["w"]) * 1.25: + signals["vertical"] = True + + left_center = box_center(left) + right_center = box_center(right) + if ( + left_center + and right_center + and gap <= 0.12 + and box_horizontal_overlap_ratio(left, right) >= 0.35 + and abs(left_center[1] - right_center[1]) >= 0.12 + ): + signals["stacked"] = True + + return signals + + +def pose_point(person: dict, name: str, min_conf: float = 0.20) -> tuple[float, float] | None: + wanted = str(name or "").strip().lower() + if not wanted: + return None + + for point in person.get("keypoints", []) or []: + point_name = str(point.get("name", "")).strip().lower() + if point_name != wanted: + continue + + conf = float(point.get("conf") or 0.0) + x = float(point.get("x") or 0.0) + y = float(point.get("y") or 0.0) + + if conf >= min_conf and is_finite01(x) and is_finite01(y): + return x, y + + return None + + +def pose_midpoint( + person: dict, + names: list[str], + min_conf: float = 0.20, + min_points: int = 1, +) -> tuple[float, float] | None: + points = [ + point + for name in names + if (point := pose_point(person, name, min_conf)) is not None + ] + + if len(points) < min_points: + return None + + x = sum(point[0] for point in points) / len(points) + y = sum(point[1] for point in points) / len(points) + return x, y + + +def point_distance(a: tuple[float, float] | None, b: tuple[float, float] | None) -> float: + if not a or not b: + return 0.0 + + return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) + + +def pose_person_geometry(person: dict) -> dict: + box = normalized_box(person.get("box")) + box_center_point = box_center(box) if box else None + + left_hip = pose_point(person, "left_hip") + right_hip = pose_point(person, "right_hip") + left_knee = pose_point(person, "left_knee") + right_knee = pose_point(person, "right_knee") + + hip = pose_midpoint(person, ["left_hip", "right_hip"]) + shoulder = pose_midpoint(person, ["left_shoulder", "right_shoulder"]) + knee = pose_midpoint(person, ["left_knee", "right_knee"]) + head = pose_midpoint(person, ["nose", "left_eye", "right_eye", "left_ear", "right_ear"]) + + center = hip or box_center_point or (0.5, 0.5) + + torso_dx = 0.0 + torso_dy = 0.0 + torso_len = 0.0 + if hip and shoulder: + torso_dx = abs(hip[0] - shoulder[0]) + torso_dy = abs(hip[1] - shoulder[1]) + torso_len = math.sqrt(torso_dx * torso_dx + torso_dy * torso_dy) + + hip_width = point_distance(left_hip, right_hip) + knee_width = point_distance(left_knee, right_knee) + + knees_below_hips = bool(knee and hip and knee[1] > hip[1] + 0.045) + knees_wide = bool( + knee_width > 0 + and knee_width >= max(0.11, hip_width * 1.12) + ) + straddling = knees_below_hips and knees_wide + + torso_horizontal = torso_len >= 0.07 and torso_dx >= torso_dy * 1.15 + torso_vertical = torso_len >= 0.07 and torso_dy >= torso_dx * 1.15 + + box_horizontal = bool(box and float(box["w"]) >= float(box["h"]) * 1.05) + box_vertical = bool(box and float(box["h"]) >= float(box["w"]) * 1.25) + + lying = torso_horizontal or box_horizontal + upright = torso_vertical or box_vertical + all_fours = torso_horizontal and knees_below_hips + bent_or_kneeling = all_fours or (knees_below_hips and not straddling) + + return { + "box": box, + "center": center, + "head": head, + "hip": hip, + "shoulder": shoulder, + "knee": knee, + "lying": lying, + "upright": upright, + "straddling": straddling, + "knees_below_hips": knees_below_hips, + "knees_wide": knees_wide, + "all_fours": all_fours, + "bent_or_kneeling": bent_or_kneeling, + } + + +def combine_position_score(scores: dict[str, float], label: str, score: float) -> None: + label = normalize_sex_position_label(label) + if is_no_sex_position_label(label) or label not in POSITION_LABELS or score <= 0: + return + + score = clamp01(score) + current = clamp01(scores.get(label, 0.0)) + scores[label] = clamp01(1 - (1 - current) * (1 - score)) + + +def add_pose_pair_geometry_scores(scores: dict[str, float], persons: list[dict]) -> None: + def add(label: str, score: float) -> None: + combine_position_score(scores, label, score) + + reliable_persons = reliable_pose_persons(persons) + if len(reliable_persons) < 2: + return + + geometries = [pose_person_geometry(person) for person in reliable_persons] + + for i, left in enumerate(geometries): + left_box = left["box"] + if not left_box: + continue + + for right in geometries[i + 1:]: + right_box = right["box"] + if not right_box: + continue + + gap = box_gap(left_box, right_box) + overlap = box_overlap_ratio(left_box, right_box) + horizontal_overlap = box_horizontal_overlap_ratio(left_box, right_box) + close = gap <= 0.12 or overlap >= 0.08 + + if not close: + continue + + left_center = left["center"] + right_center = right["center"] + top = left if left_center[1] <= right_center[1] else right + bottom = right if top is left else left + + top_above = top["center"][1] <= bottom["center"][1] - 0.055 + strong_stack = horizontal_overlap >= 0.35 and (top_above or overlap >= 0.22) + top_has_rider_shape = ( + top["straddling"] + or top["knees_wide"] + or (top["upright"] and top["knees_below_hips"]) + ) + + if strong_stack and top_has_rider_shape: + add("cowgirl", 0.20) + add("reverse_cowgirl", 0.17) + + if bottom["lying"]: + add("cowgirl", 0.12) + add("reverse_cowgirl", 0.10) + if top["straddling"]: + add("cowgirl", 0.08) + add("reverse_cowgirl", 0.06) + + if strong_stack and bottom["lying"]: + add("missionary", 0.14) + if not top["straddling"]: + add("missionary", 0.08) + + both_lying = left["lying"] and right["lying"] + same_level = abs(left_center[1] - right_center[1]) <= 0.15 + side_by_side = abs(left_center[0] - right_center[0]) >= 0.10 + + if both_lying and (same_level or side_by_side): + add("spooning", 0.18) + if overlap >= 0.10: + add("prone_bone", 0.07) + + left_bent = left["all_fours"] or left["bent_or_kneeling"] + right_bent = right["all_fours"] or right["bent_or_kneeling"] + + if left_bent != right_bent: + other = right if left_bent else left + add("doggy", 0.16) + if other["upright"]: + add("doggy", 0.06) + add("standing_doggy", 0.07) + if left["lying"] or right["lying"]: + add("prone_bone", 0.06) + elif left["all_fours"] or right["all_fours"]: + add("doggy", 0.13) + if left["lying"] or right["lying"]: + add("prone_bone", 0.07) + + +def add_pose_scene_context_scores(scores: dict[str, float], prediction: dict, persons: list[dict]) -> None: + def add(label: str, score: float) -> None: + combine_position_score(scores, label, score) + + reliable_persons = reliable_pose_persons(persons) + person_boxes = scene_person_boxes(prediction, reliable_persons) + person_count = max(len(person_boxes), len(reliable_persons)) + pair = scene_person_pair_signals(person_boxes) + add_pose_pair_geometry_scores(scores, reliable_persons) + + penis_boxes = boxes_by_label(prediction, "penis") + pussy_boxes = boxes_by_label(prediction, "pussy", "vagina", "vulva", "labia") + ass_boxes = boxes_by_label(prediction, "ass", "anus") + breast_boxes = boxes_by_label(prediction, "breasts") + tongue_boxes = boxes_by_label(prediction, "tongue") + toy_boxes = boxes_by_label(prediction, "dildo", "vibrator", "strapon", "buttplug") + + has_penis = bool(penis_boxes) + has_pussy = bool(pussy_boxes) + has_ass = bool(ass_boxes) + has_breasts = bool(breast_boxes) + has_tongue = bool(tongue_boxes) + has_toy = bool(toy_boxes) + + head_names = ["nose", "left_eye", "right_eye", "left_ear", "right_ear"] + hand_names = ["left_wrist", "right_wrist"] + hip_names = ["left_hip", "right_hip"] + + head_near_penis = any_pose_keypoint_near_boxes(reliable_persons, head_names, penis_boxes, 0.09) + head_near_pussy = any_pose_keypoint_near_boxes(reliable_persons, head_names, pussy_boxes, 0.09) + head_near_ass = any_pose_keypoint_near_boxes(reliable_persons, head_names, ass_boxes, 0.09) + hand_near_penis = any_pose_keypoint_near_boxes(reliable_persons, hand_names, penis_boxes, 0.08) + hand_near_pussy = any_pose_keypoint_near_boxes(reliable_persons, hand_names, pussy_boxes, 0.08) + hand_near_toy = any_pose_keypoint_near_boxes(reliable_persons, hand_names, toy_boxes, 0.08) + hips_near_genitals = any_pose_keypoint_near_boxes(reliable_persons, hip_names, penis_boxes + pussy_boxes, 0.08) + + if person_count >= 2: + for label, score in [ + ("missionary", 0.04), + ("doggy", 0.04), + ("cowgirl", 0.04), + ("reverse_cowgirl", 0.04), + ("standing_doggy", 0.04), + ("spooning", 0.04), + ("69", 0.03), + ]: + add(label, score) + + if pair["close"]: + for label in ["missionary", "doggy", "cowgirl", "spooning"]: + add(label, 0.04) + if pair["overlap"]: + add("missionary", 0.05) + add("cowgirl", 0.05) + add("prone_bone", 0.04) + if pair["horizontal"]: + add("spooning", 0.12) + add("prone_bone", 0.07) + if pair["vertical"]: + add("standing", 0.08) + add("standing_doggy", 0.10) + if pair["stacked"]: + add("missionary", 0.07) + add("cowgirl", 0.07) + add("reverse_cowgirl", 0.06) + add("facesitting", 0.05) + + if has_penis and has_pussy: + for label, score in [ + ("missionary", 0.08), + ("doggy", 0.08), + ("cowgirl", 0.08), + ("reverse_cowgirl", 0.07), + ("prone_bone", 0.06), + ("standing_doggy", 0.06), + ("spooning", 0.05), + ]: + add(label, score) + + if any_boxes_near(penis_boxes, pussy_boxes, 0.09) or hips_near_genitals: + add("missionary", 0.08) + add("doggy", 0.08) + add("cowgirl", 0.08) + add("reverse_cowgirl", 0.07) + + if has_penis and (head_near_penis or has_tongue): + add("blowjob", 0.16) + if head_near_penis: + add("blowjob", 0.10) + if has_pussy and (head_near_pussy or has_tongue or any_boxes_near(tongue_boxes, pussy_boxes, 0.08)): + add("cunnilingus", 0.16) + if head_near_pussy or any_boxes_near(tongue_boxes, pussy_boxes, 0.08): + add("cunnilingus", 0.10) + if has_penis and hand_near_penis: + add("handjob", 0.18) + if has_pussy and hand_near_pussy: + add("fingering", 0.18) + if has_penis and has_breasts and any_boxes_near(penis_boxes, breast_boxes, 0.10): + add("boobjob", 0.20) + if has_toy: + add("toy_play", 0.12) + if ( + hand_near_toy + or any_boxes_near(toy_boxes, pussy_boxes, 0.10) + or any_boxes_near(toy_boxes, penis_boxes, 0.10) + or any_boxes_near(toy_boxes, ass_boxes, 0.10) + ): + add("toy_play", 0.12) + if person_count >= 2 and (head_near_ass or head_near_pussy): + if has_ass: + add("facesitting", 0.14) + if has_pussy and has_penis: + add("69", 0.10) + if has_ass and has_pussy and pair["horizontal"]: + add("doggy", 0.07) + add("prone_bone", 0.07) + + +def best_pose_scene_position(prediction: dict, persons: list[dict]) -> tuple[str, float]: + scores: dict[str, float] = {} + has_direct_pose_position = False + reliable_persons = reliable_pose_persons(persons) + + for person in reliable_persons: + label = normalize_sex_position_label(person.get("label")) + score = float(person.get("score") or 0.0) + + if is_no_sex_position_label(label) or label not in POSITION_LABELS: + continue + + has_direct_pose_position = True + combine_position_score(scores, label, score) + + quality = pose_keypoint_quality(person) + if quality > 0: + combine_position_score(scores, label, 0.04 * quality) + + add_pose_scene_context_scores(scores, prediction, reliable_persons) + + best_position = "" + best_score = 0.0 + for label, score in scores.items(): + if score > best_score: + best_position = label + best_score = score + + if best_position and (has_direct_pose_position or best_score >= 0.30): + return best_position, clamp01(best_score) + + return "", 0.0 + + def apply_pose_result_to_prediction(prediction: dict, result) -> dict: persons = pose_persons_from_result(result) if not persons: @@ -552,19 +1224,7 @@ def apply_pose_result_to_prediction(prediction: dict, result) -> dict: 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 + best_position, best_score_value = best_pose_scene_position(prediction, persons) if best_position: prediction["sexPosition"] = best_position diff --git a/backend/data/pending-autostart/admin.json b/backend/data/pending-autostart/admin.json new file mode 100644 index 0000000..fc69ce2 --- /dev/null +++ b/backend/data/pending-autostart/admin.json @@ -0,0 +1,3 @@ +{ + "items": [] +} \ No newline at end of file diff --git a/backend/ml/predict_pose_model.py b/backend/ml/predict_pose_model.py index eac8988..a2f8d44 100644 --- a/backend/ml/predict_pose_model.py +++ b/backend/ml/predict_pose_model.py @@ -2,6 +2,7 @@ import argparse import json +import math from pathlib import Path from PIL import Image @@ -21,6 +22,73 @@ KEYPOINT_NAMES = [ ] BASE_MODEL_NAME = "yolo26n-pose.pt" +POSE_KEYPOINT_MIN_CONFIDENCE = 0.20 +POSE_RELIABLE_MIN_SCORE = 0.30 +POSE_RELIABLE_MIN_KEYPOINTS = 6 +POSE_RELIABLE_MIN_QUALITY = 0.45 + + +def clamp01(value): + try: + n = float(value) + except Exception: + return 0.0 + + if not math.isfinite(n): + return 0.0 + + return max(0.0, min(1.0, n)) + + +def is_finite01(value): + try: + n = float(value) + except Exception: + return False + + return math.isfinite(n) and 0.0 <= n <= 1.0 + + +def pose_keypoint_stats(person): + keypoints = person.get("keypoints", []) or [] + if not keypoints: + return 0, 0.0 + + visible = 0 + total_conf = 0.0 + + for point in keypoints: + x = point.get("x", 0.0) + y = point.get("y", 0.0) + conf = float(point.get("conf") or 0.0) + + if conf < POSE_KEYPOINT_MIN_CONFIDENCE or not is_finite01(x) or not is_finite01(y): + continue + + visible += 1 + total_conf += clamp01(conf) + + if visible == 0: + return 0, 0.0 + + coverage = clamp01(visible / max(1, len(KEYPOINT_NAMES))) + avg_conf = clamp01(total_conf / visible) + return visible, clamp01(coverage * 0.45 + avg_conf * 0.55) + + +def annotate_pose_person_quality(person): + visible, quality = pose_keypoint_stats(person) + score = clamp01(person.get("score", 0.0)) + + person["visibleKeypoints"] = visible + person["quality"] = quality + person["reliable"] = ( + score >= POSE_RELIABLE_MIN_SCORE + and visible >= POSE_RELIABLE_MIN_KEYPOINTS + and quality >= POSE_RELIABLE_MIN_QUALITY + ) + + return person def existing_file(path): @@ -184,12 +252,12 @@ def main(): "conf": kconf, }) - persons.append({ + persons.append(annotate_pose_person_quality({ "label": label, "score": score, "box": box, "keypoints": keypoints, - }) + })) print(json.dumps({ "available": True, diff --git a/backend/record.go b/backend/record.go index 1e36c1f..39b1018 100644 --- a/backend/record.go +++ b/backend/record.go @@ -240,10 +240,33 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) { From string `json:"from,omitempty"` } + streamResults := strings.TrimSpace(r.URL.Query().Get("stream")) == "1" + var streamEnc *json.Encoder + var streamFlusher http.Flusher + + if streamResults { + w.Header().Set("Content-Type", "application/x-ndjson") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + streamEnc = json.NewEncoder(w) + streamFlusher, _ = w.(http.Flusher) + } + results := make([]itemResult, 0, len(req.Files)) deletedCount := 0 seen := make(map[string]struct{}, len(req.Files)) + addResult := func(result itemResult) { + results = append(results, result) + + if streamResults && streamEnc != nil { + _ = streamEnc.Encode(result) + if streamFlusher != nil { + streamFlusher.Flush() + } + } + } + for _, raw := range req.Files { file := strings.TrimSpace(raw) if file == "" { @@ -255,7 +278,7 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) { seen[file] = struct{}{} if !isAllowedVideoExt(file) { - results = append(results, itemResult{ + addResult(itemResult{ File: file, OK: false, Error: "nicht erlaubt", @@ -268,7 +291,7 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) { target, from, fi, err := resolveDoneFileByName(doneAbs, file) if err != nil { - results = append(results, itemResult{ + addResult(itemResult{ File: file, OK: false, Error: "datei nicht gefunden", @@ -276,7 +299,7 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) { continue } if fi != nil && fi.IsDir() { - results = append(results, itemResult{ + addResult(itemResult{ File: file, OK: false, Error: "ist ein verzeichnis", @@ -287,7 +310,7 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) { if err := removeWithRetry(target); err != nil { if runtime.GOOS == "windows" && isSharingViolation(err) { - results = append(results, itemResult{ + addResult(itemResult{ File: file, OK: false, Error: "datei wird gerade verwendet", @@ -296,7 +319,7 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) { continue } - results = append(results, itemResult{ + addResult(itemResult{ File: file, OK: false, Error: err.Error(), @@ -323,7 +346,7 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) { } deletedCount++ - results = append(results, itemResult{ + addResult(itemResult{ File: file, OK: true, From: from, @@ -334,6 +357,20 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) { notifyDoneChanged() } + if streamResults { + if streamEnc != nil { + _ = streamEnc.Encode(map[string]any{ + "done": true, + "ok": deletedCount > 0, + "deleted": deletedCount, + }) + if streamFlusher != nil { + streamFlusher.Flush() + } + } + return + } + w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "ok": deletedCount > 0, diff --git a/backend/training.go b/backend/training.go index 61287dd..a89df0a 100644 --- a/backend/training.go +++ b/backend/training.go @@ -148,10 +148,13 @@ type TrainingKeypoint struct { } type TrainingPosePerson struct { - Label string `json:"label,omitempty"` - Score float64 `json:"score"` - Box TrainingBox `json:"box"` - Keypoints []TrainingKeypoint `json:"keypoints"` + Label string `json:"label,omitempty"` + Score float64 `json:"score"` + Box TrainingBox `json:"box"` + Keypoints []TrainingKeypoint `json:"keypoints"` + Quality float64 `json:"quality,omitempty"` + VisibleKeypoints int `json:"visibleKeypoints,omitempty"` + Reliable bool `json:"reliable,omitempty"` } type TrainingPosePrediction struct { @@ -1042,6 +1045,14 @@ const minDetectorValCount = 3 const minPoseTrainCount = 20 const minPoseValCount = 3 const trainingPoseKeypointCount = 17 +const trainingPoseKeypointMinConfidence = 0.20 +const trainingPoseReliableMinScore = 0.30 +const trainingPoseReliableMinKeypoints = 6 +const trainingPoseReliableMinQuality = 0.45 +const trainingPositionContextMinScore = 0.22 +const trainingPositionContextMaxScore = 0.44 +const trainingPositionContextBoostWeight = 0.60 +const trainingPositionContextOverrideMargin = 0.16 var errTrainingCancelled = errors.New("training cancelled") @@ -2107,7 +2118,7 @@ func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) { trainingDeletePoseSample(root, req.SampleID) if sexPosition := trainingSexPositionForFeedback(sample, req); !isNoSexPositionLabel(sexPosition) { - if err := trainingWritePoseSample(root, sample, sexPosition); err != nil { + if err := trainingWritePoseSample(root, sample, sexPosition, detectorBoxes); err != nil { appLogln("pose sample write failed:", err) } } @@ -2241,7 +2252,7 @@ func trainingFeedbackUpdateHandler(w http.ResponseWriter, r *http.Request) { Correction: req.Correction, Notes: req.Notes, }); !isNoSexPositionLabel(sexPosition) { - if err := trainingWritePoseSample(root, sample, sexPosition); err != nil { + if err := trainingWritePoseSample(root, sample, sexPosition, detectorBoxes); err != nil { appLogln("pose sample update failed:", err) } } @@ -4798,43 +4809,928 @@ func trainingPredictPose(root string, framePath string) TrainingPosePrediction { pose.Persons = []TrainingPosePerson{} } + pose = trainingAnnotatePosePrediction(pose) pose.Source = model.Source return pose } -func trainingApplyPoseToPrediction(pred TrainingPrediction, pose TrainingPosePrediction) TrainingPrediction { - if !pose.Available || len(pose.Persons) == 0 { - return pred +func trainingCombinePositionScore(scores map[string]float64, positionSet map[string]bool, label string, score float64) { + label = normalizeSexPositionLabel(label) + if isNoSexPositionLabel(label) || !positionSet[label] || score <= 0 { + return } - pred.Persons = pose.Persons + score = clamp01(score) + current := clamp01(scores[label]) + scores[label] = clamp01(1 - (1-current)*(1-score)) +} + +func trainingAppendPredictionSource(pred *TrainingPrediction, source string) { + source = strings.TrimSpace(source) + if source == "" { + return + } + + current := strings.TrimSpace(pred.Source) + if current == "" { + pred.Source = source + return + } + + for _, part := range strings.Split(current, "+") { + if strings.TrimSpace(part) == source { + return + } + } + + pred.Source = current + "+" + source +} + +func trainingFuseHybridPositionScores( + poseScores map[string]float64, + contextScores map[string]float64, +) (string, float64, bool, bool) { + labels := map[string]bool{} + + for label := range poseScores { + if !isNoSexPositionLabel(label) { + labels[label] = true + } + } + + for label := range contextScores { + if !isNoSexPositionLabel(label) { + labels[label] = true + } + } - positionSet := stringSet(defaultTrainingLabelsFromJSON().SexPositions) bestPosition := "" bestPositionScore := 0.0 + bestHasPose := false + bestHasContext := false + bestPosePosition := "" + bestPoseScore := 0.0 + bestPoseHasContext := false + + for label := range labels { + poseScore := clamp01(poseScores[label]) + contextScore := clamp01(contextScores[label]) + + score := 0.0 + hasPose := poseScore > 0 + hasContext := contextScore > 0 + + if hasPose { + score = poseScore + + // Kontext boostet die Pose, bleibt aber bewusst Nebeninformation. + if hasContext { + boost := clamp01(contextScore * trainingPositionContextBoostWeight) + score = clamp01(1 - (1-score)*(1-boost)) + } + } else if contextScore >= trainingPositionContextMinScore { + // Reiner Box-/Szenen-Kontext darf eine unsichere Prediction liefern, + // aber keine hohe Sicherheit vortaeuschen. + score = math.Min(trainingPositionContextMaxScore, contextScore) + } + + if score > bestPositionScore { + bestPosition = label + bestPositionScore = score + bestHasPose = hasPose + bestHasContext = hasContext + } + + if hasPose && score > bestPoseScore { + bestPosePosition = label + bestPoseScore = score + bestPoseHasContext = hasContext + } + } + + if bestPosePosition != "" && + !bestHasPose && + bestPositionScore <= bestPoseScore+trainingPositionContextOverrideMargin { + bestPosition = bestPosePosition + bestPositionScore = bestPoseScore + bestHasPose = true + bestHasContext = bestPoseHasContext + } + + return bestPosition, clamp01(bestPositionScore), bestHasPose, bestHasContext +} + +func trainingIsFinite01(v float64) bool { + return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 && v <= 1 +} + +func trainingNormalizedBox(box TrainingBox) (TrainingBox, bool) { + x := clamp01(box.X) + y := clamp01(box.Y) + w := clamp01(box.W) + h := clamp01(box.H) + + if w <= 0 || h <= 0 { + return TrainingBox{}, false + } + + if x+w > 1 { + w = 1 - x + } + if y+h > 1 { + h = 1 - y + } + + if w <= 0 || h <= 0 { + return TrainingBox{}, false + } + + box.X = x + box.Y = y + box.W = w + box.H = h + return box, true +} + +func trainingBoxArea(box TrainingBox) float64 { + box, ok := trainingNormalizedBox(box) + if !ok { + return 0 + } + + return box.W * box.H +} + +func trainingBoxCenter(box TrainingBox) (float64, float64, bool) { + box, ok := trainingNormalizedBox(box) + if !ok { + return 0, 0, false + } + + return box.X + box.W/2, box.Y + box.H/2, true +} + +func trainingBoxGap(a TrainingBox, b TrainingBox) float64 { + a, okA := trainingNormalizedBox(a) + b, okB := trainingNormalizedBox(b) + if !okA || !okB { + return 1 + } + + dx := math.Max(0, math.Max(a.X-(b.X+b.W), b.X-(a.X+a.W))) + dy := math.Max(0, math.Max(a.Y-(b.Y+b.H), b.Y-(a.Y+a.H))) + + return math.Sqrt(dx*dx + dy*dy) +} + +func trainingBoxOverlapRatio(a TrainingBox, b TrainingBox) float64 { + a, okA := trainingNormalizedBox(a) + b, okB := trainingNormalizedBox(b) + if !okA || !okB { + return 0 + } + + left := math.Max(a.X, b.X) + top := math.Max(a.Y, b.Y) + right := math.Min(a.X+a.W, b.X+b.W) + bottom := math.Min(a.Y+a.H, b.Y+b.H) + + if right <= left || bottom <= top { + return 0 + } + + intersection := (right - left) * (bottom - top) + minArea := math.Min(trainingBoxArea(a), trainingBoxArea(b)) + if minArea <= 0 { + return 0 + } + + return clamp01(intersection / minArea) +} + +func trainingBoxHorizontalOverlapRatio(a TrainingBox, b TrainingBox) float64 { + a, okA := trainingNormalizedBox(a) + b, okB := trainingNormalizedBox(b) + if !okA || !okB { + return 0 + } + + left := math.Max(a.X, b.X) + right := math.Min(a.X+a.W, b.X+b.W) + if right <= left { + return 0 + } + + minWidth := math.Min(a.W, b.W) + if minWidth <= 0 { + return 0 + } + + return clamp01((right - left) / minWidth) +} + +func trainingBoxesByLabel(boxes []TrainingBox, labels ...string) []TrainingBox { + wanted := map[string]bool{} + for _, label := range labels { + clean := strings.TrimSpace(label) + if clean != "" { + wanted[clean] = true + } + } + + out := []TrainingBox{} + for _, box := range boxes { + label := strings.TrimSpace(box.Label) + if !wanted[label] { + continue + } + if normalized, ok := trainingNormalizedBox(box); ok { + out = append(out, normalized) + } + } + + return out +} + +func trainingAnyBoxesNear(a []TrainingBox, b []TrainingBox, margin float64) bool { + for _, left := range a { + for _, right := range b { + if trainingBoxGap(left, right) <= margin || trainingBoxOverlapRatio(left, right) > 0 { + return true + } + } + } + + return false +} + +func trainingPointNearBox(x float64, y float64, box TrainingBox, margin float64) bool { + if !trainingIsFinite01(x) || !trainingIsFinite01(y) { + return false + } + + box, ok := trainingNormalizedBox(box) + if !ok { + return false + } + + return x >= box.X-margin && + x <= box.X+box.W+margin && + y >= box.Y-margin && + y <= box.Y+box.H+margin +} + +func trainingAnyPoseKeypointNearBoxes( + pose TrainingPosePrediction, + keypointNames []string, + boxes []TrainingBox, + margin float64, +) bool { + if len(boxes) == 0 { + return false + } + + nameSet := stringSet(keypointNames) for _, person := range pose.Persons { - label := strings.TrimSpace(person.Label) + if !trainingPosePersonReliable(person) { + continue + } + + for _, point := range person.Keypoints { + if !nameSet[strings.TrimSpace(point.Name)] { + continue + } + if point.Conf < trainingPoseKeypointMinConfidence { + continue + } + + for _, box := range boxes { + if trainingPointNearBox(point.X, point.Y, box, margin) { + return true + } + } + } + } + + return false +} + +func trainingPoseKeypointStats(person TrainingPosePerson) (int, float64) { + if len(person.Keypoints) == 0 { + return 0, 0 + } + + visible := 0 + totalConf := 0.0 + + for _, point := range person.Keypoints { + if point.Conf < trainingPoseKeypointMinConfidence || + !trainingIsFinite01(point.X) || + !trainingIsFinite01(point.Y) { + continue + } + + visible++ + totalConf += clamp01(point.Conf) + } + + if visible == 0 { + return 0, 0 + } + + coverage := clamp01(float64(visible) / float64(trainingPoseKeypointCount)) + avgConf := clamp01(totalConf / float64(visible)) + + return visible, clamp01(coverage*0.45 + avgConf*0.55) +} + +func trainingPoseKeypointQuality(person TrainingPosePerson) float64 { + _, quality := trainingPoseKeypointStats(person) + return quality +} + +func trainingAnnotatePosePersonQuality(person TrainingPosePerson) TrainingPosePerson { + visible, quality := trainingPoseKeypointStats(person) + person.VisibleKeypoints = visible + person.Quality = quality + person.Reliable = person.Score >= trainingPoseReliableMinScore && + visible >= trainingPoseReliableMinKeypoints && + quality >= trainingPoseReliableMinQuality + + return person +} + +func trainingPosePersonReliable(person TrainingPosePerson) bool { + visible := person.VisibleKeypoints + quality := person.Quality + + if visible == 0 && quality == 0 && len(person.Keypoints) > 0 { + visible, quality = trainingPoseKeypointStats(person) + } + + return person.Score >= trainingPoseReliableMinScore && + visible >= trainingPoseReliableMinKeypoints && + quality >= trainingPoseReliableMinQuality +} + +func trainingAnnotatePosePrediction(pose TrainingPosePrediction) TrainingPosePrediction { + for i := range pose.Persons { + pose.Persons[i] = trainingAnnotatePosePersonQuality(pose.Persons[i]) + } + + if pose.PersonCount == 0 { + pose.PersonCount = len(pose.Persons) + } + + return pose +} + +func trainingReliablePosePrediction(pose TrainingPosePrediction) TrainingPosePrediction { + pose = trainingAnnotatePosePrediction(pose) + + reliable := make([]TrainingPosePerson, 0, len(pose.Persons)) + for _, person := range pose.Persons { + if trainingPosePersonReliable(person) { + reliable = append(reliable, person) + } + } + + pose.Persons = reliable + pose.PersonCount = len(reliable) + + return pose +} + +func trainingScenePersonBoxes(pred TrainingPrediction, pose TrainingPosePrediction) []TrainingBox { + detectorBoxes := []TrainingBox{} + poseBoxes := []TrainingBox{} + + for _, box := range pred.Boxes { + if trainingIsPersonLikeLabel(box.Label) { + if normalized, ok := trainingNormalizedBox(box); ok { + detectorBoxes = append(detectorBoxes, normalized) + } + } + } + + for _, person := range pose.Persons { + if !trainingPosePersonReliable(person) { + continue + } + + if normalized, ok := trainingNormalizedBox(person.Box); ok { + poseBoxes = append(poseBoxes, normalized) + } + } + + if len(detectorBoxes) >= len(poseBoxes) && len(detectorBoxes) > 0 { + return detectorBoxes + } + + return poseBoxes +} + +func trainingIsPersonLikeLabel(label string) bool { + label = strings.TrimSpace(label) + return label == "person" || strings.HasPrefix(label, "person_") +} + +type trainingPersonPairSignals struct { + close bool + overlap bool + horizontal bool + vertical bool + stacked bool +} + +func trainingScenePersonPairSignals(boxes []TrainingBox) trainingPersonPairSignals { + signals := trainingPersonPairSignals{} + + for i := 0; i < len(boxes); i++ { + a, okA := trainingNormalizedBox(boxes[i]) + if !okA { + continue + } + + for j := i + 1; j < len(boxes); j++ { + b, okB := trainingNormalizedBox(boxes[j]) + if !okB { + continue + } + + gap := trainingBoxGap(a, b) + overlap := trainingBoxOverlapRatio(a, b) + close := gap <= 0.08 || overlap >= 0.08 + if close { + signals.close = true + } + if overlap >= 0.18 { + signals.overlap = true + } + + if close && a.W > a.H*1.15 && b.W > b.H*1.15 { + signals.horizontal = true + } + if close && a.H > a.W*1.25 && b.H > b.W*1.25 { + signals.vertical = true + } + + _, ay, okACenter := trainingBoxCenter(a) + _, by, okBCenter := trainingBoxCenter(b) + if okACenter && okBCenter && + gap <= 0.12 && + trainingBoxHorizontalOverlapRatio(a, b) >= 0.35 && + math.Abs(ay-by) >= 0.12 { + signals.stacked = true + } + } + } + + return signals +} + +type trainingPosePoint struct { + x float64 + y float64 +} + +type trainingPosePersonGeometry struct { + box TrainingBox + hasBox bool + center trainingPosePoint + lying bool + upright bool + straddling bool + kneesBelowHips bool + kneesWide bool + allFours bool + bentOrKneeling bool +} + +func trainingPosePointByName(person TrainingPosePerson, name string) (trainingPosePoint, bool) { + name = strings.TrimSpace(name) + if name == "" { + return trainingPosePoint{}, false + } + + for _, point := range person.Keypoints { + if strings.TrimSpace(point.Name) != name { + continue + } + if point.Conf < trainingPoseKeypointMinConfidence || + !trainingIsFinite01(point.X) || + !trainingIsFinite01(point.Y) { + continue + } + + return trainingPosePoint{x: point.X, y: point.Y}, true + } + + return trainingPosePoint{}, false +} + +func trainingPoseMidpoint(person TrainingPosePerson, names ...string) (trainingPosePoint, bool) { + count := 0 + x := 0.0 + y := 0.0 + + for _, name := range names { + point, ok := trainingPosePointByName(person, name) + if !ok { + continue + } + + count++ + x += point.x + y += point.y + } + + if count == 0 { + return trainingPosePoint{}, false + } + + return trainingPosePoint{ + x: x / float64(count), + y: y / float64(count), + }, true +} + +func trainingPosePointDistance(a trainingPosePoint, okA bool, b trainingPosePoint, okB bool) float64 { + if !okA || !okB { + return 0 + } + + dx := a.x - b.x + dy := a.y - b.y + return math.Sqrt(dx*dx + dy*dy) +} + +func trainingPosePersonGeometryFor(person TrainingPosePerson) trainingPosePersonGeometry { + box, hasBox := trainingNormalizedBox(person.Box) + center := trainingPosePoint{x: 0.5, y: 0.5} + if hasBox { + if x, y, ok := trainingBoxCenter(box); ok { + center = trainingPosePoint{x: x, y: y} + } + } + + leftHip, okLeftHip := trainingPosePointByName(person, "left_hip") + rightHip, okRightHip := trainingPosePointByName(person, "right_hip") + leftKnee, okLeftKnee := trainingPosePointByName(person, "left_knee") + rightKnee, okRightKnee := trainingPosePointByName(person, "right_knee") + + hip, okHip := trainingPoseMidpoint(person, "left_hip", "right_hip") + shoulder, okShoulder := trainingPoseMidpoint(person, "left_shoulder", "right_shoulder") + knee, okKnee := trainingPoseMidpoint(person, "left_knee", "right_knee") + + if okHip { + center = hip + } + + torsoDX := 0.0 + torsoDY := 0.0 + torsoLen := 0.0 + if okHip && okShoulder { + torsoDX = math.Abs(hip.x - shoulder.x) + torsoDY = math.Abs(hip.y - shoulder.y) + torsoLen = math.Sqrt(torsoDX*torsoDX + torsoDY*torsoDY) + } + + hipWidth := trainingPosePointDistance(leftHip, okLeftHip, rightHip, okRightHip) + kneeWidth := trainingPosePointDistance(leftKnee, okLeftKnee, rightKnee, okRightKnee) + + kneesBelowHips := okKnee && okHip && knee.y > hip.y+0.045 + kneesWide := kneeWidth > 0 && kneeWidth >= math.Max(0.11, hipWidth*1.12) + straddling := kneesBelowHips && kneesWide + + torsoHorizontal := torsoLen >= 0.07 && torsoDX >= torsoDY*1.15 + torsoVertical := torsoLen >= 0.07 && torsoDY >= torsoDX*1.15 + + boxHorizontal := hasBox && box.W >= box.H*1.05 + boxVertical := hasBox && box.H >= box.W*1.25 + + lying := torsoHorizontal || boxHorizontal + upright := torsoVertical || boxVertical + allFours := torsoHorizontal && kneesBelowHips + bentOrKneeling := allFours || (kneesBelowHips && !straddling) + + return trainingPosePersonGeometry{ + box: box, + hasBox: hasBox, + center: center, + lying: lying, + upright: upright, + straddling: straddling, + kneesBelowHips: kneesBelowHips, + kneesWide: kneesWide, + allFours: allFours, + bentOrKneeling: bentOrKneeling, + } +} + +func trainingAddPosePairGeometryScores( + scores map[string]float64, + positionSet map[string]bool, + pose TrainingPosePrediction, +) { + add := func(label string, score float64) { + trainingCombinePositionScore(scores, positionSet, label, score) + } + + pose = trainingReliablePosePrediction(pose) + if len(pose.Persons) < 2 { + return + } + + geometries := make([]trainingPosePersonGeometry, 0, len(pose.Persons)) + for _, person := range pose.Persons { + geometries = append(geometries, trainingPosePersonGeometryFor(person)) + } + + for i := 0; i < len(geometries); i++ { + left := geometries[i] + if !left.hasBox { + continue + } + + for j := i + 1; j < len(geometries); j++ { + right := geometries[j] + if !right.hasBox { + continue + } + + gap := trainingBoxGap(left.box, right.box) + overlap := trainingBoxOverlapRatio(left.box, right.box) + horizontalOverlap := trainingBoxHorizontalOverlapRatio(left.box, right.box) + close := gap <= 0.12 || overlap >= 0.08 + if !close { + continue + } + + top := left + bottom := right + if right.center.y < left.center.y { + top = right + bottom = left + } + + topAbove := top.center.y <= bottom.center.y-0.055 + strongStack := horizontalOverlap >= 0.35 && (topAbove || overlap >= 0.22) + topHasRiderShape := top.straddling || + top.kneesWide || + (top.upright && top.kneesBelowHips) + + if strongStack && topHasRiderShape { + add("cowgirl", 0.20) + add("reverse_cowgirl", 0.17) + + if bottom.lying { + add("cowgirl", 0.12) + add("reverse_cowgirl", 0.10) + } + if top.straddling { + add("cowgirl", 0.08) + add("reverse_cowgirl", 0.06) + } + } + + if strongStack && bottom.lying { + add("missionary", 0.14) + if !top.straddling { + add("missionary", 0.08) + } + } + + bothLying := left.lying && right.lying + sameLevel := math.Abs(left.center.y-right.center.y) <= 0.15 + sideBySide := math.Abs(left.center.x-right.center.x) >= 0.10 + if bothLying && (sameLevel || sideBySide) { + add("spooning", 0.18) + if overlap >= 0.10 { + add("prone_bone", 0.07) + } + } + + leftBent := left.allFours || left.bentOrKneeling + rightBent := right.allFours || right.bentOrKneeling + if leftBent != rightBent { + other := right + if rightBent { + other = left + } + + add("doggy", 0.16) + if other.upright { + add("doggy", 0.06) + add("standing_doggy", 0.07) + } + if left.lying || right.lying { + add("prone_bone", 0.06) + } + } else if left.allFours || right.allFours { + add("doggy", 0.13) + if left.lying || right.lying { + add("prone_bone", 0.07) + } + } + } + } +} + +func trainingAddPoseSceneContextScores( + scores map[string]float64, + positionSet map[string]bool, + pred TrainingPrediction, + pose TrainingPosePrediction, +) { + add := func(label string, score float64) { + trainingCombinePositionScore(scores, positionSet, label, score) + } + + pose = trainingReliablePosePrediction(pose) + personBoxes := trainingScenePersonBoxes(pred, pose) + personCount := len(personBoxes) + if len(pose.Persons) > personCount { + personCount = len(pose.Persons) + } + + pair := trainingScenePersonPairSignals(personBoxes) + trainingAddPosePairGeometryScores(scores, positionSet, pose) + + penisBoxes := trainingBoxesByLabel(pred.Boxes, "penis") + pussyBoxes := trainingBoxesByLabel(pred.Boxes, "pussy", "vagina", "vulva", "labia") + assBoxes := trainingBoxesByLabel(pred.Boxes, "ass", "anus") + breastBoxes := trainingBoxesByLabel(pred.Boxes, "breasts") + tongueBoxes := trainingBoxesByLabel(pred.Boxes, "tongue") + toyBoxes := trainingBoxesByLabel(pred.Boxes, "dildo", "vibrator", "strapon", "buttplug") + + hasPenis := len(penisBoxes) > 0 + hasPussy := len(pussyBoxes) > 0 + hasAss := len(assBoxes) > 0 + hasBreasts := len(breastBoxes) > 0 + hasTongue := len(tongueBoxes) > 0 + hasToy := len(toyBoxes) > 0 + + headNames := []string{"nose", "left_eye", "right_eye", "left_ear", "right_ear"} + handNames := []string{"left_wrist", "right_wrist"} + hipNames := []string{"left_hip", "right_hip"} + + headNearPenis := trainingAnyPoseKeypointNearBoxes(pose, headNames, penisBoxes, 0.09) + headNearPussy := trainingAnyPoseKeypointNearBoxes(pose, headNames, pussyBoxes, 0.09) + headNearAss := trainingAnyPoseKeypointNearBoxes(pose, headNames, assBoxes, 0.09) + handNearPenis := trainingAnyPoseKeypointNearBoxes(pose, handNames, penisBoxes, 0.08) + handNearPussy := trainingAnyPoseKeypointNearBoxes(pose, handNames, pussyBoxes, 0.08) + handNearToy := trainingAnyPoseKeypointNearBoxes(pose, handNames, toyBoxes, 0.08) + hipsNearGenitals := trainingAnyPoseKeypointNearBoxes(pose, hipNames, append(penisBoxes, pussyBoxes...), 0.08) + + if personCount >= 2 { + add("missionary", 0.04) + add("doggy", 0.04) + add("cowgirl", 0.04) + add("reverse_cowgirl", 0.04) + add("standing_doggy", 0.04) + add("spooning", 0.04) + add("69", 0.03) + } + + if pair.close { + add("missionary", 0.04) + add("doggy", 0.04) + add("cowgirl", 0.04) + add("spooning", 0.04) + } + if pair.overlap { + add("missionary", 0.05) + add("cowgirl", 0.05) + add("prone_bone", 0.04) + } + if pair.horizontal { + add("spooning", 0.12) + add("prone_bone", 0.07) + } + if pair.vertical { + add("standing", 0.08) + add("standing_doggy", 0.10) + } + if pair.stacked { + add("missionary", 0.07) + add("cowgirl", 0.07) + add("reverse_cowgirl", 0.06) + add("facesitting", 0.05) + } + + if hasPenis && hasPussy { + add("missionary", 0.08) + add("doggy", 0.08) + add("cowgirl", 0.08) + add("reverse_cowgirl", 0.07) + add("prone_bone", 0.06) + add("standing_doggy", 0.06) + add("spooning", 0.05) + + if trainingAnyBoxesNear(penisBoxes, pussyBoxes, 0.09) || hipsNearGenitals { + add("missionary", 0.08) + add("doggy", 0.08) + add("cowgirl", 0.08) + add("reverse_cowgirl", 0.07) + } + } + + if hasPenis && (headNearPenis || hasTongue) { + add("blowjob", 0.16) + if headNearPenis { + add("blowjob", 0.10) + } + } + if hasPussy && (headNearPussy || hasTongue || trainingAnyBoxesNear(tongueBoxes, pussyBoxes, 0.08)) { + add("cunnilingus", 0.16) + if headNearPussy || trainingAnyBoxesNear(tongueBoxes, pussyBoxes, 0.08) { + add("cunnilingus", 0.10) + } + } + if hasPenis && handNearPenis { + add("handjob", 0.18) + } + if hasPussy && handNearPussy { + add("fingering", 0.18) + } + if hasPenis && hasBreasts && trainingAnyBoxesNear(penisBoxes, breastBoxes, 0.10) { + add("boobjob", 0.20) + } + if hasToy { + add("toy_play", 0.12) + if handNearToy || + trainingAnyBoxesNear(toyBoxes, pussyBoxes, 0.10) || + trainingAnyBoxesNear(toyBoxes, penisBoxes, 0.10) || + trainingAnyBoxesNear(toyBoxes, assBoxes, 0.10) { + add("toy_play", 0.12) + } + } + if personCount >= 2 && (headNearAss || headNearPussy) { + if hasAss { + add("facesitting", 0.14) + } + if hasPussy && hasPenis { + add("69", 0.10) + } + } + if hasAss && hasPussy && pair.horizontal { + add("doggy", 0.07) + add("prone_bone", 0.07) + } +} + +func trainingApplyPoseToPrediction(pred TrainingPrediction, pose TrainingPosePrediction) TrainingPrediction { + if pose.Available { + pred.ModelAvailable = true + } + + positionSet := stringSet(defaultTrainingLabelsFromJSON().SexPositions) + poseScores := map[string]float64{} + contextScores := map[string]float64{} + reliablePose := TrainingPosePrediction{ + Available: pose.Available, + Source: pose.Source, + Persons: []TrainingPosePerson{}, + } + + if pose.Available && len(pose.Persons) > 0 { + pose = trainingAnnotatePosePrediction(pose) + pred.Persons = pose.Persons + reliablePose = trainingReliablePosePrediction(pose) + } + + for _, person := range reliablePose.Persons { + label := normalizeSexPositionLabel(person.Label) if isNoSexPositionLabel(label) || !positionSet[label] { continue } - if person.Score > bestPositionScore { - bestPosition = label - bestPositionScore = person.Score + trainingCombinePositionScore(poseScores, positionSet, label, person.Score) + + if quality := trainingPoseKeypointQuality(person); quality > 0 { + trainingCombinePositionScore(poseScores, positionSet, label, 0.04*quality) } } - if bestPosition != "" { + trainingAddPoseSceneContextScores(contextScores, positionSet, pred, reliablePose) + + bestPosition, bestPositionScore, hasPoseSignal, hasContextSignal := + trainingFuseHybridPositionScores(poseScores, contextScores) + + if bestPosition != "" && (hasPoseSignal || bestPositionScore >= trainingPositionContextMinScore) { pred.SexPosition = bestPosition - pred.SexPositionScore = bestPositionScore + pred.SexPositionScore = clamp01(bestPositionScore) } - if pred.Source == "" { - pred.Source = "yolo_pose" - } else { - pred.Source = pred.Source + "+yolo_pose" + if pose.Available { + trainingAppendPredictionSource(&pred, "yolo_pose") + } + + if hasContextSignal { + trainingAppendPredictionSource(&pred, "box_context") } return pred @@ -4977,10 +5873,68 @@ func trainingPosePersonsForSample(root string, sample *TrainingSample) []Trainin return pose.Persons } +func trainingPoseContextPersonBoxes(boxes []TrainingBox) []TrainingBox { + out := []TrainingBox{} + + for _, box := range boxes { + if !trainingIsPersonLikeLabel(box.Label) { + continue + } + + if normalized, ok := trainingNormalizedBox(box); ok { + out = append(out, normalized) + } + } + + return out +} + +func trainingFilterPosePersonsByContext( + persons []TrainingPosePerson, + contextBoxes []TrainingBox, +) []TrainingPosePerson { + if len(persons) == 0 { + return persons + } + + personBoxes := trainingPoseContextPersonBoxes(contextBoxes) + if len(personBoxes) == 0 { + return persons + } + + filtered := []TrainingPosePerson{} + + for _, person := range persons { + personBox, ok := trainingNormalizedBox(person.Box) + if !ok { + continue + } + + for _, contextBox := range personBoxes { + if trainingBoxOverlapRatio(personBox, contextBox) >= 0.12 || + trainingBoxGap(personBox, contextBox) <= 0.08 { + filtered = append(filtered, person) + break + } + } + } + + if len(filtered) == 0 { + return persons + } + + return filtered +} + func trainingPoseLabelContent(persons []TrainingPosePerson, classID int) ([]byte, error) { lines := []string{} for _, person := range persons { + person = trainingAnnotatePosePersonQuality(person) + if !trainingPosePersonReliable(person) { + continue + } + if len(person.Keypoints) < trainingPoseKeypointCount { continue } @@ -5012,7 +5966,7 @@ func trainingPoseLabelContent(persons []TrainingPosePerson, classID int) ([]byte ky := clamp01(kp.Y) visibility := 0 - if kp.Conf >= 0.20 && kx > 0 && ky > 0 { + if kp.Conf >= trainingPoseKeypointMinConfidence && kx > 0 && ky > 0 { visibility = 2 visible++ } else { @@ -5042,7 +5996,12 @@ func trainingPoseLabelContent(persons []TrainingPosePerson, classID int) ([]byte return []byte(strings.Join(lines, "\n") + "\n"), nil } -func trainingWritePoseSample(root string, sample *TrainingSample, sexPosition string) error { +func trainingWritePoseSample( + root string, + sample *TrainingSample, + sexPosition string, + contextBoxes []TrainingBox, +) error { if sample == nil { return errors.New("sample missing") } @@ -5063,6 +6022,7 @@ func trainingWritePoseSample(root string, sample *TrainingSample, sexPosition st } persons := trainingPosePersonsForSample(root, sample) + persons = trainingFilterPosePersonsByContext(persons, contextBoxes) labelContent, err := trainingPoseLabelContent(persons, classID) if err != nil { return err @@ -5169,7 +6129,7 @@ func trainingSyncPoseDataset(root string) (int, error) { continue } - if err := trainingWritePoseSample(root, sample, sexPosition); err != nil { + if err := trainingWritePoseSample(root, sample, sexPosition, effective.Boxes); err != nil { appLogln("pose sample sync skipped:", sampleID, err) continue } diff --git a/backend/training_test.go b/backend/training_test.go index 561a64f..22cb73c 100644 --- a/backend/training_test.go +++ b/backend/training_test.go @@ -131,3 +131,292 @@ func TestTrainingEffectiveCorrectionClearsNegativeAnnotation(t *testing.T) { t.Fatalf("negative annotation has %d boxes, want 0", len(effective.Boxes)) } } + +func trainingTestPoseKeypoints(overrides map[string]TrainingKeypoint) []TrainingKeypoint { + names := []string{ + "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", + } + + out := make([]TrainingKeypoint, 0, len(names)) + for i, name := range names { + point := TrainingKeypoint{ + Name: name, + X: 0.20 + float64(i)*0.01, + Y: 0.25 + float64(i)*0.01, + Conf: 0.85, + } + + if override, ok := overrides[name]; ok { + override.Name = name + point = override + } + + out = append(out, point) + } + + return out +} + +func TestTrainingApplyPoseToPredictionAggregatesMultiplePersons(t *testing.T) { + pred := TrainingPrediction{ + SexPosition: trainingNoSexPositionLabel, + Boxes: []TrainingBox{ + {Label: "person_female", X: 0.10, Y: 0.10, W: 0.35, H: 0.75}, + {Label: "person_male", X: 0.42, Y: 0.12, W: 0.34, H: 0.72}, + }, + } + + pose := TrainingPosePrediction{ + Available: true, + Persons: []TrainingPosePerson{ + { + Label: "missionary", + Score: 0.58, + Box: TrainingBox{X: 0.10, Y: 0.10, W: 0.35, H: 0.75}, + Keypoints: trainingTestPoseKeypoints(nil), + }, + { + Label: "doggy", + Score: 0.55, + Box: TrainingBox{X: 0.42, Y: 0.12, W: 0.34, H: 0.72}, + Keypoints: trainingTestPoseKeypoints(nil), + }, + { + Label: "doggy", + Score: 0.54, + Box: TrainingBox{X: 0.52, Y: 0.14, W: 0.30, H: 0.70}, + Keypoints: trainingTestPoseKeypoints(nil), + }, + }, + } + + got := trainingApplyPoseToPrediction(pred, pose) + if got.SexPosition != "doggy" { + t.Fatalf("sex position = %q, want doggy", got.SexPosition) + } +} + +func TestTrainingApplyPoseToPredictionUsesObjectPositionContext(t *testing.T) { + pred := TrainingPrediction{ + SexPosition: trainingNoSexPositionLabel, + Boxes: []TrainingBox{ + {Label: "person_female", X: 0.08, Y: 0.08, W: 0.42, H: 0.78}, + {Label: "person_male", X: 0.42, Y: 0.12, W: 0.35, H: 0.76}, + {Label: "penis", X: 0.50, Y: 0.48, W: 0.07, H: 0.08}, + }, + } + + pose := TrainingPosePrediction{ + Available: true, + Persons: []TrainingPosePerson{ + { + Label: "missionary", + Score: 0.58, + Box: TrainingBox{X: 0.08, Y: 0.08, W: 0.42, H: 0.78}, + Keypoints: trainingTestPoseKeypoints(map[string]TrainingKeypoint{ + "nose": {X: 0.18, Y: 0.18, Conf: 0.90}, + }), + }, + { + Label: "blowjob", + Score: 0.55, + Box: TrainingBox{X: 0.42, Y: 0.12, W: 0.35, H: 0.76}, + Keypoints: trainingTestPoseKeypoints(map[string]TrainingKeypoint{ + "nose": {X: 0.53, Y: 0.52, Conf: 0.95}, + }), + }, + }, + } + + got := trainingApplyPoseToPrediction(pred, pose) + if got.SexPosition != "blowjob" { + t.Fatalf("sex position = %q, want blowjob", got.SexPosition) + } +} + +func TestTrainingApplyPoseToPredictionUsesOcclusionTolerantCowgirlGeometry(t *testing.T) { + pred := TrainingPrediction{ + SexPosition: trainingNoSexPositionLabel, + Boxes: []TrainingBox{ + {Label: "person_female", X: 0.30, Y: 0.10, W: 0.28, H: 0.62}, + {Label: "person_male", X: 0.20, Y: 0.42, W: 0.55, H: 0.24}, + }, + } + + pose := TrainingPosePrediction{ + Available: true, + Persons: []TrainingPosePerson{ + { + Label: "person", + Score: 0.72, + Box: TrainingBox{X: 0.30, Y: 0.10, W: 0.28, H: 0.62}, + Keypoints: trainingTestPoseKeypoints(map[string]TrainingKeypoint{ + "left_shoulder": {X: 0.40, Y: 0.22, Conf: 0.92}, + "right_shoulder": {X: 0.48, Y: 0.22, Conf: 0.92}, + "left_hip": {X: 0.38, Y: 0.38, Conf: 0.92}, + "right_hip": {X: 0.50, Y: 0.38, Conf: 0.92}, + "left_knee": {X: 0.31, Y: 0.58, Conf: 0.90}, + "right_knee": {X: 0.57, Y: 0.58, Conf: 0.90}, + }), + }, + { + Label: "person", + Score: 0.70, + Box: TrainingBox{X: 0.20, Y: 0.42, W: 0.55, H: 0.24}, + Keypoints: trainingTestPoseKeypoints(map[string]TrainingKeypoint{ + "left_shoulder": {X: 0.25, Y: 0.50, Conf: 0.90}, + "right_shoulder": {X: 0.38, Y: 0.50, Conf: 0.90}, + "left_hip": {X: 0.52, Y: 0.53, Conf: 0.90}, + "right_hip": {X: 0.66, Y: 0.53, Conf: 0.90}, + "left_knee": {X: 0.58, Y: 0.56, Conf: 0.88}, + "right_knee": {X: 0.70, Y: 0.56, Conf: 0.88}, + }), + }, + }, + } + + got := trainingApplyPoseToPrediction(pred, pose) + if got.SexPosition != "cowgirl" { + t.Fatalf("sex position = %q, want cowgirl", got.SexPosition) + } + if got.SexPositionScore < 0.30 { + t.Fatalf("sex position score = %.3f, want >= 0.30", got.SexPositionScore) + } +} + +func TestTrainingApplyPoseToPredictionKeepsUnreliablePoseOutOfContext(t *testing.T) { + pred := TrainingPrediction{ + SexPosition: trainingNoSexPositionLabel, + Boxes: []TrainingBox{ + {Label: "person_female", X: 0.08, Y: 0.08, W: 0.42, H: 0.78}, + {Label: "penis", X: 0.50, Y: 0.48, W: 0.07, H: 0.08}, + }, + } + + pose := TrainingPosePrediction{ + Available: true, + Persons: []TrainingPosePerson{ + { + Label: "blowjob", + Score: 0.24, + Box: TrainingBox{X: 0.08, Y: 0.08, W: 0.42, H: 0.78}, + Keypoints: trainingTestPoseKeypoints(map[string]TrainingKeypoint{ + "nose": {X: 0.53, Y: 0.52, Conf: 0.95}, + }), + }, + }, + } + + got := trainingApplyPoseToPrediction(pred, pose) + if got.SexPosition != trainingNoSexPositionLabel { + t.Fatalf("sex position = %q, want %s", got.SexPosition, trainingNoSexPositionLabel) + } + if len(got.Persons) != 1 { + t.Fatalf("persons = %d, want 1", len(got.Persons)) + } + if got.Persons[0].Reliable { + t.Fatalf("low-score pose should be marked unreliable: %+v", got.Persons[0]) + } + if got.Persons[0].VisibleKeypoints == 0 || got.Persons[0].Quality == 0 { + t.Fatalf("pose quality should be annotated: %+v", got.Persons[0]) + } +} + +func TestTrainingApplyPoseToPredictionUsesBoxContextWithoutPose(t *testing.T) { + pred := TrainingPrediction{ + ModelAvailable: true, + Source: "yolo26_detector", + SexPosition: trainingNoSexPositionLabel, + Boxes: []TrainingBox{ + {Label: "person_female", X: 0.18, Y: 0.12, W: 0.42, H: 0.72}, + {Label: "person_male", X: 0.42, Y: 0.18, W: 0.38, H: 0.68}, + {Label: "penis", X: 0.48, Y: 0.50, W: 0.08, H: 0.08}, + {Label: "pussy", X: 0.54, Y: 0.51, W: 0.08, H: 0.08}, + }, + } + + got := trainingApplyPoseToPrediction(pred, TrainingPosePrediction{Available: false}) + if got.SexPosition == trainingNoSexPositionLabel { + t.Fatalf("sex position = %q, want uncertain context prediction", got.SexPosition) + } + if got.SexPositionScore < trainingPositionContextMinScore { + t.Fatalf("sex position score = %.3f, want >= %.3f", got.SexPositionScore, trainingPositionContextMinScore) + } + if got.SexPositionScore > trainingPositionContextMaxScore { + t.Fatalf("sex position score = %.3f, want <= %.3f", got.SexPositionScore, trainingPositionContextMaxScore) + } + if !strings.Contains(got.Source, "box_context") { + t.Fatalf("source = %q, want box_context", got.Source) + } +} + +func TestTrainingApplyPoseToPredictionBoostsPoseWithBoxContext(t *testing.T) { + pred := TrainingPrediction{ + ModelAvailable: true, + Source: "yolo26_detector", + SexPosition: trainingNoSexPositionLabel, + Boxes: []TrainingBox{ + {Label: "penis", X: 0.48, Y: 0.50, W: 0.08, H: 0.08}, + {Label: "pussy", X: 0.54, Y: 0.51, W: 0.08, H: 0.08}, + }, + } + + pose := TrainingPosePrediction{ + Available: true, + Persons: []TrainingPosePerson{ + { + Label: "doggy", + Score: 0.31, + Box: TrainingBox{X: 0.20, Y: 0.12, W: 0.60, H: 0.76}, + Keypoints: trainingTestPoseKeypoints(nil), + }, + }, + } + + got := trainingApplyPoseToPrediction(pred, pose) + if got.SexPosition != "doggy" { + t.Fatalf("sex position = %q, want doggy", got.SexPosition) + } + if got.SexPositionScore <= 0.31 { + t.Fatalf("sex position score = %.3f, want context boost over 0.31", got.SexPositionScore) + } + if !strings.Contains(got.Source, "yolo_pose") || !strings.Contains(got.Source, "box_context") { + t.Fatalf("source = %q, want yolo_pose and box_context", got.Source) + } +} + +func TestTrainingFilterPosePersonsByContextDropsUnannotatedPeople(t *testing.T) { + persons := []TrainingPosePerson{ + { + Label: "doggy", + Score: 0.80, + Box: TrainingBox{X: 0.10, Y: 0.10, W: 0.30, H: 0.70}, + Keypoints: trainingTestPoseKeypoints(nil), + }, + { + Label: "doggy", + Score: 0.70, + Box: TrainingBox{X: 0.70, Y: 0.10, W: 0.20, H: 0.70}, + Keypoints: trainingTestPoseKeypoints(nil), + }, + } + + filtered := trainingFilterPosePersonsByContext(persons, []TrainingBox{ + {Label: "person_female", X: 0.08, Y: 0.08, W: 0.34, H: 0.74}, + }) + + if len(filtered) != 1 { + t.Fatalf("filtered persons = %d, want 1", len(filtered)) + } + if filtered[0].Box.X != persons[0].Box.X { + t.Fatalf("kept wrong person: %+v", filtered[0].Box) + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 06aac2d..f82101d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -714,7 +714,28 @@ export default function App() { const [splitModalKey, setSplitModalKey] = useState(0) const [assetNonce, setAssetNonce] = useState(0) + const [assetNonceByFile, setAssetNonceByFile] = useState>({}) const bumpAssets = useCallback(() => setAssetNonce((n) => n + 1), []) + const bumpAssetForFile = useCallback((fileOrPath: string) => { + const file = baseName(fileOrPath) + if (!file) { + bumpAssets() + return + } + + const plainFile = stripHotPrefix(file) + + setAssetNonceByFile((prev) => { + const next = { ...prev } + next[file] = (next[file] ?? 0) + 1 + + if (plainFile && plainFile !== file) { + next[plainFile] = (next[plainFile] ?? 0) + 1 + } + + return next + }) + }, [bumpAssets]) const [recSettings, setRecSettings] = useState(DEFAULT_RECORDER_SETTINGS) @@ -1091,6 +1112,51 @@ export default function App() { loadDonePageRef.current = loadDonePage }, [loadDonePage]) + const doneRefreshTimerRef = useRef(null) + const doneRefreshInFlightRef = useRef(false) + const doneRefreshQueuedRef = useRef(false) + + const scheduleDoneRefresh = useCallback((delayMs = 450) => { + if (doneRefreshTimerRef.current != null) { + window.clearTimeout(doneRefreshTimerRef.current) + } + + doneRefreshTimerRef.current = window.setTimeout(() => { + doneRefreshTimerRef.current = null + + if (doneRefreshInFlightRef.current) { + doneRefreshQueuedRef.current = true + return + } + + doneRefreshInFlightRef.current = true + + void (async () => { + try { + donePrefetchRef.current = null + await loadDonePageRef.current(donePageRef.current, doneSortRef.current) + await loadDoneCount() + } finally { + doneRefreshInFlightRef.current = false + + if (doneRefreshQueuedRef.current) { + doneRefreshQueuedRef.current = false + scheduleDoneRefresh(300) + } + } + })() + }, Math.max(0, delayMs)) + }, [loadDoneCount]) + + useEffect(() => { + return () => { + if (doneRefreshTimerRef.current != null) { + window.clearTimeout(doneRefreshTimerRef.current) + doneRefreshTimerRef.current = null + } + } + }, []) + useEffect(() => { try { window.localStorage.setItem(INCLUDE_KEEP_KEY, includeKeep ? '1' : '0') @@ -2821,8 +2887,7 @@ export default function App() { void loadTaskStateOnce() if (selectedTabRef.current === 'finished') { - donePrefetchRef.current = null - void loadDonePageRef.current(donePageRef.current, doneSortRef.current) + scheduleDoneRefresh(500) } }, document.hidden ? 60000 : 5000) } @@ -2848,12 +2913,12 @@ export default function App() { } const onDoneChanged = () => { - void loadDoneCount() - if (selectedTabRef.current === 'finished') { - donePrefetchRef.current = null - void loadDonePageRef.current(donePageRef.current, doneSortRef.current) + scheduleDoneRefresh(900) + return } + + void loadDoneCount() } const onAutostart = (ev: MessageEvent) => { @@ -2898,7 +2963,7 @@ export default function App() { // ✅ Wenn Assets fertig / fehlerhaft / entfernt sind, Preview-/Sprite-Cache neu ziehen if (state === 'done' || state === 'error' || state === 'missing') { - bumpAssets() + bumpAssetForFile(file) } } catch {} } @@ -2975,7 +3040,7 @@ export default function App() { ) if (state === 'done' || state === 'error') { - bumpAssets() + bumpAssetForFile(file) } } catch { // ignore @@ -3132,6 +3197,8 @@ export default function App() { loadPendingAutoStarts, applyAutostartState, loadAutostartState, + bumpAssetForFile, + scheduleDoneRefresh, ]) useEffect(() => { @@ -3430,7 +3497,7 @@ export default function App() { // Einheitliche Start-Event-Phase (delete/keep animieren dieselbe Row) if (kind === 'delete' || kind === 'keep') { window.dispatchEvent( - new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const } }) + new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const, kind } }) ) } @@ -3439,7 +3506,7 @@ export default function App() { if (kind === 'delete' || kind === 'keep') { window.dispatchEvent( - new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } }) + new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const, kind } }) ) } @@ -3448,11 +3515,21 @@ export default function App() { } catch (e) { if (kind === 'delete' || kind === 'keep') { window.dispatchEvent( - new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } }) + new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const, kind } }) ) } await onError?.(e) + if (e && typeof e === 'object') { + try { + Object.defineProperty(e, '__finishedDownloadsNotified', { + value: true, + configurable: true, + }) + } catch { + ;(e as any).__finishedDownloadsNotified = true + } + } throw e } } @@ -3484,8 +3561,7 @@ export default function App() { donePrefetchRef.current = null - void loadDonePageRef.current(donePageRef.current, doneSortRef.current) - void loadDoneCount() + scheduleDoneRefresh(900) }, 320) }, onError: async () => { @@ -3497,14 +3573,14 @@ export default function App() { const from = typeof data?.from === 'string' ? data.from : undefined return undoToken ? { undoToken, from } : {} - } catch { - return + } catch (e) { + throw e } }, [ runFinishedFileAction, notify, - loadDoneCount, + scheduleDoneRefresh, ] ) @@ -3529,8 +3605,7 @@ export default function App() { donePrefetchRef.current = null - void loadDonePageRef.current(donePageRef.current, doneSortRef.current) - void loadDoneCount() + scheduleDoneRefresh(900) }, 320) }, onError: async () => { @@ -3539,11 +3614,11 @@ export default function App() { }) return data - } catch { - return + } catch (e) { + throw e } }, - [runFinishedFileAction, notify, loadDoneCount] + [runFinishedFileAction, notify, scheduleDoneRefresh] ) const handleToggleHot = useCallback( @@ -4328,6 +4403,7 @@ export default function App() { teaserAudio={Boolean(recSettings.teaserAudio)} pauseTeasers={Boolean(detailsModelKey) || splitModalOpen || Boolean(playerJob)} assetNonce={assetNonce} + assetNonceByFile={assetNonceByFile} sortMode={doneSort} onSortModeChange={(m) => { setDoneSort(m) diff --git a/frontend/src/components/ui/DeletingPreviewOverlay.tsx b/frontend/src/components/ui/DeletingPreviewOverlay.tsx index dedf4a5..f6a5cad 100644 --- a/frontend/src/components/ui/DeletingPreviewOverlay.tsx +++ b/frontend/src/components/ui/DeletingPreviewOverlay.tsx @@ -1,14 +1,21 @@ 'use client' +import { CheckIcon } from '@heroicons/react/24/solid' import LoadingSpinner from './LoadingSpinner' export default function DeletingPreviewOverlay({ - label = 'Wird gelöscht', + done = false, + label, className = 'rounded-t-lg', + compact = false, }: { + done?: boolean label?: string className?: string + compact?: boolean }) { + const displayLabel = label ?? (done ? 'Gelöscht' : 'Wird gelöscht') + return (
-
- +
+ {done ? ( + + + ) : ( + + )} -
- {label} +
+ {displayLabel}
) -} \ No newline at end of file +} diff --git a/frontend/src/components/ui/FinishedDownloads.tsx b/frontend/src/components/ui/FinishedDownloads.tsx index bd75db0..193f674 100644 --- a/frontend/src/components/ui/FinishedDownloads.tsx +++ b/frontend/src/components/ui/FinishedDownloads.tsx @@ -104,6 +104,7 @@ type Props = { onPageSizeChange?: (size: number) => void onPageChange: (page: number) => void assetNonce?: number + assetNonceByFile?: Record sortMode: DoneSortMode onSortModeChange: (m: DoneSortMode) => void loadMode?: 'paged' | 'all' @@ -173,6 +174,8 @@ const GALLERY_CARD_SCALE_PRESETS = [ ] as const const DEFAULT_GALLERY_CARD_SCALE_INDEX = 2 +const DEFAULT_FINISHED_AUTO_PAGE_SIZE = 8 +const MAX_FINISHED_AUTO_PAGE_SIZE = 120 function clampPresetIndex(value: number) { return Math.max( @@ -196,6 +199,14 @@ function nearestPresetIndex(value: number) { return bestIndex } +function normalizeFinishedAutoPageSize(size: number) { + if (!Number.isFinite(size)) return DEFAULT_FINISHED_AUTO_PAGE_SIZE + return Math.max( + 1, + Math.min(MAX_FINISHED_AUTO_PAGE_SIZE, Math.round(size)) + ) +} + function useMediaQuery(query: string) { const [matches, setMatches] = useState(false) @@ -407,6 +418,29 @@ function fileStemFromOutput(output?: string): string { return stripHotPrefix(file.replace(/\.[^.]+$/, '')) } +function undoActionForStorage(action: UndoAction): UndoAction { + if (action.kind === 'delete') { + return { + kind: 'delete', + undoToken: action.undoToken, + originalFile: action.originalFile, + rowKey: action.rowKey, + from: action.from, + } + } + + if (action.kind === 'keep') { + return { + kind: 'keep', + keptFile: action.keptFile, + originalFile: action.originalFile, + rowKey: action.rowKey, + } + } + + return action +} + function getPostworkSummaryForOutput( output: string | undefined, summaries: Record @@ -2213,6 +2247,7 @@ export default function FinishedDownloads({ onPageChange, onPageSizeChange, assetNonce, + assetNonceByFile = {}, sortMode, onSortModeChange, modelsByKey, @@ -2260,9 +2295,11 @@ export default function FinishedDownloads({ const [deletedKeys, setDeletedKeys] = useState>(() => new Set()) const [deletingKeys, setDeletingKeys] = useState>(() => new Set()) + const [deletedSuccessKeys, setDeletedSuccessKeys] = useState>(() => new Set()) const [keepingKeys, setKeepingKeys] = useState>(() => new Set()) const [removingKeys, setRemovingKeys] = useState>(() => new Set()) const [hiddenFiles, setHiddenFiles] = useState>(() => new Set()) + const [restoredJobsByKey, setRestoredJobsByKey] = useState>({}) const [undoing, setUndoing] = useState(false) const [bulkBusy, setBulkBusy] = useState(false) @@ -2457,6 +2494,24 @@ export default function FinishedDownloads({ const previewMuted = !Boolean(teaserAudio) || forcePreviewMuted + const assetNonceForJob = useCallback( + (job: RecordJob): number => { + const globalNonce = assetNonce ?? 0 + const fileRaw = baseName(job.output || '') + if (!fileRaw) return globalNonce + + const filePlain = stripHotPrefix(fileRaw) + const fileNonce = assetNonceByFile[fileRaw] ?? 0 + const plainNonce = + filePlain && filePlain !== fileRaw + ? assetNonceByFile[filePlain] ?? 0 + : 0 + + return globalNonce + Math.max(fileNonce, plainNonce) + }, + [assetNonce, assetNonceByFile] + ) + const audibleTeaserKey = previewMuted ? null @@ -2573,21 +2628,44 @@ export default function FinishedDownloads({ const effectiveAllMode = shouldLoadAllDoneJobs - const DEFAULT_GALLERY_PAGE_SIZE = 8 - const [galleryPageSize, setGalleryPageSize] = useState( - DEFAULT_GALLERY_PAGE_SIZE + const [recommendedPageSizeByView, setRecommendedPageSizeByView] = + useState>(() => ({ + table: DEFAULT_FINISHED_AUTO_PAGE_SIZE, + cards: DEFAULT_FINISHED_AUTO_PAGE_SIZE, + gallery: DEFAULT_FINISHED_AUTO_PAGE_SIZE, + })) + + const handleRecommendedPageSizeChange = useCallback( + (viewMode: ViewMode, size: number) => { + const next = normalizeFinishedAutoPageSize(size) + + setRecommendedPageSizeByView((prev) => { + if (prev[viewMode] === next) return prev + return { ...prev, [viewMode]: next } + }) + }, + [] ) - const handleGalleryRecommendedPageSizeChange = useCallback((size: number) => { - const next = Math.max(2, Math.min(24, Math.round(size))) - if (next === galleryPageSize) return - setGalleryPageSize(next) - }, [galleryPageSize]) + const handleTableRecommendedPageSizeChange = useCallback( + (size: number) => handleRecommendedPageSizeChange('table', size), + [handleRecommendedPageSizeChange] + ) + + const handleCardsRecommendedPageSizeChange = useCallback( + (size: number) => handleRecommendedPageSizeChange('cards', size), + [handleRecommendedPageSizeChange] + ) + + const handleGalleryRecommendedPageSizeChange = useCallback( + (size: number) => handleRecommendedPageSizeChange('gallery', size), + [handleRecommendedPageSizeChange] + ) const effectivePageSize = - view === 'gallery' - ? galleryPageSize - : pageSize + mobileCardStackMode + ? pageSize + : recommendedPageSizeByView[view] ?? pageSize const doneJobsPage = shouldLoadAllDoneJobs @@ -2613,6 +2691,69 @@ export default function FinishedDownloads({ [renamedFiles] ) + const makeRestoredJob = useCallback( + ( + sourceJob: RecordJob | undefined, + restoredFileRaw: string, + fallbackFileRaw: string, + fallbackKey?: string + ): RecordJob => { + const restoredFile = baseName(restoredFileRaw || fallbackFileRaw) + const fallbackFile = baseName(fallbackFileRaw || restoredFileRaw) + const file = restoredFile || fallbackFile + const sourceOutput = norm(sourceJob?.output || fallbackFile || file) + const idx = sourceOutput.lastIndexOf('/') + const dir = idx >= 0 ? sourceOutput.slice(0, idx + 1) : '' + const now = new Date().toISOString() + + return { + ...(sourceJob ?? {}), + id: String(sourceJob?.id ?? fallbackKey ?? fileStemFromOutput(file) ?? file), + output: dir + file, + status: sourceJob?.status ?? 'finished', + startedAt: sourceJob?.startedAt || now, + endedAt: sourceJob?.endedAt || now, + } + }, + [] + ) + + const upsertRestoredJob = useCallback((job: RecordJob) => { + const key = keyFor(job) + const file = baseName(job.output || '') + + const upsert = (items: RecordJob[] | null) => { + if (!items) return items + + let replaced = false + const next = items.map((item) => { + const sameKey = keyFor(item) === key + const sameFile = file && baseName(item.output || '') === file + + if (!sameKey && !sameFile) return item + + replaced = true + return { + ...item, + ...job, + meta: mergeMetaPreferDone((job as any)?.meta, (item as any)?.meta), + } + }) + + return replaced ? next : [job, ...next] + } + + setRestoredJobsByKey((prev) => { + if (prev[key] === job) return prev + return { + ...prev, + [key]: job, + } + }) + + setAllDoneJobs(upsert) + }, []) + const rows = useMemo(() => { const map = new Map() const doneBaseKeys = new Set() @@ -2665,6 +2806,26 @@ export default function FinishedDownloads({ } } + for (const restoredJob of Object.values(restoredJobsByKey)) { + const jj = applyRenamedOutputLocal(restoredJob) + const k = keyFor(jj) + const file = baseName(jj.output || '') + + if (!file || map.has(k)) continue + + let fileAlreadyPresent = false + for (const existingJob of map.values()) { + if (baseName(existingJob.output || '') === file) { + fileAlreadyPresent = true + break + } + } + + if (!fileAlreadyPresent) { + map.set(k, jj) + } + } + return Array.from(map.entries()) .map(([k, j]) => ({ k, j })) .filter(({ k, j }) => { @@ -2692,7 +2853,7 @@ export default function FinishedDownloads({ return false }) .map(({ j }) => j) - }, [jobs, doneJobsPage, deletedKeys, hiddenFiles, applyRenamedOutputLocal, doneMetaByFile]) + }, [jobs, doneJobsPage, restoredJobsByKey, deletedKeys, hiddenFiles, applyRenamedOutputLocal, doneMetaByFile]) const effectivePostworkSummaryByFile = useMemo>(() => { const next: Record = {} @@ -2866,7 +3027,7 @@ export default function FinishedDownloads({ effectivePostworkSummaryByFile, ]) - const finishedDownloadsLoading = allDoneJobsLoading + const finishedDownloadsLoading = allDoneJobsLoading && rows.length === 0 const totalItemsForPagination = finishedDownloadsLoading @@ -2907,6 +3068,45 @@ export default function FinishedDownloads({ return selectionStore.getSelectedFiles() }, [selectionStore, selectionVersion]) + const selectedJobsForBulk = useMemo(() => { + if (selectedFiles.length === 0) return [] + + const byFile = new Map() + for (const job of rows) { + const file = baseName(job.output || '') + if (file && !byFile.has(file)) { + byFile.set(file, job) + } + } + + const now = new Date().toISOString() + const jobsForFiles: RecordJob[] = [] + const seen = new Set() + + for (const rawFile of selectedFiles) { + const file = String(rawFile || '').trim() + if (!file || seen.has(file)) continue + seen.add(file) + + const existing = byFile.get(file) + if (existing) { + jobsForFiles.push(existing) + continue + } + + const fallbackKey = fileToKeyRef.current.get(file) || fileStemFromOutput(file) || file + jobsForFiles.push({ + id: fallbackKey, + output: file, + status: 'finished', + startedAt: now, + endedAt: now, + }) + } + + return jobsForFiles + }, [rows, selectedFiles]) + const allPageSelected = useMemo(() => { return ( activeVisibleRows.length > 0 && @@ -2934,6 +3134,13 @@ export default function FinishedDownloads({ return next }, [activeVisibleRows, keyFor, selectionStore, selectionVersion]) + const autoPageSizeSuspended = + bulkBusy || + deletingKeys.size > 0 || + deletedSuccessKeys.size > 0 || + keepingKeys.size > 0 || + removingKeys.size > 0 + // ----------------------------------------------------------------------------- // callbacks // ----------------------------------------------------------------------------- @@ -3228,6 +3435,15 @@ export default function FinishedDownloads({ }) }, []) + const markDeletedSuccess = useCallback((key: string, value: boolean) => { + setDeletedSuccessKeys((prev) => { + const next = new Set(prev) + if (value) next.add(key) + else next.delete(key) + return next + }) + }, []) + const markDeleted = useCallback((key: string) => { setDeletedKeys((prev) => { const next = new Set(prev) @@ -3262,6 +3478,72 @@ export default function FinishedDownloads({ } }, []) + const fileAliasesFor = useCallback((file?: string) => { + const aliases = new Set() + + const add = (value?: string | null) => { + const clean = baseName(String(value ?? '').trim()) + if (!clean) return + + aliases.add(clean) + + const plain = stripHotPrefix(clean) + if (plain) aliases.add(plain) + } + + add(file) + + const resolved = resolveRenamedFile(baseName(String(file ?? '')), renamedFiles) + add(resolved) + + let changed = true + while (changed) { + changed = false + + for (const [from, to] of Object.entries(renamedFiles)) { + const before = aliases.size + if (aliases.has(baseName(from)) || aliases.has(baseName(to))) { + add(from) + add(to) + add(resolveRenamedFile(from, renamedFiles)) + } + if (aliases.size !== before) changed = true + } + } + + return aliases + }, [renamedFiles]) + + const rowKeyAliasesFor = useCallback((key?: string, file?: string) => { + const aliases = new Set() + + const add = (value?: string | null) => { + const clean = String(value ?? '').trim() + if (!clean) return + + aliases.add(clean) + + const file = baseName(clean) + if (file) { + aliases.add(file) + const plain = stripHotPrefix(file) + if (plain) aliases.add(plain) + + const stem = fileStemFromOutput(file) + if (stem) aliases.add(stem) + } + } + + add(key) + + for (const fileAlias of fileAliasesFor(file)) { + add(fileAlias) + add(fileToKeyRef.current.get(fileAlias)) + } + + return aliases + }, [fileAliasesFor]) + const removeRowNow = useCallback( (key: string, file?: string) => { cancelRemoveTimer(key) @@ -3274,6 +3556,45 @@ export default function FinishedDownloads({ }) } + setRestoredJobsByKey((prev) => { + if (!Object.keys(prev).length) return prev + + let changed = false + const next: Record = {} + + for (const [restoredKey, restoredJob] of Object.entries(prev)) { + const restoredFile = baseName(restoredJob.output || '') + const shouldDrop = + restoredKey === key || + keyFor(restoredJob) === key || + Boolean(file && restoredFile === file) + + if (shouldDrop) { + changed = true + continue + } + + next[restoredKey] = restoredJob + } + + return changed ? next : prev + }) + + setAllDoneJobs((prev) => { + if (!prev) return prev + + return prev.filter((job) => { + const jobKey = keyFor(job) + const jobFile = baseName(job.output || '') + + if (jobKey === key) return false + if (file && jobFile === file) return false + + return true + }) + }) + + markDeletedSuccess(key, false) markDeleted(key) markRemoving(key, false) @@ -3281,46 +3602,66 @@ export default function FinishedDownloads({ refreshHoverPreviewFromPointer() }) }, - [cancelRemoveTimer, markDeleted, markRemoving, refreshHoverPreviewFromPointer] + [cancelRemoveTimer, markDeletedSuccess, markDeleted, markRemoving, refreshHoverPreviewFromPointer] ) const restoreRow = useCallback( (key: string, file?: string) => { - cancelRemoveTimer(key) + const keyAliases = rowKeyAliasesFor(key, file) + const fileAliases = fileAliasesFor(file) - setDeletedKeys((prev) => { + for (const keyAlias of keyAliases) { + cancelRemoveTimer(keyAlias) + } + + const removeKeyAliases = (prev: Set) => { + let changed = false const next = new Set(prev) - next.delete(key) - return next - }) - setRemovingKeys((prev) => { - const next = new Set(prev) - next.delete(key) - return next - }) + for (const keyAlias of keyAliases) { + if (next.delete(keyAlias)) changed = true + } - setDeletingKeys((prev) => { - const next = new Set(prev) - next.delete(key) - return next - }) + return changed ? next : prev + } - setKeepingKeys((prev) => { - const next = new Set(prev) - next.delete(key) - return next - }) + setDeletedKeys(removeKeyAliases) + setRemovingKeys(removeKeyAliases) + setDeletingKeys(removeKeyAliases) + setDeletedSuccessKeys(removeKeyAliases) + setKeepingKeys(removeKeyAliases) - if (file) { + if (fileAliases.size > 0) { setHiddenFiles((prev) => { + let changed = false const next = new Set(prev) - next.delete(file) - return next + + for (const fileAlias of fileAliases) { + if (next.delete(fileAlias)) changed = true + } + + return changed ? next : prev }) } }, - [cancelRemoveTimer] + [cancelRemoveTimer, fileAliasesFor, rowKeyAliasesFor] + ) + + const completeDeletedRow = useCallback( + (key: string, file?: string) => { + cancelRemoveTimer(key) + markDeleting(key, false) + markRemoving(key, false) + markDeletedSuccess(key, true) + + const t = window.setTimeout(() => { + removeTimersRef.current.delete(key) + removeRowNow(key, file) + }, 560) + + removeTimersRef.current.set(key, t) + }, + [cancelRemoveTimer, markDeleting, markRemoving, markDeletedSuccess, removeRowNow] ) const animateRemove = useCallback( @@ -3469,7 +3810,7 @@ export default function FinishedDownloads({ try { if (optimisticRemove && !alreadyRemoved) { - animateRemove(rowKey) + animateRemove(rowKey, file) } const result = await run() @@ -3483,12 +3824,12 @@ export default function FinishedDownloads({ await onError?.(e) - if (looksLikeFileInUseError(e)) { + if (!Boolean((e as any)?.__finishedDownloadsNotified) && looksLikeFileInUseError(e)) { notify.error( labels.inUseTitle, `${file} wird noch verwendet (Player/Preview). Bitte kurz warten und erneut versuchen.` ) - } else { + } else if (!Boolean((e as any)?.__finishedDownloadsNotified)) { const suffix = e?.message ? ` — ${String(e.message)}` : '' notify.error(labels.failTitle, `${labels.failPrefix ?? file}${suffix}`) } @@ -3502,7 +3843,10 @@ export default function FinishedDownloads({ ) const deleteVideo = useCallback( - async (job: RecordJob, opts?: { alreadyRemoved?: boolean }): Promise => { + async ( + job: RecordJob, + opts?: { alreadyRemoved?: boolean; optimisticRemove?: boolean } + ): Promise => { const file = baseName(job.output || '') const key = keyFor(job) @@ -3512,8 +3856,8 @@ export default function FinishedDownloads({ file, rowKey: key, setBusy: (v) => markDeleting(key, v), - isBusyNow: () => deletingKeys.has(key), - optimisticRemove: false, + isBusyNow: () => deletingKeys.has(key) || deletedSuccessKeys.has(key), + optimisticRemove: Boolean(opts?.optimisticRemove), alreadyRemoved: opts?.alreadyRemoved, labels: { invalidTitle: 'Löschen nicht möglich', @@ -3544,13 +3888,19 @@ export default function FinishedDownloads({ }, onSuccess: async (result: any) => { selectionStore.deselect(key) - removeRowNow(key, file) + + if (opts?.optimisticRemove) { + markDeletedSuccess(key, false) + markRemoving(key, false) + } else { + completeDeletedRow(key, file) + } const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : '' const from = typeof result?.from === 'string' ? result.from : undefined if (undoToken) { - setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key, from }) + setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key, from, job }) } else { setLastAction(null) } @@ -3563,13 +3913,16 @@ export default function FinishedDownloads({ }, [ deletingKeys, + deletedSuccessKeys, markDeleting, + markDeletedSuccess, + markRemoving, onDeleteJob, withFileReleaseRetry, runFileMutation, emitCountHint, selectionStore, - removeRowNow, + completeDeletedRow, ] ) @@ -3618,7 +3971,7 @@ export default function FinishedDownloads({ const keptFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : file - setLastAction({ kind: 'keep', keptFile, originalFile: file, rowKey: key }) + setLastAction({ kind: 'keep', keptFile, originalFile: file, rowKey: key, job }) emitCountHint(includeKeep ? 0 : -1) } @@ -3722,34 +4075,7 @@ export default function FinishedDownloads({ setUndoing(true) const unhideRow = (keyOrToken: string, file?: string) => { - setDeletedKeys((prev) => { - const next = new Set(prev) - next.delete(keyOrToken) - return next - }) - setDeletingKeys((prev) => { - const next = new Set(prev) - next.delete(keyOrToken) - return next - }) - setKeepingKeys((prev) => { - const next = new Set(prev) - next.delete(keyOrToken) - return next - }) - setRemovingKeys((prev) => { - const next = new Set(prev) - next.delete(keyOrToken) - return next - }) - - if (file) { - setHiddenFiles((prev) => { - const next = new Set(prev) - next.delete(file) - return next - }) - } + restoreRow(keyOrToken, file) } try { @@ -3764,10 +4090,20 @@ export default function FinishedDownloads({ } const data = await res.json().catch(() => null) as any const restoredFile = String(data?.restoredFile || lastAction.originalFile) + const restoredJob = makeRestoredJob( + lastAction.job, + restoredFile, + lastAction.originalFile, + lastAction.rowKey + ) + const restoredKey = keyFor(restoredJob) if (lastAction.rowKey) unhideRow(lastAction.rowKey, lastAction.originalFile) unhideRow(lastAction.originalFile, lastAction.originalFile) unhideRow(restoredFile, restoredFile) + unhideRow(restoredKey, restoredFile) + upsertRestoredJob(restoredJob) + void refreshDoneMetaForFile(baseName(restoredFile)) const visibleDelta = lastAction.from === 'keep' && !includeKeep ? 0 : +1 emitCountHint(visibleDelta) @@ -3786,10 +4122,20 @@ export default function FinishedDownloads({ } const data = await res.json().catch(() => null) as any const restoredFile = String(data?.newFile || lastAction.originalFile) + const restoredJob = makeRestoredJob( + lastAction.job, + restoredFile, + lastAction.originalFile, + lastAction.rowKey + ) + const restoredKey = keyFor(restoredJob) if (lastAction.rowKey) unhideRow(lastAction.rowKey, lastAction.originalFile) unhideRow(lastAction.originalFile, lastAction.originalFile) unhideRow(restoredFile, restoredFile) + unhideRow(restoredKey, restoredFile) + upsertRestoredJob(restoredJob) + void refreshDoneMetaForFile(baseName(restoredFile)) emitCountHint(includeKeep ? 0 : +1) setLastAction(null) @@ -3829,6 +4175,10 @@ export default function FinishedDownloads({ includeKeep, emitCountHint, applyRename, + restoreRow, + makeRestoredJob, + upsertRestoredJob, + refreshDoneMetaForFile, ]) const markHotBusy = useCallback((key: string, value: boolean) => { @@ -4054,19 +4404,20 @@ export default function FinishedDownloads({ const bulkDeleteSelected = useCallback(async () => { if (bulkBusy) return - if (selectedFiles.length === 0) return + if (selectedJobsForBulk.length === 0) return const ok = window.confirm(`${selectedFiles.length} Downloads löschen?`) if (!ok) return - const selectedDeleteItems = selectedFiles - .map((file) => { + const selectedDeleteItems = selectedJobsForBulk + .map((job) => { + const file = baseName(job.output || '') const cleanFile = String(file || '').trim() if (!cleanFile) return null return { file: cleanFile, - key: fileToKeyRef.current.get(cleanFile) || cleanFile, + key: keyFor(job), } }) .filter(Boolean) as Array<{ file: string; key: string }> @@ -4074,6 +4425,42 @@ export default function FinishedDownloads({ if (selectedDeleteItems.length === 0) return const selectedDeleteKeys = selectedDeleteItems.map((item) => item.key) + const selectedDeleteFiles = selectedDeleteItems.map((item) => item.file) + const deletedKeys = new Set() + const failedKeys = new Set() + + const clearDeleting = (keys: Iterable) => { + setDeletingKeys((prev) => { + const next = new Set(prev) + for (const key of keys) { + next.delete(key) + } + return next + }) + } + + const handleResult = (item: any) => { + if (item?.done) return + + const file = String(item?.file || '').trim() + if (!file) return + + const key = + fileToKeyRef.current.get(file) || + selectedDeleteItems.find((selected) => selected.file === file)?.key || + file + + if (item?.ok) { + if (!deletedKeys.has(key)) { + deletedKeys.add(key) + completeDeletedRow(key, file) + } + return + } + + failedKeys.add(key) + clearDeleting([key]) + } // Sofort Overlay anzeigen, noch bevor der Bulk-Request läuft. setDeletingKeys((prev) => { @@ -4087,10 +4474,10 @@ export default function FinishedDownloads({ setBulkBusy(true) try { - const res = await fetch('/api/record/delete-many', { + const res = await fetch('/api/record/delete-many?stream=1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ files: selectedFiles }), + body: JSON.stringify({ files: selectedDeleteFiles }), }) if (!res.ok) { @@ -4098,23 +4485,44 @@ export default function FinishedDownloads({ throw new Error(text || `HTTP ${res.status}`) } - const data = await res.json().catch(() => null) - const results = Array.isArray(data?.results) ? data.results : [] + if (res.body) { + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' - const deletedKeys = new Set() - const failedKeys = new Set() + for (;;) { + const { value, done } = await reader.read() - for (const item of results) { - const file = String(item?.file || '').trim() - if (!file) continue + if (done) break - const key = fileToKeyRef.current.get(file) || file + buffer += decoder.decode(value, { stream: true }) - if (item?.ok) { - deletedKeys.add(key) - removeRowNow(key, file) - } else { - failedKeys.add(key) + for (;;) { + const newlineIndex = buffer.indexOf('\n') + if (newlineIndex < 0) break + + const line = buffer.slice(0, newlineIndex).trim() + buffer = buffer.slice(newlineIndex + 1) + + if (!line) continue + handleResult(JSON.parse(line)) + } + } + + buffer += decoder.decode() + const tail = buffer.trim() + if (tail) { + for (const line of tail.split(/\r?\n/)) { + const cleanLine = line.trim() + if (cleanLine) handleResult(JSON.parse(cleanLine)) + } + } + } else { + const data = await res.json().catch(() => null) + const results = Array.isArray(data?.results) ? data.results : [] + + for (const item of results) { + handleResult(item) } } @@ -4127,31 +4535,10 @@ export default function FinishedDownloads({ } if (failedKeys.size > 0) { - setDeletingKeys((prev) => { - const next = new Set(prev) - for (const key of failedKeys) { - next.delete(key) - } - return next - }) + clearDeleting(failedKeys) } - // Erfolgreiche Keys nach der Remove-Animation aus dem Busy-State räumen. - if (deletedKeys.size > 0) { - window.setTimeout(() => { - setDeletingKeys((prev) => { - const next = new Set(prev) - for (const key of deletedKeys) { - next.delete(key) - } - return next - }) - }, 450) - } - - const deletedCount = Number( - data?.deleted ?? results.filter((x: any) => x?.ok).length ?? 0 - ) + const deletedCount = deletedKeys.size clearSelection() emitCountHint(-deletedCount) @@ -4161,13 +4548,13 @@ export default function FinishedDownloads({ } } catch (e: any) { // Bei komplettem Fehler alle zuvor markierten Overlays wieder entfernen. - setDeletingKeys((prev) => { - const next = new Set(prev) - for (const key of selectedDeleteKeys) { - next.delete(key) - } - return next - }) + clearDeleting(selectedDeleteKeys.filter((key) => !deletedKeys.has(key))) + + if (deletedKeys.size > 0) { + clearSelection() + emitCountHint(-deletedKeys.size) + notify.success?.('L\u00f6schen erfolgreich', `${deletedKeys.size} Downloads gel\u00f6scht.`) + } notify.error('Bulk-Löschen fehlgeschlagen', String(e?.message || e)) } finally { @@ -4176,9 +4563,10 @@ export default function FinishedDownloads({ }, [ bulkBusy, selectedFiles, + selectedJobsForBulk, clearSelection, emitCountHint, - removeRowNow, + completeDeletedRow, notify, ]) @@ -4311,14 +4699,20 @@ export default function FinishedDownloads({ ]) useEffect(() => { - if (view !== 'gallery') return + if (mobileCardStackMode) return - const wanted = galleryPageSize + const wanted = recommendedPageSizeByView[view] ?? pageSize if (pageSize !== wanted) { onPageSizeChange?.(wanted) } - }, [view, pageSize, galleryPageSize, onPageSizeChange]) + }, [ + view, + pageSize, + mobileCardStackMode, + recommendedPageSizeByView, + onPageSizeChange, + ]) useEffect(() => { try { @@ -4802,7 +5196,7 @@ export default function FinishedDownloads({ const payload: PersistedUndoState = { v: 1, - action: lastAction, + action: undoActionForStorage(lastAction), ts: Date.now(), } @@ -4969,14 +5363,22 @@ export default function FinishedDownloads({ useEffect(() => { const onExternalDelete = (ev: Event) => { - const detail = (ev as CustomEvent<{ file: string; phase: 'start' | 'success' | 'error' }>).detail + const detail = (ev as CustomEvent<{ + file: string + phase: 'start' | 'success' | 'error' + kind?: 'delete' | 'keep' + }>).detail if (!detail?.file) return const key = fileToKeyRef.current.get(detail.file) || detail.file + const kind = detail.kind === 'keep' ? 'keep' : 'delete' if (detail.phase === 'start') { - markDeleting(key, true) - animateRemove(key, detail.file) + if (kind === 'keep') { + markKeeping(key, true) + } else { + markDeleting(key, true) + } return } @@ -4987,13 +5389,18 @@ export default function FinishedDownloads({ } if (detail.phase === 'success') { - markDeleting(key, false) + if (kind === 'keep') { + markKeeping(key, false) + animateRemove(key, detail.file) + } else { + completeDeletedRow(key, detail.file) + } } } window.addEventListener('finished-downloads:delete', onExternalDelete as EventListener) return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener) - }, [animateRemove, markDeleting, restoreRow]) + }, [animateRemove, completeDeletedRow, markDeleting, markKeeping, restoreRow]) useEffect(() => { const onExternalRename = (ev: Event) => { @@ -5330,7 +5737,7 @@ export default function FinishedDownloads({ onClose={() => setReportOpen(false)} /> -
+
{/* Toolbar links neben dem Content, nicht per Transform aus dem Container geschoben */}
diff --git a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx index 4b01d5a..97c727c 100644 --- a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx @@ -53,6 +53,7 @@ import PreviewRatingOverlay from './PreviewRatingOverlay' import PreviewMetaOverlay from './PreviewMetaOverlay' import DeletingPreviewOverlay from './DeletingPreviewOverlay' import { autoTagsForJob, mergeTags } from './autoTags' +import { useFinishedDownloadsAutoGridPageSize } from './useFinishedDownloadsAutoPageSize' type SwipeRefs = { @@ -75,12 +76,14 @@ type Props = { teaserState: FinishedDownloadsTeaserState deletingKeys: Set + deletedSuccessKeys: Set keepingKeys: Set removingKeys: Set swipeRefs: SwipeRefs assetNonce?: number + assetNonceForJob?: (job: RecordJob) => number jobForDetails: (job: RecordJob) => RecordJob handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void @@ -108,7 +111,10 @@ type Props = { handleResolution: (job: RecordJob, w: number, h: number) => void resolutionLabelOf: (job: RecordJob) => string - deleteVideo: (job: RecordJob) => Promise + deleteVideo: ( + job: RecordJob, + opts?: { alreadyRemoved?: boolean; optimisticRemove?: boolean } + ) => Promise keepVideo: (job: RecordJob) => Promise releasePlayingFile: (file: string, opts?: { close?: boolean }) => Promise @@ -136,6 +142,8 @@ type Props = { onUnlockMobileTeaserAudio?: () => void renderIntegrityBadge?: (job: RecordJob) => ReactNode renderRatingOverlay?: (job: RecordJob) => ReactNode + onRecommendedPageSizeChange?: (size: number) => void + suspendAutoPageSize?: boolean } function CardBlurWrapper({ @@ -330,11 +338,13 @@ function FinishedDownloadsCardsView({ teaserState, inlinePlay, deletingKeys, + deletedSuccessKeys, keepingKeys, removingKeys, swipeRefs, assetNonce, + assetNonceForJob, jobForDetails, handleScrubberClickIndex, @@ -378,8 +388,29 @@ function FinishedDownloadsCardsView({ onUnlockMobileTeaserAudio, renderIntegrityBadge, renderRatingOverlay, + onRecommendedPageSizeChange, + suspendAutoPageSize, }: Props) { + const rootRef = useRef(null) + const desktopGridMinCardWidth = 300 + const desktopGridGapPx = 12 + const estimateDesktopCardHeight = useCallback( + ({ itemWidth }: { itemWidth: number }) => { + return Math.round(itemWidth * 9 / 16 + 150) + }, + [] + ) + + useFinishedDownloadsAutoGridPageSize({ + rootRef, + enabled: !isSmall && !suspendAutoPageSize, + minItemWidth: desktopGridMinCardWidth, + gapPx: desktopGridGapPx, + fallbackItemHeight: estimateDesktopCardHeight, + onRecommendedPageSizeChange, + }) + const footerTapRef = useRef>({}) const teaserPlayFnsRef = useRef void) | null>>({}) @@ -451,6 +482,7 @@ function FinishedDownloadsCardsView({ } ) => { const k = keyFor(j) + const rowAssetNonce = assetNonceForJob?.(j) ?? assetNonce ?? 0 const handleSelectOrOpen = () => { if (selectionModeActive) { @@ -497,7 +529,8 @@ function FinishedDownloadsCardsView({ disabled: false, }) - const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k) + const deleteDone = deletedSuccessKeys.has(k) + const busy = deletingKeys.has(k) || deleteDone || keepingKeys.has(k) || removingKeys.has(k) const model = modelNameFromOutput(j.output) const fileRaw = baseName(j.output || '') @@ -516,7 +549,7 @@ function FinishedDownloadsCardsView({ const sprite = readPreviewSpriteInfo((j as any)?.meta, { fallbackPath: flags?.previewScrubberPath, fallbackCount: flags?.previewScrubberCount, - versionFallback: assetNonce ?? 0, + versionFallback: rowAssetNonce, }) const spriteUrl = sprite.url @@ -603,6 +636,7 @@ function FinishedDownloadsCardsView({ 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500', busy && 'pointer-events-none opacity-70', deletingKeys.has(k) && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30', + deleteDone && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30', keepingKeys.has(k) && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30', removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]', ] @@ -737,7 +771,7 @@ function FinishedDownloadsCardsView({ inlineLoop={false} muted={previewMuted} popoverMuted={previewMuted} - assetNonce={assetNonce ?? 0} + assetNonce={rowAssetNonce} alwaysLoadStill={forceLoadStill} teaserPreloadEnabled={teaserPreloadEnabled} teaserPreloadRootMargin={ @@ -854,8 +888,8 @@ function FinishedDownloadsCardsView({
) : null} - {deletingKeys.has(k) ? ( - + {deletingKeys.has(k) || deleteDone ? ( + ) : null}
@@ -1167,9 +1201,14 @@ function FinishedDownloadsCardsView({ return ( -
+
{!isSmall ? ( -
+
{rows.map((j) => { const k = keyFor(j) @@ -1180,6 +1219,7 @@ function FinishedDownloadsCardsView({ selectionStore={selectionStore} selectionModeActive={selectionModeActive} isDeleting={deletingKeys.has(k)} + isDeleted={deletedSuccessKeys.has(k)} isKeeping={keepingKeys.has(k)} isRemoving={removingKeys.has(k)} postworkBadge={getPostworkSummaryForOutput(j.output, postworkByFile)} @@ -1187,7 +1227,7 @@ function FinishedDownloadsCardsView({ durationSeconds={durations[k]} teaserState={teaserState} inlinePlay={inlinePlay} - assetNonce={assetNonce} + assetNonce={assetNonceForJob?.(j) ?? assetNonce} forcePreviewMuted={forcePreviewMuted} keyFor={keyFor} baseName={baseName} @@ -1390,10 +1430,11 @@ function FinishedDownloadsCardsView({ } }) }} - onSwipeLeft={async () => { + onSwipeLeft={() => { lockMobileStackHeight() setMobileTopTeaserEnabled(false) - return await deleteVideo(j) + void deleteVideo(j, { optimisticRemove: true }) + return true }} onSwipeRight={async () => { lockMobileStackHeight() diff --git a/frontend/src/components/ui/FinishedDownloadsDesktopCard.tsx b/frontend/src/components/ui/FinishedDownloadsDesktopCard.tsx index b0607d8..557bfc1 100644 --- a/frontend/src/components/ui/FinishedDownloadsDesktopCard.tsx +++ b/frontend/src/components/ui/FinishedDownloadsDesktopCard.tsx @@ -41,6 +41,7 @@ import { } from './videoHeatmap' import PreviewRatingOverlay from './PreviewRatingOverlay' import PreviewMetaOverlay from './PreviewMetaOverlay' +import DeletingPreviewOverlay from './DeletingPreviewOverlay' import { autoTagsForJob, mergeTags } from './autoTags' export type FinishedDownloadsDesktopCardProps = { @@ -48,6 +49,7 @@ export type FinishedDownloadsDesktopCardProps = { selectionStore: FinishedSelectionStore selectionModeActive: boolean isDeleting: boolean + isDeleted: boolean isKeeping: boolean isRemoving: boolean postworkBadge?: FinishedPostworkSummary @@ -140,6 +142,7 @@ const FinishedDownloadsDesktopCard = memo( selectionStore, selectionModeActive, isDeleting, + isDeleted, isKeeping, isRemoving, postworkBadge, @@ -183,7 +186,7 @@ const FinishedDownloadsDesktopCard = memo( renderRatingOverlay, }: FinishedDownloadsDesktopCardProps) { const k = keyFor(j) - const busy = isDeleting || isKeeping || isRemoving + const busy = isDeleting || isDeleted || isKeeping || isRemoving const checked = useSelectionChecked(selectionStore, k) const longPressTimerRef = useRef(null) const longPressTriggeredRef = useRef(false) @@ -334,6 +337,7 @@ const FinishedDownloadsDesktopCard = memo( checked && 'ring-2 ring-indigo-500 bg-indigo-50/60 dark:bg-indigo-500/10 dark:ring-indigo-400', busy && 'pointer-events-none opacity-70', isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30', + isDeleted && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30', isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30', isRemoving && 'opacity-0 translate-y-2 scale-[0.98]', ].filter(Boolean).join(' ')} @@ -487,6 +491,10 @@ const FinishedDownloadsDesktopCard = memo( />
) : null} + + {isDeleting || isDeleted ? ( + + ) : null}
@@ -606,6 +614,7 @@ const FinishedDownloadsDesktopCard = memo( return ( prev.job === next.job && prev.isDeleting === next.isDeleting && + prev.isDeleted === next.isDeleted && prev.isKeeping === next.isKeeping && prev.isRemoving === next.isRemoving && samePostworkSummaryLite(prev.postworkBadge, next.postworkBadge) && diff --git a/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx b/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx index 46f9834..ea8e044 100644 --- a/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx +++ b/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx @@ -74,6 +74,7 @@ type Props = { activeTagSet: Set deletingKeys: Set + deletedSuccessKeys: Set keepingKeys: Set removingKeys: Set @@ -132,6 +133,7 @@ function FinishedDownloadsGalleryCardInner({ modelsByKey, activeTagSet, deletingKeys, + deletedSuccessKeys, keepingKeys, removingKeys, registerTeaserHost, @@ -304,8 +306,9 @@ function FinishedDownloadsGalleryCardInner({ const isRemoving = removingKeys.has(k) const isDeleting = deletingKeys.has(k) + const isDeleted = deletedSuccessKeys.has(k) const isKeeping = keepingKeys.has(k) - const busy = isDeleting || isKeeping || isRemoving + const busy = isDeleting || isDeleted || isKeeping || isRemoving const sprite = useMemo( () => @@ -404,6 +407,7 @@ function FinishedDownloadsGalleryCardInner({ checked && 'ring-2 ring-indigo-500 bg-indigo-50/60 dark:bg-indigo-500/10 dark:ring-indigo-400', busy && !isRemoving && 'pointer-events-none opacity-70', isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30', + isDeleted && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30', isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30', isRemoving ? 'pointer-events-none opacity-0 translate-y-2 scale-[0.98]' : 'opacity-100 translate-y-0 scale-100', ].filter(Boolean).join(' ')} @@ -559,8 +563,8 @@ function FinishedDownloadsGalleryCardInner({
) : null} - {isDeleting ? ( - + {isDeleting || isDeleted ? ( + ) : null} @@ -676,4 +680,4 @@ function FinishedDownloadsGalleryCardInner({ } const FinishedDownloadsGalleryCard = memo(FinishedDownloadsGalleryCardInner) -export default FinishedDownloadsGalleryCard \ No newline at end of file +export default FinishedDownloadsGalleryCard diff --git a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx index f8875c6..c6d314d 100644 --- a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx @@ -46,6 +46,7 @@ type Props = { formatBytes: (bytes?: number | null) => string deletingKeys: Set + deletedSuccessKeys: Set keepingKeys: Set removingKeys: Set @@ -68,11 +69,13 @@ type Props = { onSplit?: (job: RecordJob) => void | Promise onAddToDownloads?: (job: RecordJob) => void | Promise onRecommendedPageSizeChange?: (size: number) => void + suspendAutoPageSize?: boolean forcePreviewMuted?: boolean mobileTeaserAudioUnlocked?: boolean onUnlockMobileTeaserAudio?: () => void assetNonce?: number + assetNonceForJob?: (job: RecordJob) => number renderIntegrityBadge?: (job: RecordJob) => ReactNode renderRatingOverlay?: (job: RecordJob) => ReactNode } @@ -114,6 +117,7 @@ function FinishedDownloadsGalleryView({ sizeBytesOf, formatBytes, deletingKeys, + deletedSuccessKeys, keepingKeys, removingKeys, registerTeaserHost, @@ -136,8 +140,10 @@ function FinishedDownloadsGalleryView({ mobileTeaserAudioUnlocked, onUnlockMobileTeaserAudio, assetNonce, + assetNonceForJob, renderIntegrityBadge, renderRatingOverlay, + suspendAutoPageSize, }: Props) { const observeTeasers = shouldObserveTeasers(teaserState.mode) @@ -181,7 +187,7 @@ function FinishedDownloadsGalleryView({ const rafRef = useRef(null) const recommendPageSize = useCallback(() => { - if (!onRecommendedPageSizeChange) return + if (suspendAutoPageSize || !onRecommendedPageSizeChange) return const el = rootRef.current if (!el) return @@ -197,8 +203,40 @@ function FinishedDownloadsGalleryView({ Math.floor((containerWidth + gapPx) / (minCardWidth + gapPx)) ) - const rowsPerPage = 2 - const nextPageSize = clamp(columns * rowsPerPage, rowsPerPage, 24) + const grid = el.firstElementChild + const itemEls = + grid instanceof HTMLElement + ? Array.from(grid.children).filter((child): child is HTMLElement => child instanceof HTMLElement) + : [] + const measuredCardHeight = itemEls.reduce((maxHeight, child) => { + const height = child.getBoundingClientRect().height + return Number.isFinite(height) && height > maxHeight ? height : maxHeight + }, 0) + const estimatedCardWidth = + columns > 0 + ? (containerWidth - gapPx * (columns - 1)) / columns + : minCardWidth + const fallbackFooterHeight = + sizePreset === 'xl' + ? 132 + : sizePreset === 'lg' + ? 124 + : sizePreset === 'md' + ? 118 + : 110 + const cardHeight = Math.max( + 1, + measuredCardHeight || Math.round(estimatedCardWidth * 9 / 16 + fallbackFooterHeight + 2) + ) + const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0 + const top = Math.max(0, el.getBoundingClientRect().top) + const bottomReservePx = 112 + const availableHeight = Math.max(0, viewportHeight - top - bottomReservePx) + const rowsPerPage = Math.max( + 1, + Math.floor((availableHeight + gapPx) / (cardHeight + gapPx)) + ) + const nextPageSize = clamp(columns * rowsPerPage, columns, 120) const currentPageSize = lastRecommendedPageSizeRef.current if (currentPageSize === nextPageSize) return @@ -233,7 +271,7 @@ function FinishedDownloadsGalleryView({ Math.floor((stableWidth + gapPx) / (minCardWidth + gapPx)) ) - const stablePageSize = clamp(stableColumns * rowsPerPage, rowsPerPage, 24) + const stablePageSize = clamp(stableColumns * rowsPerPage, stableColumns, 120) if (stablePageSize >= (lastRecommendedPageSizeRef.current ?? 0)) { return @@ -252,9 +290,11 @@ function FinishedDownloadsGalleryView({ rafRef.current = null measureAndApply() }) - }, [minCardWidth, onRecommendedPageSizeChange]) + }, [minCardWidth, onRecommendedPageSizeChange, suspendAutoPageSize]) useEffect(() => { + if (suspendAutoPageSize) return + recommendPageSize() const el = rootRef.current @@ -281,7 +321,7 @@ function FinishedDownloadsGalleryView({ rafRef.current = null } } - }, [recommendPageSize]) + }, [recommendPageSize, suspendAutoPageSize]) const contentPaddingClass = sizePreset === 'xl' @@ -356,6 +396,7 @@ function FinishedDownloadsGalleryView({ modelsByKey={modelsByKey} activeTagSet={activeTagSet} deletingKeys={deletingKeys} + deletedSuccessKeys={deletedSuccessKeys} keepingKeys={keepingKeys} removingKeys={removingKeys} registerTeaserHost={registerTeaserHostIfNeeded} @@ -381,7 +422,7 @@ function FinishedDownloadsGalleryView({ footerMinHeightClass={footerMinHeightClass} titleClass={titleClass} sublineClass={sublineClass} - assetNonce={assetNonce ?? 0} + assetNonce={assetNonceForJob?.(j) ?? assetNonce ?? 0} renderIntegrityBadge={renderIntegrityBadge} renderRatingOverlay={renderRatingOverlay} mobileTeaserAudioUnlocked={mobileTeaserAudioUnlocked} @@ -405,4 +446,4 @@ function FinishedDownloadsGalleryView({ } const MemoFinishedDownloadsGalleryView = memo(FinishedDownloadsGalleryView) -export default MemoFinishedDownloadsGalleryView \ No newline at end of file +export default MemoFinishedDownloadsGalleryView diff --git a/frontend/src/components/ui/FinishedDownloadsTableView.tsx b/frontend/src/components/ui/FinishedDownloadsTableView.tsx index bbe21de..ced6770 100644 --- a/frontend/src/components/ui/FinishedDownloadsTableView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsTableView.tsx @@ -2,7 +2,7 @@ 'use client' -import { useState, useMemo, useCallback, type Dispatch, type ReactNode, type SetStateAction } from 'react' +import { useState, useMemo, useCallback, useRef, type Dispatch, type ReactNode, type SetStateAction } from 'react' import Table, { type Column, type SortState } from './Table' import type { RecordJob, @@ -26,6 +26,8 @@ import ModelGenderIcon from './ModelGenderIcon' import { resolveFinishedModelFlags } from './finishedModelFlags' import CountryFlag, { resolveFinishedCountry } from './CountryFlag' import PreviewRatingOverlay from './PreviewRatingOverlay' +import { useFinishedDownloadsAutoTablePageSize } from './useFinishedDownloadsAutoPageSize' +import DeletingPreviewOverlay from './DeletingPreviewOverlay' type Props = { rows: RecordJob[] @@ -62,9 +64,11 @@ type Props = { handleResolution: (job: RecordJob, w: number, h: number) => void blurPreviews?: boolean assetNonce?: number + assetNonceForJob?: (job: RecordJob) => number // state/flags for actions row + rowClassName deletingKeys: Set + deletedSuccessKeys: Set keepingKeys: Set removingKeys: Set @@ -91,6 +95,8 @@ type Props = { forcePreviewMuted?: boolean renderIntegrityBadge?: (job: RecordJob) => ReactNode renderRatingOverlay?: (job: RecordJob) => ReactNode + onRecommendedPageSizeChange?: (size: number) => void + suspendAutoPageSize?: boolean } export default function FinishedDownloadsTableView({ @@ -122,8 +128,10 @@ export default function FinishedDownloadsTableView({ handleResolution, blurPreviews, assetNonce, + assetNonceForJob, deletingKeys, + deletedSuccessKeys, keepingKeys, removingKeys, @@ -148,8 +156,19 @@ export default function FinishedDownloadsTableView({ forcePreviewMuted, renderIntegrityBadge, renderRatingOverlay, + onRecommendedPageSizeChange, + suspendAutoPageSize, }: Props) { const [sort, setSort] = useState(null) + const rootRef = useRef(null) + + useFinishedDownloadsAutoTablePageSize({ + rootRef, + enabled: !suspendAutoPageSize, + fallbackRowHeight: 96, + fallbackHeaderHeight: 48, + onRecommendedPageSizeChange, + }) const runtimeSecondsForSort = useCallback((job: RecordJob) => { // 1) Prefer real video duration (ffprobe / backend) @@ -292,6 +311,8 @@ export default function FinishedDownloadsTableView({ widthClassName: 'w-[140px]', cell: (j) => { const k = keyFor(j) + const isDeleting = deletingKeys.has(k) + const isDeleted = deletedSuccessKeys.has(k) const isPreviewActive = teaserState.activeKey === k || teaserState.hoverKey === k const allowSound = shouldEnableTeaserAudio({ @@ -304,6 +325,7 @@ export default function FinishedDownloadsTableView({ const fileRaw = baseName(j.output || '') const previewId = stripHotPrefix(fileRaw.replace(/\.[^.]+$/, '')).trim() + const rowAssetNonce = assetNonceForJob?.(j) ?? assetNonce ?? 0 const scrubTimeSec = spriteInfo && typeof scrubActiveIndex === 'number' @@ -312,7 +334,7 @@ export default function FinishedDownloadsTableView({ const scrubFrameSrc = previewId && typeof scrubTimeSec === 'number' - ? `/api/preview?id=${encodeURIComponent(previewId)}&t=${scrubTimeSec.toFixed(3)}&v=${assetNonce ?? 0}` + ? `/api/preview?id=${encodeURIComponent(previewId)}&t=${scrubTimeSec.toFixed(3)}&v=${rowAssetNonce}` : '' return ( @@ -347,7 +369,7 @@ export default function FinishedDownloadsTableView({ })} animatedMode="teaser" animatedTrigger="always" - assetNonce={assetNonce} + assetNonce={rowAssetNonce} forceActive={isPreviewActive} teaserPreloadEnabled={false} /> @@ -365,6 +387,10 @@ export default function FinishedDownloadsTableView({ draggable={false} /> ) : null} + + {isDeleting || isDeleted ? ( + + ) : null} ) @@ -531,7 +557,7 @@ export default function FinishedDownloadsTableView({ srOnlyHeader: true, cell: (j) => { const k = keyFor(j) - const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k) + const busy = deletingKeys.has(k) || deletedSuccessKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k) const fileRaw = baseName(j.output || '') const isHot = isHotName(fileRaw) @@ -585,6 +611,7 @@ export default function FinishedDownloadsTableView({ blurPreviews, teaserState, assetNonce, + assetNonceForJob, lower, modelNameFromOutput, modelsByKey, @@ -593,6 +620,7 @@ export default function FinishedDownloadsTableView({ handlePrimaryAction, resolutions, deletingKeys, + deletedSuccessKeys, keepingKeys, removingKeys, previewSpriteInfoOf, @@ -651,6 +679,7 @@ export default function FinishedDownloadsTableView({ 'transition-all duration-300', (deletingKeys.has(k) || removingKeys.has(k)) && 'bg-red-50/60 dark:bg-red-500/10 pointer-events-none', deletingKeys.has(k) && 'animate-pulse', + deletedSuccessKeys.has(k) && 'bg-emerald-50/60 dark:bg-emerald-500/10 pointer-events-none', (keepingKeys.has(k) || removingKeys.has(k)) && 'pointer-events-none', keepingKeys.has(k) && 'bg-emerald-50/60 dark:bg-emerald-500/10 animate-pulse', removingKeys.has(k) && 'opacity-0', @@ -658,11 +687,11 @@ export default function FinishedDownloadsTableView({ .filter(Boolean) .join(' ') }, - [keyFor, deletingKeys, keepingKeys, removingKeys] + [keyFor, deletingKeys, deletedSuccessKeys, keepingKeys, removingKeys] ) return ( -
+
(null) const teaserSoundForcedRef = useRef(false) const teaserMutedRef = useRef(muted) + const teaserSpeedHoldRef = useRef(false) const setTeaserPlaybackRate = useCallback((rate: number) => { const el = teaserMp4Ref.current @@ -945,7 +947,10 @@ export default function FinishedVideoPreview({ (forceActive || effectiveNearView || effectiveInView || (wantsHover && hovered)) useEffect(() => { + teaserSpeedHoldRef.current = teaserSpeedHold + if (animatedMode !== 'teaser' || showingInlineVideo || !teaserActive) { + teaserSpeedHoldRef.current = false setTeaserPlaybackRate(1) return } @@ -1237,8 +1242,9 @@ export default function FinishedVideoPreview({ el.playsInline = true el.loop = true el.autoplay = true - el.playbackRate = teaserSpeedHold ? 2 : 1 - el.defaultPlaybackRate = teaserSpeedHold ? 2 : 1 + const playbackRate = teaserSpeedHoldRef.current ? 2 : 1 + el.playbackRate = playbackRate + el.defaultPlaybackRate = playbackRate } catch {} if (opts?.resetToStart) { @@ -1305,6 +1311,7 @@ export default function FinishedVideoPreview({ vv.addEventListener('canplay', onCanPlay) vv.addEventListener('pause', onPause) vv.addEventListener('waiting', onWaiting) + teaserLastProgressTimeRef.current = performance.now() // frischer Start für neue TopCard tryPlay({ resetToStart: true }) @@ -1328,7 +1335,7 @@ export default function FinishedVideoPreview({ const stalledFor = now - teaserLastProgressTimeRef.current const ct = Number(el.currentTime) - if ((!Number.isFinite(ct) || ct <= 0.05) && stalledFor > teaserWatchdogMs) { + if ((!Number.isFinite(ct) || ct <= 0.05) && stalledFor > teaserStartResetWatchdogMs) { tryPlay({ resetToStart: true }) return } @@ -1359,7 +1366,7 @@ export default function FinishedVideoPreview({ pageVisible, teaserRetryDelayMs, teaserWatchdogMs, - teaserSpeedHold, + teaserStartResetWatchdogMs, ]) useEffect(() => { @@ -1674,7 +1681,7 @@ export default function FinishedVideoPreview({ playsInline autoPlay loop - preload={mobilePreviewConstrained ? 'metadata' : 'auto'} + preload="auto" poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined} onLoadedData={markTeaserAvailable} onCanPlay={markTeaserAvailable} diff --git a/frontend/src/components/ui/RecordJobActions.tsx b/frontend/src/components/ui/RecordJobActions.tsx index f7ff840..498fbf0 100644 --- a/frontend/src/components/ui/RecordJobActions.tsx +++ b/frontend/src/components/ui/RecordJobActions.tsx @@ -901,7 +901,11 @@ export default function RecordJobActions({ e.preventDefault() e.stopPropagation() setMenuOpen(false) - await onKeep?.(job) + try { + await onKeep?.(job) + } catch { + // The app-level handler already reports delete/keep failures. + } }} > @@ -921,7 +925,11 @@ export default function RecordJobActions({ e.preventDefault() e.stopPropagation() setMenuOpen(false) - await onDelete?.(job) + try { + await onDelete?.(job) + } catch { + // The app-level handler already reports delete/keep failures. + } }} > diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx index 6e90b46..cfe0c6a 100644 --- a/frontend/src/components/ui/TrainingTab.tsx +++ b/frontend/src/components/ui/TrainingTab.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' import Button from './Button' +import LabeledSwitch from './LabeledSwitch' import LoadingSpinner from './LoadingSpinner' import { formatDuration } from './formatters' import { @@ -128,6 +129,12 @@ type CorrectionState = { boxes: TrainingBox[] } +type QueuedTrainingSample = { + sample: TrainingSample + correction?: CorrectionState + manualCorrection?: boolean +} + type TrainingBox = { label: string score?: number @@ -149,6 +156,9 @@ type TrainingPosePerson = { score: number box: TrainingBox keypoints: TrainingKeypoint[] + quality?: number + visibleKeypoints?: number + reliable?: boolean } type BoxInteraction = @@ -271,6 +281,170 @@ type TrainingFeedbackListResponse = { type TrainingSampleMode = 'random' | 'uncertain' type FeedbackFilter = 'all' | 'accepted' | 'corrected' | 'negative' +const POSE_KEYPOINT_MIN_CONFIDENCE = 0.15 +const POSE_PERSON_MIN_SCORE = 0.30 +const POSE_RELIABLE_KEYPOINT_MIN_CONFIDENCE = 0.20 +const POSE_RELIABLE_MIN_SCORE = 0.30 +const POSE_RELIABLE_MIN_KEYPOINTS = 6 +const POSE_RELIABLE_MIN_QUALITY = 0.45 +const POSE_UNRELIABLE_COLOR = '#94a3b8' +const POSE_PERSON_COLORS = ['#38bdf8', '#a78bfa', '#34d399', '#f59e0b', '#fb7185'] +const POSE_SKELETON_EDGES: Array = [ + ['left_ear', 'left_eye'], + ['left_eye', 'nose'], + ['nose', 'right_eye'], + ['right_eye', 'right_ear'], + ['left_shoulder', 'right_shoulder'], + ['left_shoulder', 'left_elbow'], + ['left_elbow', 'left_wrist'], + ['right_shoulder', 'right_elbow'], + ['right_elbow', 'right_wrist'], + ['left_shoulder', 'left_hip'], + ['right_shoulder', 'right_hip'], + ['left_hip', 'right_hip'], + ['left_hip', 'left_knee'], + ['left_knee', 'left_ankle'], + ['right_hip', 'right_knee'], + ['right_knee', 'right_ankle'], +] + +const POSE_KEYPOINT_LABELS: Record = { + nose: 'Nase', + left_eye: 'L Auge', + right_eye: 'R Auge', + left_ear: 'L Ohr', + right_ear: 'R Ohr', + left_shoulder: 'L Schulter', + right_shoulder: 'R Schulter', + left_elbow: 'L Ellbogen', + right_elbow: 'R Ellbogen', + left_wrist: 'L Hand', + right_wrist: 'R Hand', + left_hip: 'L Huefte', + right_hip: 'R Huefte', + left_knee: 'L Knie', + right_knee: 'R Knie', + left_ankle: 'L Fuss', + right_ankle: 'R Fuss', +} + +function poseKeypointId(name?: string | null) { + return String(name ?? '').trim().toLowerCase() +} + +function poseKeypointLabel(name?: string | null) { + const key = poseKeypointId(name) + return POSE_KEYPOINT_LABELS[key] || key.replace(/_/g, ' ') || 'Punkt' +} + +function isPoseKeypointVisible(point?: TrainingKeypoint | null): point is TrainingKeypoint { + if (!point) return false + + const x = Number(point.x) + const y = Number(point.y) + const conf = Number(point.conf) + + return ( + Number.isFinite(x) && + Number.isFinite(y) && + Number.isFinite(conf) && + conf >= POSE_KEYPOINT_MIN_CONFIDENCE + ) +} + +function isPoseReliableKeypoint(point?: TrainingKeypoint | null): point is TrainingKeypoint { + if (!point) return false + + const x = Number(point.x) + const y = Number(point.y) + const conf = Number(point.conf) + + return ( + Number.isFinite(x) && + Number.isFinite(y) && + Number.isFinite(conf) && + x >= 0 && + x <= 1 && + y >= 0 && + y <= 1 && + conf >= POSE_RELIABLE_KEYPOINT_MIN_CONFIDENCE + ) +} + +function posePersonVisibleKeypoints(person: TrainingPosePerson) { + const direct = Number(person.visibleKeypoints) + if (Number.isFinite(direct) && direct >= 0) { + return Math.floor(direct) + } + + return (person.keypoints ?? []).filter(isPoseReliableKeypoint).length +} + +function posePersonQuality(person: TrainingPosePerson) { + const direct = Number(person.quality) + if (Number.isFinite(direct) && direct >= 0) { + return clamp01(direct) + } + + const reliableKeypoints = (person.keypoints ?? []).filter(isPoseReliableKeypoint) + if (reliableKeypoints.length === 0) return 0 + + const totalConfidence = reliableKeypoints.reduce( + (sum, point) => sum + clamp01(Number(point.conf)), + 0 + ) + const coverage = clamp01(reliableKeypoints.length / 17) + const averageConfidence = clamp01(totalConfidence / reliableKeypoints.length) + + return clamp01(coverage * 0.45 + averageConfidence * 0.55) +} + +function isPosePersonReliable(person: TrainingPosePerson) { + if (typeof person.reliable === 'boolean') { + return person.reliable + } + + return ( + clamp01(Number(person.score)) >= POSE_RELIABLE_MIN_SCORE && + posePersonVisibleKeypoints(person) >= POSE_RELIABLE_MIN_KEYPOINTS && + posePersonQuality(person) >= POSE_RELIABLE_MIN_QUALITY + ) +} + +function hasVisiblePoseBox(person: TrainingPosePerson) { + const box = person.box + if (!box) return false + + const w = Number(box.w) + const h = Number(box.h) + + return ( + clamp01(Number(person.score)) >= POSE_PERSON_MIN_SCORE && + Number.isFinite(w) && + Number.isFinite(h) && + w > 0 && + h > 0 + ) +} + +function poseCoordPx(value: number, size: number) { + return clamp01(Number(value)) * Math.max(0, Number(size) || 0) +} + +function poseBoxPixelStyle(box: TrainingBox, layerWidth: number, layerHeight: number): CSSProperties { + const x = clamp01(Number(box.x)) + const y = clamp01(Number(box.y)) + const w = Math.max(0, Math.min(1 - x, clamp01(Number(box.w)))) + const h = Math.max(0, Math.min(1 - y, clamp01(Number(box.h)))) + + return { + left: poseCoordPx(x, layerWidth), + top: poseCoordPx(y, layerHeight), + width: w * Math.max(0, Number(layerWidth) || 0), + height: h * Math.max(0, Number(layerHeight) || 0), + } +} + function backendText(data: any, fallback: string) { return String( data?.message || @@ -761,14 +935,24 @@ function predictionToCorrection(sample: TrainingSample | null): CorrectionState } } -function correctionHasRelevantContent(value: CorrectionState) { +function cloneCorrectionState(value: CorrectionState): CorrectionState { + return { + sexPosition: value.sexPosition, + peoplePresent: [...value.peoplePresent], + bodyPartsPresent: [...value.bodyPartsPresent], + objectsPresent: [...value.objectsPresent], + clothingPresent: [...value.clothingPresent], + boxes: (value.boxes ?? []).map((box) => ({ ...box })), + } +} + +function correctionHasTrainablePositionOrBoxes(value: CorrectionState) { return ( (value.sexPosition && !isNoSexPositionValue(value.sexPosition)) || - (value.peoplePresent?.length ?? 0) > 0 || - (value.bodyPartsPresent?.length ?? 0) > 0 || - (value.objectsPresent?.length ?? 0) > 0 || - (value.clothingPresent?.length ?? 0) > 0 || - (value.boxes?.length ?? 0) > 0 + (value.boxes ?? []).some((box) => { + const normalized = normalizeBox(box) + return Boolean(normalized.label && normalized.w > 0 && normalized.h > 0) + }) ) } @@ -1155,6 +1339,7 @@ function LabelToggleGrid(props: { values: string[] selected: string[] scores?: Record + activeCounts?: Record onToggle: (value: string) => void drawLabel?: string onDrawLabelChange?: (value: string) => void @@ -1172,7 +1357,8 @@ function LabelToggleGrid(props: { return (
{props.values.map((value) => { - const active = props.selected.includes(value) + const activeCount = Math.max(0, Math.floor(Number(props.activeCounts?.[value] ?? 0))) + const active = props.selected.includes(value) || activeCount > 0 const item = getSegmentLabelItem(value) const Icon = item.icon const score = props.scores?.[value] @@ -1219,7 +1405,11 @@ function LabelToggleGrid(props: { {item.text} - {hasScore ? ( + {activeCount > 0 ? ( + + {activeCount === 1 ? '1 Box' : `${activeCount} Boxen`} + + ) : hasScore ? ( + activeCounts?: Record expanded: boolean onExpandedChange: (expanded: boolean) => void onToggle: (value: string) => void @@ -1603,9 +1794,18 @@ function CollapsibleLabelSection(props: { const hasDrawLabelInSection = cleanDrawLabel !== '' && props.values.includes(cleanDrawLabel) - const activeCount = props.singleDrawMode - ? Math.max(props.selected.length, hasDrawLabelInSection ? 1 : 0) - : props.selected.length + const countedActiveItems = props.activeCounts + ? props.values.reduce( + (sum, value) => sum + Math.max(0, Math.floor(Number(props.activeCounts?.[value] ?? 0))), + 0 + ) + : null + + const activeCount = countedActiveItems !== null + ? Math.max(countedActiveItems, hasDrawLabelInSection ? 1 : 0) + : props.singleDrawMode + ? Math.max(props.selected.length, hasDrawLabelInSection ? 1 : 0) + : props.selected.length const hasActiveItems = activeCount > 0 const shown = props.expanded @@ -1657,6 +1857,7 @@ function CollapsibleLabelSection(props: { values={props.values} selected={props.selected} scores={props.scores} + activeCounts={props.activeCounts} onToggle={props.onToggle} drawLabel={props.drawLabel} onDrawLabelChange={props.onDrawLabelChange} @@ -2559,9 +2760,9 @@ export default function TrainingTab(props: { const tabActive = props.active ?? true const [labels, setLabels] = useState(emptyLabels) const [sample, setSample] = useState(null) + const sampleRef = useRef(null) const [correction, setCorrection] = useState(() => predictionToCorrection(null)) const [hasManualCorrection, setHasManualCorrection] = useState(false) - const [negativeExample, setNegativeExample] = useState(false) const [loading, setLoading] = useState(false) const [analysisProgress, setAnalysisProgress] = useState(0) const [analysisStep, setAnalysisStep] = useState('') @@ -2591,8 +2792,8 @@ export default function TrainingTab(props: { } }) - const [importedSampleQueue, setImportedSampleQueue] = useState([]) - const importedSampleQueueRef = useRef([]) + const [importedSampleQueue, setImportedSampleQueue] = useState([]) + const importedSampleQueueRef = useRef([]) const [feedbackModalOpen, setFeedbackModalOpen] = useState(false) const [feedbackItems, setFeedbackItems] = useState([]) @@ -2628,6 +2829,7 @@ export default function TrainingTab(props: { return false } }) + const [showPoseSkeleton, setShowPoseSkeleton] = useState(true) const [frameNaturalSize, setFrameNaturalSize] = useState<{ width: number @@ -2770,10 +2972,16 @@ export default function TrainingTab(props: { ]) const editFeedbackItem = useCallback((item: TrainingAnnotation) => { - setSample(annotationToTrainingSample(item)) - setCorrection(annotationToCorrectionState(item)) - setHasManualCorrection(!item.accepted) - setNegativeExample(Boolean(item.negative)) + const nextSample = annotationToTrainingSample(item) + const nextCorrection = annotationToCorrectionState(item) + const nextManualCorrection = !item.accepted + + sampleRef.current = nextSample + correctionRef.current = nextCorrection + hasManualCorrectionRef.current = nextManualCorrection + setSample(nextSample) + setCorrection(nextCorrection) + setHasManualCorrection(nextManualCorrection) setEditingFeedback({ sampleId: item.sampleId, @@ -3000,6 +3208,10 @@ export default function TrainingTab(props: { importedSampleQueueRef.current = importedSampleQueue }, [importedSampleQueue]) + useEffect(() => { + sampleRef.current = sample + }, [sample]) + useEffect(() => { if (!feedbackModalOpen) return @@ -3050,8 +3262,11 @@ export default function TrainingTab(props: { }, [labels.people, labels.bodyParts, labels.objects, labels.clothing]) const correctionBoxes = correction.boxes ?? [] - const isNegativeCorrection = - negativeExample && !correctionHasRelevantContent(correction) + const hasTrainableFeedbackContent = useMemo( + () => correctionHasTrainablePositionOrBoxes(correction), + [correction] + ) + const willSaveAsNegative = Boolean(sample) && !hasTrainableFeedbackContent const visibleBoxes = [ ...correctionBoxes.map((box, index) => ({ box, index, isDraft: false })), @@ -3060,6 +3275,14 @@ export default function TrainingTab(props: { : []), ] + const posePersons = useMemo(() => { + return (sample?.prediction.persons ?? []).filter((person) => + hasVisiblePoseBox(person) || person.keypoints?.some(isPoseKeypointVisible) + ) + }, [sample?.prediction.persons]) + + const hasPosePersons = posePersons.length > 0 + const bodyPartScores = useMemo(() => { return Object.fromEntries( (sample?.prediction.bodyPartsPresent ?? []) @@ -3103,6 +3326,19 @@ export default function TrainingTab(props: { return Object.fromEntries(bestScores) }, [sample?.prediction.boxes, labels.people]) + const peopleBoxCounts = useMemo(() => { + const counts = new Map() + + for (const box of correctionBoxes) { + const cleanLabel = String(box.label || '').trim() + if (!labels.people.includes(cleanLabel)) continue + + counts.set(cleanLabel, (counts.get(cleanLabel) ?? 0) + 1) + } + + return Object.fromEntries(counts) + }, [correctionBoxes, labels.people]) + const selectedPeopleLabels = useMemo(() => { return correction.peoplePresent }, [correction.peoplePresent]) @@ -3346,9 +3582,11 @@ export default function TrainingTab(props: { const loadTrainingSampleIntoTab = useCallback(( nextSample: TrainingSample, - opts?: { manualCorrection?: boolean } + opts?: { manualCorrection?: boolean; correction?: CorrectionState } ) => { - const nextCorrection = predictionToCorrection(nextSample) + const nextCorrection = opts?.correction + ? cloneCorrectionState(opts.correction) + : predictionToCorrection(nextSample) setDrawingBox(null) setBoxInteraction(null) @@ -3364,10 +3602,12 @@ export default function TrainingTab(props: { }) }) + sampleRef.current = nextSample + correctionRef.current = nextCorrection + hasManualCorrectionRef.current = Boolean(opts?.manualCorrection) setSample(nextSample) setCorrection(nextCorrection) setHasManualCorrection(Boolean(opts?.manualCorrection)) - setNegativeExample(false) const initiallyExpandedSection: CorrectionSectionKey | null = nextCorrection.sexPosition && !isNoSexPositionValue(nextCorrection.sexPosition) @@ -3395,20 +3635,88 @@ export default function TrainingTab(props: { ) }, []) - const loadNextImportedQueuedSample = useCallback(() => { - const [nextSample, ...rest] = importedSampleQueueRef.current + const setQueuedTrainingSamples = useCallback((nextQueue: QueuedTrainingSample[]) => { + importedSampleQueueRef.current = nextQueue + setImportedSampleQueue(nextQueue) + }, []) - if (!nextSample) { + const currentSampleToQueuedItem = useCallback((): QueuedTrainingSample | null => { + const currentSample = sampleRef.current + + if (!currentSample) { + return null + } + + return { + sample: currentSample, + correction: cloneCorrectionState(correctionRef.current), + manualCorrection: hasManualCorrectionRef.current, + } + }, []) + + const loadQueuedTrainingSample = useCallback((item: QueuedTrainingSample) => { + loadTrainingSampleIntoTab(item.sample, { + correction: item.correction, + manualCorrection: item.manualCorrection, + }) + }, [loadTrainingSampleIntoTab]) + + const loadNextImportedQueuedSample = useCallback(() => { + const [nextItem, ...rest] = importedSampleQueueRef.current + + if (!nextItem) { return false } - importedSampleQueueRef.current = rest - setImportedSampleQueue(rest) + setQueuedTrainingSamples(rest) - loadTrainingSampleIntoTab(nextSample) + loadQueuedTrainingSample(nextItem) return true - }, [loadTrainingSampleIntoTab]) + }, [loadQueuedTrainingSample, setQueuedTrainingSamples]) + + const deferCurrentSampleToQueueEnd = useCallback(() => { + const currentItem = currentSampleToQueuedItem() + + if (!currentItem) { + return false + } + + setQueuedTrainingSamples([...importedSampleQueueRef.current, currentItem]) + return true + }, [currentSampleToQueuedItem, setQueuedTrainingSamples]) + + const loadPriorityTrainingSamples = useCallback(( + prioritySamples: TrainingSample[], + opts?: { deferCurrentSampleToQueueEnd?: boolean } + ) => { + const priorityItems = prioritySamples.map((prioritySample) => ({ + sample: prioritySample, + })) + + if (priorityItems.length === 0) { + return false + } + + const currentItem = opts?.deferCurrentSampleToQueueEnd + ? currentSampleToQueuedItem() + : null + const nextQueue = [ + ...priorityItems, + ...importedSampleQueueRef.current, + ...(currentItem ? [currentItem] : []), + ] + const [nextItem, ...rest] = nextQueue + + if (!nextItem) { + return false + } + + setQueuedTrainingSamples(rest) + loadQueuedTrainingSample(nextItem) + + return true + }, [currentSampleToQueuedItem, loadQueuedTrainingSample, setQueuedTrainingSamples]) const loadNext = useCallback(async (opts?: { forceNew?: boolean @@ -3416,6 +3724,7 @@ export default function TrainingTab(props: { preserveNotice?: boolean mode?: TrainingSampleMode previewUrl?: string + deferCurrentSampleToQueueEnd?: boolean }) => { const requestId = makeRequestId() activeAnalysisRequestIdRef.current = requestId @@ -3477,6 +3786,10 @@ export default function TrainingTab(props: { setAnalysisProgress(92) setAnalysisStep('Analyse-Ergebnis wird übernommen…') + if (opts?.deferCurrentSampleToQueueEnd) { + deferCurrentSampleToQueueEnd() + } + loadTrainingSampleIntoTab(data as TrainingSample) } catch (e) { if (isCurrentRequest()) { @@ -3501,7 +3814,7 @@ export default function TrainingTab(props: { setAnalysisStep('') }, 500) } - }, [loadTrainingSampleIntoTab, setLoadingPreviewCandidate]) + }, [deferCurrentSampleToQueueEnd, loadTrainingSampleIntoTab, setLoadingPreviewCandidate]) const reloadCurrentImage = useCallback(async () => { setDrawingBox(null) @@ -3595,12 +3908,11 @@ export default function TrainingTab(props: { throw new Error('Es wurden keine Trainingsframes erzeugt.') } - const [firstSample, ...queuedSamples] = samples + const deferredCurrentSample = Boolean(sampleRef.current) - importedSampleQueueRef.current = queuedSamples - setImportedSampleQueue(queuedSamples) - - loadTrainingSampleIntoTab(firstSample) + loadPriorityTrainingSamples(samples, { + deferCurrentSampleToQueueEnd: deferredCurrentSample, + }) setImageReloadKey((value) => value + 1) await loadTrainingStatus() @@ -3613,6 +3925,14 @@ export default function TrainingTab(props: { : `${samples.length} Frames ins Training übernommen.` ) + if (deferredCurrentSample) { + setMessage( + errorCount > 0 + ? `${samples.length} Frames ins Training übernommen, ${errorCount} Frames fehlgeschlagen. Das aktuelle Bild wurde ans Ende der Queue gelegt.` + : `${samples.length} Frames ins Training übernommen. Das aktuelle Bild wurde ans Ende der Queue gelegt.` + ) + } + return true } catch (e) { setError(e instanceof Error ? e.message : String(e)) @@ -3638,7 +3958,7 @@ export default function TrainingTab(props: { }, 500) } }, [ - loadTrainingSampleIntoTab, + loadPriorityTrainingSamples, loadTrainingStatus, setLoadingPreviewCandidate, ]) @@ -4221,7 +4541,13 @@ export default function TrainingTab(props: { .map(normalizeBox) .filter((box) => box.label && box.w > 0 && box.h > 0) - const negative = options?.negative ?? isNegativeCorrection + const feedbackCorrection = { + ...correction, + boxes: normalizedBoxes, + } + const negative = + options?.negative ?? + !correctionHasTrainablePositionOrBoxes(feedbackCorrection) const correctionPayload: CorrectionState = negative ? { sexPosition: NO_SEX_POSITION_LABEL, @@ -4329,7 +4655,6 @@ export default function TrainingTab(props: { sample, correction, editingFeedback, - isNegativeCorrection, loadNext, loadTrainingStatus, loadNextImportedQueuedSample, @@ -4358,10 +4683,12 @@ export default function TrainingTab(props: { throw new Error(backendText(data, `HTTP ${res.status}`)) } + sampleRef.current = null + correctionRef.current = predictionToCorrection(null) + hasManualCorrectionRef.current = false setSample(null) setCorrection(predictionToCorrection(null)) setHasManualCorrection(false) - setNegativeExample(false) setDrawingBox(null) setBoxInteraction(null) setTouchMagnifier(null) @@ -4474,9 +4801,11 @@ export default function TrainingTab(props: { throw new Error(data?.error || `HTTP ${res.status}`) } + sampleRef.current = null + correctionRef.current = predictionToCorrection(null) + hasManualCorrectionRef.current = false setSample(null) setCorrection(predictionToCorrection(null)) - setNegativeExample(false) setTrainingStatus({ feedbackCount: 0, requiredCount, @@ -5353,11 +5682,19 @@ export default function TrainingTab(props: { setTrainingSampleMode(nextMode) - if (!uiLocked) { + if (!uiLocked && nextMode === 'uncertain') { void loadNext({ forceNew: true, mode: nextMode, + deferCurrentSampleToQueueEnd: Boolean(sampleRef.current), }) + } else if (!uiLocked && !sampleRef.current) { + if (!loadNextImportedQueuedSample()) { + void loadNext({ + forceNew: true, + mode: nextMode, + }) + } } }} className={[ @@ -6235,6 +6572,18 @@ export default function TrainingTab(props: { ].join(' ')} style={imageStageStyle} > + {hasPosePersons ? ( +
+ +
+ ) : null} + {imageSrc ? (
+ {showPoseSkeleton && imageLayerStyle && hasPosePersons ? (() => { + const layerWidth = Number(imageLayerStyle.width) + const layerHeight = Number(imageLayerStyle.height) + + if ( + !Number.isFinite(layerWidth) || + !Number.isFinite(layerHeight) || + layerWidth <= 0 || + layerHeight <= 0 + ) { + return null + } + + return ( + + ) + })() : null} + {showImageBoxes && imageLayerStyle ? (
- -