This commit is contained in:
Linrador 2026-06-22 13:57:33 +02:00
parent 67d62c1fbc
commit 1c8071a275
19 changed files with 3862 additions and 317 deletions

View File

@ -1,6 +1,7 @@
# backend\ai_server.py # backend\ai_server.py
import json import json
import math
import os import os
import secrets import secrets
from pathlib import Path from pathlib import Path
@ -182,6 +183,9 @@ _LABEL_ERROR = ""
_DEVICE = os.environ.get("YOLO_DEVICE", "") _DEVICE = os.environ.get("YOLO_DEVICE", "")
_CONF = float(os.environ.get("YOLO_CONF", "0.25")) _CONF = float(os.environ.get("YOLO_CONF", "0.25"))
_POSE_CONF = float(os.environ.get("YOLO_POSE_CONF", "0.30")) _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")) _BATCH = int(os.environ.get("YOLO_BATCH", "16"))
_IMGSZ = int(os.environ.get("YOLO_IMGSZ", "640")) _IMGSZ = int(os.environ.get("YOLO_IMGSZ", "640"))
_HALF = os.environ.get("YOLO_HALF", "0").lower() in {"1", "true", "yes", "on"} _HALF = os.environ.get("YOLO_HALF", "0").lower() in {"1", "true", "yes", "on"}
@ -530,7 +534,7 @@ def pose_persons_from_result(result) -> list[dict]:
"conf": kconf, "conf": kconf,
}) })
persons.append({ persons.append(annotate_pose_person_quality({
"label": label, "label": label,
"score": score, "score": score,
"box": { "box": {
@ -540,11 +544,679 @@ def pose_persons_from_result(result) -> list[dict]:
"h": h, "h": h,
}, },
"keypoints": keypoints, "keypoints": keypoints,
}) }))
return persons 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: def apply_pose_result_to_prediction(prediction: dict, result) -> dict:
persons = pose_persons_from_result(result) persons = pose_persons_from_result(result)
if not persons: if not persons:
@ -552,19 +1224,7 @@ def apply_pose_result_to_prediction(prediction: dict, result) -> dict:
prediction["persons"] = persons prediction["persons"] = persons
best_position = "" best_position, best_score_value = best_pose_scene_position(prediction, persons)
best_score_value = 0.0
for person in persons:
label = str(person.get("label") or "").strip().lower()
score = float(person.get("score") or 0.0)
if is_no_sex_position_label(label) or label not in POSITION_LABELS:
continue
if score > best_score_value:
best_position = label
best_score_value = score
if best_position: if best_position:
prediction["sexPosition"] = best_position prediction["sexPosition"] = best_position

View File

@ -0,0 +1,3 @@
{
"items": []
}

View File

@ -2,6 +2,7 @@
import argparse import argparse
import json import json
import math
from pathlib import Path from pathlib import Path
from PIL import Image from PIL import Image
@ -21,6 +22,73 @@ KEYPOINT_NAMES = [
] ]
BASE_MODEL_NAME = "yolo26n-pose.pt" 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): def existing_file(path):
@ -184,12 +252,12 @@ def main():
"conf": kconf, "conf": kconf,
}) })
persons.append({ persons.append(annotate_pose_person_quality({
"label": label, "label": label,
"score": score, "score": score,
"box": box, "box": box,
"keypoints": keypoints, "keypoints": keypoints,
}) }))
print(json.dumps({ print(json.dumps({
"available": True, "available": True,

View File

@ -240,10 +240,33 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) {
From string `json:"from,omitempty"` 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)) results := make([]itemResult, 0, len(req.Files))
deletedCount := 0 deletedCount := 0
seen := make(map[string]struct{}, len(req.Files)) 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 { for _, raw := range req.Files {
file := strings.TrimSpace(raw) file := strings.TrimSpace(raw)
if file == "" { if file == "" {
@ -255,7 +278,7 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) {
seen[file] = struct{}{} seen[file] = struct{}{}
if !isAllowedVideoExt(file) { if !isAllowedVideoExt(file) {
results = append(results, itemResult{ addResult(itemResult{
File: file, File: file,
OK: false, OK: false,
Error: "nicht erlaubt", Error: "nicht erlaubt",
@ -268,7 +291,7 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) {
target, from, fi, err := resolveDoneFileByName(doneAbs, file) target, from, fi, err := resolveDoneFileByName(doneAbs, file)
if err != nil { if err != nil {
results = append(results, itemResult{ addResult(itemResult{
File: file, File: file,
OK: false, OK: false,
Error: "datei nicht gefunden", Error: "datei nicht gefunden",
@ -276,7 +299,7 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) {
continue continue
} }
if fi != nil && fi.IsDir() { if fi != nil && fi.IsDir() {
results = append(results, itemResult{ addResult(itemResult{
File: file, File: file,
OK: false, OK: false,
Error: "ist ein verzeichnis", Error: "ist ein verzeichnis",
@ -287,7 +310,7 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) {
if err := removeWithRetry(target); err != nil { if err := removeWithRetry(target); err != nil {
if runtime.GOOS == "windows" && isSharingViolation(err) { if runtime.GOOS == "windows" && isSharingViolation(err) {
results = append(results, itemResult{ addResult(itemResult{
File: file, File: file,
OK: false, OK: false,
Error: "datei wird gerade verwendet", Error: "datei wird gerade verwendet",
@ -296,7 +319,7 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) {
continue continue
} }
results = append(results, itemResult{ addResult(itemResult{
File: file, File: file,
OK: false, OK: false,
Error: err.Error(), Error: err.Error(),
@ -323,7 +346,7 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) {
} }
deletedCount++ deletedCount++
results = append(results, itemResult{ addResult(itemResult{
File: file, File: file,
OK: true, OK: true,
From: from, From: from,
@ -334,6 +357,20 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) {
notifyDoneChanged() 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") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
"ok": deletedCount > 0, "ok": deletedCount > 0,

File diff suppressed because it is too large Load Diff

View File

@ -131,3 +131,292 @@ func TestTrainingEffectiveCorrectionClearsNegativeAnnotation(t *testing.T) {
t.Fatalf("negative annotation has %d boxes, want 0", len(effective.Boxes)) 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)
}
}

View File

@ -714,7 +714,28 @@ export default function App() {
const [splitModalKey, setSplitModalKey] = useState(0) const [splitModalKey, setSplitModalKey] = useState(0)
const [assetNonce, setAssetNonce] = useState(0) const [assetNonce, setAssetNonce] = useState(0)
const [assetNonceByFile, setAssetNonceByFile] = useState<Record<string, number>>({})
const bumpAssets = useCallback(() => setAssetNonce((n) => n + 1), []) 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<RecorderSettingsState>(DEFAULT_RECORDER_SETTINGS) const [recSettings, setRecSettings] = useState<RecorderSettingsState>(DEFAULT_RECORDER_SETTINGS)
@ -1091,6 +1112,51 @@ export default function App() {
loadDonePageRef.current = loadDonePage loadDonePageRef.current = loadDonePage
}, [loadDonePage]) }, [loadDonePage])
const doneRefreshTimerRef = useRef<number | null>(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(() => { useEffect(() => {
try { try {
window.localStorage.setItem(INCLUDE_KEEP_KEY, includeKeep ? '1' : '0') window.localStorage.setItem(INCLUDE_KEEP_KEY, includeKeep ? '1' : '0')
@ -2821,8 +2887,7 @@ export default function App() {
void loadTaskStateOnce() void loadTaskStateOnce()
if (selectedTabRef.current === 'finished') { if (selectedTabRef.current === 'finished') {
donePrefetchRef.current = null scheduleDoneRefresh(500)
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
} }
}, document.hidden ? 60000 : 5000) }, document.hidden ? 60000 : 5000)
} }
@ -2848,12 +2913,12 @@ export default function App() {
} }
const onDoneChanged = () => { const onDoneChanged = () => {
void loadDoneCount()
if (selectedTabRef.current === 'finished') { if (selectedTabRef.current === 'finished') {
donePrefetchRef.current = null scheduleDoneRefresh(900)
void loadDonePageRef.current(donePageRef.current, doneSortRef.current) return
} }
void loadDoneCount()
} }
const onAutostart = (ev: MessageEvent) => { const onAutostart = (ev: MessageEvent) => {
@ -2898,7 +2963,7 @@ export default function App() {
// ✅ Wenn Assets fertig / fehlerhaft / entfernt sind, Preview-/Sprite-Cache neu ziehen // ✅ Wenn Assets fertig / fehlerhaft / entfernt sind, Preview-/Sprite-Cache neu ziehen
if (state === 'done' || state === 'error' || state === 'missing') { if (state === 'done' || state === 'error' || state === 'missing') {
bumpAssets() bumpAssetForFile(file)
} }
} catch {} } catch {}
} }
@ -2975,7 +3040,7 @@ export default function App() {
) )
if (state === 'done' || state === 'error') { if (state === 'done' || state === 'error') {
bumpAssets() bumpAssetForFile(file)
} }
} catch { } catch {
// ignore // ignore
@ -3132,6 +3197,8 @@ export default function App() {
loadPendingAutoStarts, loadPendingAutoStarts,
applyAutostartState, applyAutostartState,
loadAutostartState, loadAutostartState,
bumpAssetForFile,
scheduleDoneRefresh,
]) ])
useEffect(() => { useEffect(() => {
@ -3430,7 +3497,7 @@ export default function App() {
// Einheitliche Start-Event-Phase (delete/keep animieren dieselbe Row) // Einheitliche Start-Event-Phase (delete/keep animieren dieselbe Row)
if (kind === 'delete' || kind === 'keep') { if (kind === 'delete' || kind === 'keep') {
window.dispatchEvent( 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') { if (kind === 'delete' || kind === 'keep') {
window.dispatchEvent( 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) { } catch (e) {
if (kind === 'delete' || kind === 'keep') { if (kind === 'delete' || kind === 'keep') {
window.dispatchEvent( 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) 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 throw e
} }
} }
@ -3484,8 +3561,7 @@ export default function App() {
donePrefetchRef.current = null donePrefetchRef.current = null
void loadDonePageRef.current(donePageRef.current, doneSortRef.current) scheduleDoneRefresh(900)
void loadDoneCount()
}, 320) }, 320)
}, },
onError: async () => { onError: async () => {
@ -3497,14 +3573,14 @@ export default function App() {
const from = typeof data?.from === 'string' ? data.from : undefined const from = typeof data?.from === 'string' ? data.from : undefined
return undoToken ? { undoToken, from } : {} return undoToken ? { undoToken, from } : {}
} catch { } catch (e) {
return throw e
} }
}, },
[ [
runFinishedFileAction, runFinishedFileAction,
notify, notify,
loadDoneCount, scheduleDoneRefresh,
] ]
) )
@ -3529,8 +3605,7 @@ export default function App() {
donePrefetchRef.current = null donePrefetchRef.current = null
void loadDonePageRef.current(donePageRef.current, doneSortRef.current) scheduleDoneRefresh(900)
void loadDoneCount()
}, 320) }, 320)
}, },
onError: async () => { onError: async () => {
@ -3539,11 +3614,11 @@ export default function App() {
}) })
return data return data
} catch { } catch (e) {
return throw e
} }
}, },
[runFinishedFileAction, notify, loadDoneCount] [runFinishedFileAction, notify, scheduleDoneRefresh]
) )
const handleToggleHot = useCallback( const handleToggleHot = useCallback(
@ -4328,6 +4403,7 @@ export default function App() {
teaserAudio={Boolean(recSettings.teaserAudio)} teaserAudio={Boolean(recSettings.teaserAudio)}
pauseTeasers={Boolean(detailsModelKey) || splitModalOpen || Boolean(playerJob)} pauseTeasers={Boolean(detailsModelKey) || splitModalOpen || Boolean(playerJob)}
assetNonce={assetNonce} assetNonce={assetNonce}
assetNonceByFile={assetNonceByFile}
sortMode={doneSort} sortMode={doneSort}
onSortModeChange={(m) => { onSortModeChange={(m) => {
setDoneSort(m) setDoneSort(m)

View File

@ -1,14 +1,21 @@
'use client' 'use client'
import { CheckIcon } from '@heroicons/react/24/solid'
import LoadingSpinner from './LoadingSpinner' import LoadingSpinner from './LoadingSpinner'
export default function DeletingPreviewOverlay({ export default function DeletingPreviewOverlay({
label = 'Wird gelöscht', done = false,
label,
className = 'rounded-t-lg', className = 'rounded-t-lg',
compact = false,
}: { }: {
done?: boolean
label?: string label?: string
className?: string className?: string
compact?: boolean
}) { }) {
const displayLabel = label ?? (done ? 'Gelöscht' : 'Wird gelöscht')
return ( return (
<div <div
className={[ className={[
@ -16,17 +23,33 @@ export default function DeletingPreviewOverlay({
className, className,
].join(' ')} ].join(' ')}
aria-live="polite" aria-live="polite"
aria-label={label} aria-label={displayLabel}
> >
<div className="flex flex-col items-center gap-2 rounded-xl bg-black/35 px-4 py-3 shadow-lg ring-1 ring-white/15"> <div
<LoadingSpinner className={[
size="md" 'flex flex-col items-center rounded-xl bg-black/35 shadow-lg ring-1 ring-white/15',
className="text-white" compact ? 'gap-1 px-2 py-1.5' : 'gap-2 px-4 py-3',
srLabel={label} ].join(' ')}
/> >
{done ? (
<span
className={[
'grid place-items-center rounded-full bg-emerald-500 text-white shadow-sm ring-1 ring-emerald-300/70',
compact ? 'h-5 w-5' : 'h-8 w-8',
].join(' ')}
>
<CheckIcon className={compact ? 'size-3.5' : 'size-5'} aria-hidden="true" />
</span>
) : (
<LoadingSpinner
size={compact ? 'sm' : 'md'}
className="text-white"
srLabel={displayLabel}
/>
)}
<div className="text-xs font-semibold tracking-wide text-white/95"> <div className={['font-semibold tracking-wide text-white/95', compact ? 'text-[10px]' : 'text-xs'].join(' ')}>
{label} {displayLabel}
</div> </div>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@ -53,6 +53,7 @@ import PreviewRatingOverlay from './PreviewRatingOverlay'
import PreviewMetaOverlay from './PreviewMetaOverlay' import PreviewMetaOverlay from './PreviewMetaOverlay'
import DeletingPreviewOverlay from './DeletingPreviewOverlay' import DeletingPreviewOverlay from './DeletingPreviewOverlay'
import { autoTagsForJob, mergeTags } from './autoTags' import { autoTagsForJob, mergeTags } from './autoTags'
import { useFinishedDownloadsAutoGridPageSize } from './useFinishedDownloadsAutoPageSize'
type SwipeRefs = { type SwipeRefs = {
@ -75,12 +76,14 @@ type Props = {
teaserState: FinishedDownloadsTeaserState teaserState: FinishedDownloadsTeaserState
deletingKeys: Set<string> deletingKeys: Set<string>
deletedSuccessKeys: Set<string>
keepingKeys: Set<string> keepingKeys: Set<string>
removingKeys: Set<string> removingKeys: Set<string>
swipeRefs: SwipeRefs swipeRefs: SwipeRefs
assetNonce?: number assetNonce?: number
assetNonceForJob?: (job: RecordJob) => number
jobForDetails: (job: RecordJob) => RecordJob jobForDetails: (job: RecordJob) => RecordJob
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
@ -108,7 +111,10 @@ type Props = {
handleResolution: (job: RecordJob, w: number, h: number) => void handleResolution: (job: RecordJob, w: number, h: number) => void
resolutionLabelOf: (job: RecordJob) => string resolutionLabelOf: (job: RecordJob) => string
deleteVideo: (job: RecordJob) => Promise<boolean> deleteVideo: (
job: RecordJob,
opts?: { alreadyRemoved?: boolean; optimisticRemove?: boolean }
) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean> keepVideo: (job: RecordJob) => Promise<boolean>
releasePlayingFile: (file: string, opts?: { close?: boolean }) => Promise<void> releasePlayingFile: (file: string, opts?: { close?: boolean }) => Promise<void>
@ -136,6 +142,8 @@ type Props = {
onUnlockMobileTeaserAudio?: () => void onUnlockMobileTeaserAudio?: () => void
renderIntegrityBadge?: (job: RecordJob) => ReactNode renderIntegrityBadge?: (job: RecordJob) => ReactNode
renderRatingOverlay?: (job: RecordJob) => ReactNode renderRatingOverlay?: (job: RecordJob) => ReactNode
onRecommendedPageSizeChange?: (size: number) => void
suspendAutoPageSize?: boolean
} }
function CardBlurWrapper({ function CardBlurWrapper({
@ -330,11 +338,13 @@ function FinishedDownloadsCardsView({
teaserState, teaserState,
inlinePlay, inlinePlay,
deletingKeys, deletingKeys,
deletedSuccessKeys,
keepingKeys, keepingKeys,
removingKeys, removingKeys,
swipeRefs, swipeRefs,
assetNonce, assetNonce,
assetNonceForJob,
jobForDetails, jobForDetails,
handleScrubberClickIndex, handleScrubberClickIndex,
@ -378,8 +388,29 @@ function FinishedDownloadsCardsView({
onUnlockMobileTeaserAudio, onUnlockMobileTeaserAudio,
renderIntegrityBadge, renderIntegrityBadge,
renderRatingOverlay, renderRatingOverlay,
onRecommendedPageSizeChange,
suspendAutoPageSize,
}: Props) { }: Props) {
const rootRef = useRef<HTMLDivElement | null>(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<Record<string, boolean>>({}) const footerTapRef = useRef<Record<string, boolean>>({})
const teaserPlayFnsRef = useRef<Record<string, (() => void) | null>>({}) const teaserPlayFnsRef = useRef<Record<string, (() => void) | null>>({})
@ -451,6 +482,7 @@ function FinishedDownloadsCardsView({
} }
) => { ) => {
const k = keyFor(j) const k = keyFor(j)
const rowAssetNonce = assetNonceForJob?.(j) ?? assetNonce ?? 0
const handleSelectOrOpen = () => { const handleSelectOrOpen = () => {
if (selectionModeActive) { if (selectionModeActive) {
@ -497,7 +529,8 @@ function FinishedDownloadsCardsView({
disabled: false, 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 model = modelNameFromOutput(j.output)
const fileRaw = baseName(j.output || '') const fileRaw = baseName(j.output || '')
@ -516,7 +549,7 @@ function FinishedDownloadsCardsView({
const sprite = readPreviewSpriteInfo((j as any)?.meta, { const sprite = readPreviewSpriteInfo((j as any)?.meta, {
fallbackPath: flags?.previewScrubberPath, fallbackPath: flags?.previewScrubberPath,
fallbackCount: flags?.previewScrubberCount, fallbackCount: flags?.previewScrubberCount,
versionFallback: assetNonce ?? 0, versionFallback: rowAssetNonce,
}) })
const spriteUrl = sprite.url 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', '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', busy && 'pointer-events-none opacity-70',
deletingKeys.has(k) && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30', deletingKeys.has(k) && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
deleteDone && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
keepingKeys.has(k) && '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]', removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
] ]
@ -737,7 +771,7 @@ function FinishedDownloadsCardsView({
inlineLoop={false} inlineLoop={false}
muted={previewMuted} muted={previewMuted}
popoverMuted={previewMuted} popoverMuted={previewMuted}
assetNonce={assetNonce ?? 0} assetNonce={rowAssetNonce}
alwaysLoadStill={forceLoadStill} alwaysLoadStill={forceLoadStill}
teaserPreloadEnabled={teaserPreloadEnabled} teaserPreloadEnabled={teaserPreloadEnabled}
teaserPreloadRootMargin={ teaserPreloadRootMargin={
@ -854,8 +888,8 @@ function FinishedDownloadsCardsView({
</div> </div>
) : null} ) : null}
{deletingKeys.has(k) ? ( {deletingKeys.has(k) || deleteDone ? (
<DeletingPreviewOverlay /> <DeletingPreviewOverlay done={deleteDone} />
) : null} ) : null}
</div> </div>
</div> </div>
@ -1167,9 +1201,14 @@ function FinishedDownloadsCardsView({
return ( return (
<div className="relative"> <div ref={rootRef} className="relative">
{!isSmall ? ( {!isSmall ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div
className="grid gap-3"
style={{
gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${desktopGridMinCardWidth}px), 1fr))`,
}}
>
{rows.map((j) => { {rows.map((j) => {
const k = keyFor(j) const k = keyFor(j)
@ -1180,6 +1219,7 @@ function FinishedDownloadsCardsView({
selectionStore={selectionStore} selectionStore={selectionStore}
selectionModeActive={selectionModeActive} selectionModeActive={selectionModeActive}
isDeleting={deletingKeys.has(k)} isDeleting={deletingKeys.has(k)}
isDeleted={deletedSuccessKeys.has(k)}
isKeeping={keepingKeys.has(k)} isKeeping={keepingKeys.has(k)}
isRemoving={removingKeys.has(k)} isRemoving={removingKeys.has(k)}
postworkBadge={getPostworkSummaryForOutput(j.output, postworkByFile)} postworkBadge={getPostworkSummaryForOutput(j.output, postworkByFile)}
@ -1187,7 +1227,7 @@ function FinishedDownloadsCardsView({
durationSeconds={durations[k]} durationSeconds={durations[k]}
teaserState={teaserState} teaserState={teaserState}
inlinePlay={inlinePlay} inlinePlay={inlinePlay}
assetNonce={assetNonce} assetNonce={assetNonceForJob?.(j) ?? assetNonce}
forcePreviewMuted={forcePreviewMuted} forcePreviewMuted={forcePreviewMuted}
keyFor={keyFor} keyFor={keyFor}
baseName={baseName} baseName={baseName}
@ -1390,10 +1430,11 @@ function FinishedDownloadsCardsView({
} }
}) })
}} }}
onSwipeLeft={async () => { onSwipeLeft={() => {
lockMobileStackHeight() lockMobileStackHeight()
setMobileTopTeaserEnabled(false) setMobileTopTeaserEnabled(false)
return await deleteVideo(j) void deleteVideo(j, { optimisticRemove: true })
return true
}} }}
onSwipeRight={async () => { onSwipeRight={async () => {
lockMobileStackHeight() lockMobileStackHeight()

View File

@ -41,6 +41,7 @@ import {
} from './videoHeatmap' } from './videoHeatmap'
import PreviewRatingOverlay from './PreviewRatingOverlay' import PreviewRatingOverlay from './PreviewRatingOverlay'
import PreviewMetaOverlay from './PreviewMetaOverlay' import PreviewMetaOverlay from './PreviewMetaOverlay'
import DeletingPreviewOverlay from './DeletingPreviewOverlay'
import { autoTagsForJob, mergeTags } from './autoTags' import { autoTagsForJob, mergeTags } from './autoTags'
export type FinishedDownloadsDesktopCardProps = { export type FinishedDownloadsDesktopCardProps = {
@ -48,6 +49,7 @@ export type FinishedDownloadsDesktopCardProps = {
selectionStore: FinishedSelectionStore selectionStore: FinishedSelectionStore
selectionModeActive: boolean selectionModeActive: boolean
isDeleting: boolean isDeleting: boolean
isDeleted: boolean
isKeeping: boolean isKeeping: boolean
isRemoving: boolean isRemoving: boolean
postworkBadge?: FinishedPostworkSummary postworkBadge?: FinishedPostworkSummary
@ -140,6 +142,7 @@ const FinishedDownloadsDesktopCard = memo(
selectionStore, selectionStore,
selectionModeActive, selectionModeActive,
isDeleting, isDeleting,
isDeleted,
isKeeping, isKeeping,
isRemoving, isRemoving,
postworkBadge, postworkBadge,
@ -183,7 +186,7 @@ const FinishedDownloadsDesktopCard = memo(
renderRatingOverlay, renderRatingOverlay,
}: FinishedDownloadsDesktopCardProps) { }: FinishedDownloadsDesktopCardProps) {
const k = keyFor(j) const k = keyFor(j)
const busy = isDeleting || isKeeping || isRemoving const busy = isDeleting || isDeleted || isKeeping || isRemoving
const checked = useSelectionChecked(selectionStore, k) const checked = useSelectionChecked(selectionStore, k)
const longPressTimerRef = useRef<number | null>(null) const longPressTimerRef = useRef<number | null>(null)
const longPressTriggeredRef = useRef(false) const longPressTriggeredRef = useRef(false)
@ -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', 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', busy && 'pointer-events-none opacity-70',
isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30', isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
isDeleted && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
isKeeping && '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]', isRemoving && 'opacity-0 translate-y-2 scale-[0.98]',
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
@ -487,6 +491,10 @@ const FinishedDownloadsDesktopCard = memo(
/> />
</div> </div>
) : null} ) : null}
{isDeleting || isDeleted ? (
<DeletingPreviewOverlay done={isDeleted} />
) : null}
</div> </div>
</div> </div>
@ -606,6 +614,7 @@ const FinishedDownloadsDesktopCard = memo(
return ( return (
prev.job === next.job && prev.job === next.job &&
prev.isDeleting === next.isDeleting && prev.isDeleting === next.isDeleting &&
prev.isDeleted === next.isDeleted &&
prev.isKeeping === next.isKeeping && prev.isKeeping === next.isKeeping &&
prev.isRemoving === next.isRemoving && prev.isRemoving === next.isRemoving &&
samePostworkSummaryLite(prev.postworkBadge, next.postworkBadge) && samePostworkSummaryLite(prev.postworkBadge, next.postworkBadge) &&

View File

@ -74,6 +74,7 @@ type Props = {
activeTagSet: Set<string> activeTagSet: Set<string>
deletingKeys: Set<string> deletingKeys: Set<string>
deletedSuccessKeys: Set<string>
keepingKeys: Set<string> keepingKeys: Set<string>
removingKeys: Set<string> removingKeys: Set<string>
@ -132,6 +133,7 @@ function FinishedDownloadsGalleryCardInner({
modelsByKey, modelsByKey,
activeTagSet, activeTagSet,
deletingKeys, deletingKeys,
deletedSuccessKeys,
keepingKeys, keepingKeys,
removingKeys, removingKeys,
registerTeaserHost, registerTeaserHost,
@ -304,8 +306,9 @@ function FinishedDownloadsGalleryCardInner({
const isRemoving = removingKeys.has(k) const isRemoving = removingKeys.has(k)
const isDeleting = deletingKeys.has(k) const isDeleting = deletingKeys.has(k)
const isDeleted = deletedSuccessKeys.has(k)
const isKeeping = keepingKeys.has(k) const isKeeping = keepingKeys.has(k)
const busy = isDeleting || isKeeping || isRemoving const busy = isDeleting || isDeleted || isKeeping || isRemoving
const sprite = useMemo( 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', 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', busy && !isRemoving && 'pointer-events-none opacity-70',
isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30', isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
isDeleted && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
isKeeping && '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', isRemoving ? 'pointer-events-none opacity-0 translate-y-2 scale-[0.98]' : 'opacity-100 translate-y-0 scale-100',
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
@ -559,8 +563,8 @@ function FinishedDownloadsGalleryCardInner({
</div> </div>
) : null} ) : null}
{isDeleting ? ( {isDeleting || isDeleted ? (
<DeletingPreviewOverlay /> <DeletingPreviewOverlay done={isDeleted} />
) : null} ) : null}
</div> </div>
</div> </div>

View File

@ -46,6 +46,7 @@ type Props = {
formatBytes: (bytes?: number | null) => string formatBytes: (bytes?: number | null) => string
deletingKeys: Set<string> deletingKeys: Set<string>
deletedSuccessKeys: Set<string>
keepingKeys: Set<string> keepingKeys: Set<string>
removingKeys: Set<string> removingKeys: Set<string>
@ -68,11 +69,13 @@ type Props = {
onSplit?: (job: RecordJob) => void | Promise<void> onSplit?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean> onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
onRecommendedPageSizeChange?: (size: number) => void onRecommendedPageSizeChange?: (size: number) => void
suspendAutoPageSize?: boolean
forcePreviewMuted?: boolean forcePreviewMuted?: boolean
mobileTeaserAudioUnlocked?: boolean mobileTeaserAudioUnlocked?: boolean
onUnlockMobileTeaserAudio?: () => void onUnlockMobileTeaserAudio?: () => void
assetNonce?: number assetNonce?: number
assetNonceForJob?: (job: RecordJob) => number
renderIntegrityBadge?: (job: RecordJob) => ReactNode renderIntegrityBadge?: (job: RecordJob) => ReactNode
renderRatingOverlay?: (job: RecordJob) => ReactNode renderRatingOverlay?: (job: RecordJob) => ReactNode
} }
@ -114,6 +117,7 @@ function FinishedDownloadsGalleryView({
sizeBytesOf, sizeBytesOf,
formatBytes, formatBytes,
deletingKeys, deletingKeys,
deletedSuccessKeys,
keepingKeys, keepingKeys,
removingKeys, removingKeys,
registerTeaserHost, registerTeaserHost,
@ -136,8 +140,10 @@ function FinishedDownloadsGalleryView({
mobileTeaserAudioUnlocked, mobileTeaserAudioUnlocked,
onUnlockMobileTeaserAudio, onUnlockMobileTeaserAudio,
assetNonce, assetNonce,
assetNonceForJob,
renderIntegrityBadge, renderIntegrityBadge,
renderRatingOverlay, renderRatingOverlay,
suspendAutoPageSize,
}: Props) { }: Props) {
const observeTeasers = shouldObserveTeasers(teaserState.mode) const observeTeasers = shouldObserveTeasers(teaserState.mode)
@ -181,7 +187,7 @@ function FinishedDownloadsGalleryView({
const rafRef = useRef<number | null>(null) const rafRef = useRef<number | null>(null)
const recommendPageSize = useCallback(() => { const recommendPageSize = useCallback(() => {
if (!onRecommendedPageSizeChange) return if (suspendAutoPageSize || !onRecommendedPageSizeChange) return
const el = rootRef.current const el = rootRef.current
if (!el) return if (!el) return
@ -197,8 +203,40 @@ function FinishedDownloadsGalleryView({
Math.floor((containerWidth + gapPx) / (minCardWidth + gapPx)) Math.floor((containerWidth + gapPx) / (minCardWidth + gapPx))
) )
const rowsPerPage = 2 const grid = el.firstElementChild
const nextPageSize = clamp(columns * rowsPerPage, rowsPerPage, 24) 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 const currentPageSize = lastRecommendedPageSizeRef.current
if (currentPageSize === nextPageSize) return if (currentPageSize === nextPageSize) return
@ -233,7 +271,7 @@ function FinishedDownloadsGalleryView({
Math.floor((stableWidth + gapPx) / (minCardWidth + gapPx)) 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)) { if (stablePageSize >= (lastRecommendedPageSizeRef.current ?? 0)) {
return return
@ -252,9 +290,11 @@ function FinishedDownloadsGalleryView({
rafRef.current = null rafRef.current = null
measureAndApply() measureAndApply()
}) })
}, [minCardWidth, onRecommendedPageSizeChange]) }, [minCardWidth, onRecommendedPageSizeChange, suspendAutoPageSize])
useEffect(() => { useEffect(() => {
if (suspendAutoPageSize) return
recommendPageSize() recommendPageSize()
const el = rootRef.current const el = rootRef.current
@ -281,7 +321,7 @@ function FinishedDownloadsGalleryView({
rafRef.current = null rafRef.current = null
} }
} }
}, [recommendPageSize]) }, [recommendPageSize, suspendAutoPageSize])
const contentPaddingClass = const contentPaddingClass =
sizePreset === 'xl' sizePreset === 'xl'
@ -356,6 +396,7 @@ function FinishedDownloadsGalleryView({
modelsByKey={modelsByKey} modelsByKey={modelsByKey}
activeTagSet={activeTagSet} activeTagSet={activeTagSet}
deletingKeys={deletingKeys} deletingKeys={deletingKeys}
deletedSuccessKeys={deletedSuccessKeys}
keepingKeys={keepingKeys} keepingKeys={keepingKeys}
removingKeys={removingKeys} removingKeys={removingKeys}
registerTeaserHost={registerTeaserHostIfNeeded} registerTeaserHost={registerTeaserHostIfNeeded}
@ -381,7 +422,7 @@ function FinishedDownloadsGalleryView({
footerMinHeightClass={footerMinHeightClass} footerMinHeightClass={footerMinHeightClass}
titleClass={titleClass} titleClass={titleClass}
sublineClass={sublineClass} sublineClass={sublineClass}
assetNonce={assetNonce ?? 0} assetNonce={assetNonceForJob?.(j) ?? assetNonce ?? 0}
renderIntegrityBadge={renderIntegrityBadge} renderIntegrityBadge={renderIntegrityBadge}
renderRatingOverlay={renderRatingOverlay} renderRatingOverlay={renderRatingOverlay}
mobileTeaserAudioUnlocked={mobileTeaserAudioUnlocked} mobileTeaserAudioUnlocked={mobileTeaserAudioUnlocked}

View File

@ -2,7 +2,7 @@
'use client' '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 Table, { type Column, type SortState } from './Table'
import type { import type {
RecordJob, RecordJob,
@ -26,6 +26,8 @@ import ModelGenderIcon from './ModelGenderIcon'
import { resolveFinishedModelFlags } from './finishedModelFlags' import { resolveFinishedModelFlags } from './finishedModelFlags'
import CountryFlag, { resolveFinishedCountry } from './CountryFlag' import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
import PreviewRatingOverlay from './PreviewRatingOverlay' import PreviewRatingOverlay from './PreviewRatingOverlay'
import { useFinishedDownloadsAutoTablePageSize } from './useFinishedDownloadsAutoPageSize'
import DeletingPreviewOverlay from './DeletingPreviewOverlay'
type Props = { type Props = {
rows: RecordJob[] rows: RecordJob[]
@ -62,9 +64,11 @@ type Props = {
handleResolution: (job: RecordJob, w: number, h: number) => void handleResolution: (job: RecordJob, w: number, h: number) => void
blurPreviews?: boolean blurPreviews?: boolean
assetNonce?: number assetNonce?: number
assetNonceForJob?: (job: RecordJob) => number
// state/flags for actions row + rowClassName // state/flags for actions row + rowClassName
deletingKeys: Set<string> deletingKeys: Set<string>
deletedSuccessKeys: Set<string>
keepingKeys: Set<string> keepingKeys: Set<string>
removingKeys: Set<string> removingKeys: Set<string>
@ -91,6 +95,8 @@ type Props = {
forcePreviewMuted?: boolean forcePreviewMuted?: boolean
renderIntegrityBadge?: (job: RecordJob) => ReactNode renderIntegrityBadge?: (job: RecordJob) => ReactNode
renderRatingOverlay?: (job: RecordJob) => ReactNode renderRatingOverlay?: (job: RecordJob) => ReactNode
onRecommendedPageSizeChange?: (size: number) => void
suspendAutoPageSize?: boolean
} }
export default function FinishedDownloadsTableView({ export default function FinishedDownloadsTableView({
@ -122,8 +128,10 @@ export default function FinishedDownloadsTableView({
handleResolution, handleResolution,
blurPreviews, blurPreviews,
assetNonce, assetNonce,
assetNonceForJob,
deletingKeys, deletingKeys,
deletedSuccessKeys,
keepingKeys, keepingKeys,
removingKeys, removingKeys,
@ -148,8 +156,19 @@ export default function FinishedDownloadsTableView({
forcePreviewMuted, forcePreviewMuted,
renderIntegrityBadge, renderIntegrityBadge,
renderRatingOverlay, renderRatingOverlay,
onRecommendedPageSizeChange,
suspendAutoPageSize,
}: Props) { }: Props) {
const [sort, setSort] = useState<SortState>(null) const [sort, setSort] = useState<SortState>(null)
const rootRef = useRef<HTMLDivElement | null>(null)
useFinishedDownloadsAutoTablePageSize({
rootRef,
enabled: !suspendAutoPageSize,
fallbackRowHeight: 96,
fallbackHeaderHeight: 48,
onRecommendedPageSizeChange,
})
const runtimeSecondsForSort = useCallback((job: RecordJob) => { const runtimeSecondsForSort = useCallback((job: RecordJob) => {
// 1) Prefer real video duration (ffprobe / backend) // 1) Prefer real video duration (ffprobe / backend)
@ -292,6 +311,8 @@ export default function FinishedDownloadsTableView({
widthClassName: 'w-[140px]', widthClassName: 'w-[140px]',
cell: (j) => { cell: (j) => {
const k = keyFor(j) const k = keyFor(j)
const isDeleting = deletingKeys.has(k)
const isDeleted = deletedSuccessKeys.has(k)
const isPreviewActive = const isPreviewActive =
teaserState.activeKey === k || teaserState.hoverKey === k teaserState.activeKey === k || teaserState.hoverKey === k
const allowSound = shouldEnableTeaserAudio({ const allowSound = shouldEnableTeaserAudio({
@ -304,6 +325,7 @@ export default function FinishedDownloadsTableView({
const fileRaw = baseName(j.output || '') const fileRaw = baseName(j.output || '')
const previewId = stripHotPrefix(fileRaw.replace(/\.[^.]+$/, '')).trim() const previewId = stripHotPrefix(fileRaw.replace(/\.[^.]+$/, '')).trim()
const rowAssetNonce = assetNonceForJob?.(j) ?? assetNonce ?? 0
const scrubTimeSec = const scrubTimeSec =
spriteInfo && typeof scrubActiveIndex === 'number' spriteInfo && typeof scrubActiveIndex === 'number'
@ -312,7 +334,7 @@ export default function FinishedDownloadsTableView({
const scrubFrameSrc = const scrubFrameSrc =
previewId && typeof scrubTimeSec === 'number' 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 ( return (
@ -347,7 +369,7 @@ export default function FinishedDownloadsTableView({
})} })}
animatedMode="teaser" animatedMode="teaser"
animatedTrigger="always" animatedTrigger="always"
assetNonce={assetNonce} assetNonce={rowAssetNonce}
forceActive={isPreviewActive} forceActive={isPreviewActive}
teaserPreloadEnabled={false} teaserPreloadEnabled={false}
/> />
@ -365,6 +387,10 @@ export default function FinishedDownloadsTableView({
draggable={false} draggable={false}
/> />
) : null} ) : null}
{isDeleting || isDeleted ? (
<DeletingPreviewOverlay done={isDeleted} compact className="rounded-md" />
) : null}
</div> </div>
</div> </div>
) )
@ -531,7 +557,7 @@ export default function FinishedDownloadsTableView({
srOnlyHeader: true, srOnlyHeader: true,
cell: (j) => { cell: (j) => {
const k = keyFor(j) const k = keyFor(j)
const busy = deletingKeys.has(k) || 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 fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw) const isHot = isHotName(fileRaw)
@ -585,6 +611,7 @@ export default function FinishedDownloadsTableView({
blurPreviews, blurPreviews,
teaserState, teaserState,
assetNonce, assetNonce,
assetNonceForJob,
lower, lower,
modelNameFromOutput, modelNameFromOutput,
modelsByKey, modelsByKey,
@ -593,6 +620,7 @@ export default function FinishedDownloadsTableView({
handlePrimaryAction, handlePrimaryAction,
resolutions, resolutions,
deletingKeys, deletingKeys,
deletedSuccessKeys,
keepingKeys, keepingKeys,
removingKeys, removingKeys,
previewSpriteInfoOf, previewSpriteInfoOf,
@ -651,6 +679,7 @@ export default function FinishedDownloadsTableView({
'transition-all duration-300', '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) || removingKeys.has(k)) && 'bg-red-50/60 dark:bg-red-500/10 pointer-events-none',
deletingKeys.has(k) && 'animate-pulse', deletingKeys.has(k) && 'animate-pulse',
deletedSuccessKeys.has(k) && 'bg-emerald-50/60 dark:bg-emerald-500/10 pointer-events-none',
(keepingKeys.has(k) || removingKeys.has(k)) && 'pointer-events-none', (keepingKeys.has(k) || removingKeys.has(k)) && 'pointer-events-none',
keepingKeys.has(k) && 'bg-emerald-50/60 dark:bg-emerald-500/10 animate-pulse', keepingKeys.has(k) && 'bg-emerald-50/60 dark:bg-emerald-500/10 animate-pulse',
removingKeys.has(k) && 'opacity-0', removingKeys.has(k) && 'opacity-0',
@ -658,11 +687,11 @@ export default function FinishedDownloadsTableView({
.filter(Boolean) .filter(Boolean)
.join(' ') .join(' ')
}, },
[keyFor, deletingKeys, keepingKeys, removingKeys] [keyFor, deletingKeys, deletedSuccessKeys, keepingKeys, removingKeys]
) )
return ( return (
<div className="relative overflow-x-auto rounded-2xl border border-gray-200/80 bg-white/80 p-2 shadow-sm dark:border-white/10 dark:bg-transparent dark:p-0"> <div ref={rootRef} className="relative overflow-x-auto rounded-2xl border border-gray-200/80 bg-white/80 p-2 shadow-sm dark:border-white/10 dark:bg-transparent dark:p-0">
<Table <Table
rows={rows} rows={rows}
columns={columns} columns={columns}

View File

@ -520,7 +520,8 @@ export default function FinishedVideoPreview({
? inView ? inView
: forceActive || effectiveNearView || effectiveInView : forceActive || effectiveNearView || effectiveInView
const teaserRetryDelayMs = mobilePreviewConstrained ? 450 : 120 const teaserRetryDelayMs = mobilePreviewConstrained ? 450 : 120
const teaserWatchdogMs = mobilePreviewConstrained ? 700 : 180 const teaserWatchdogMs = mobilePreviewConstrained ? 900 : 420
const teaserStartResetWatchdogMs = mobilePreviewConstrained ? 1800 : 900
const inlineMode: 'never' | 'always' | 'hover' = const inlineMode: 'never' | 'always' | 'hover' =
inlineVideo === true || inlineVideo === 'always' ? 'always' : inlineVideo === 'hover' ? 'hover' : 'never' inlineVideo === true || inlineVideo === 'always' ? 'always' : inlineVideo === 'hover' ? 'hover' : 'never'
@ -548,6 +549,7 @@ export default function FinishedVideoPreview({
const clipsRef = useRef<HTMLVideoElement | null>(null) const clipsRef = useRef<HTMLVideoElement | null>(null)
const teaserSoundForcedRef = useRef(false) const teaserSoundForcedRef = useRef(false)
const teaserMutedRef = useRef(muted) const teaserMutedRef = useRef(muted)
const teaserSpeedHoldRef = useRef(false)
const setTeaserPlaybackRate = useCallback((rate: number) => { const setTeaserPlaybackRate = useCallback((rate: number) => {
const el = teaserMp4Ref.current const el = teaserMp4Ref.current
@ -945,7 +947,10 @@ export default function FinishedVideoPreview({
(forceActive || effectiveNearView || effectiveInView || (wantsHover && hovered)) (forceActive || effectiveNearView || effectiveInView || (wantsHover && hovered))
useEffect(() => { useEffect(() => {
teaserSpeedHoldRef.current = teaserSpeedHold
if (animatedMode !== 'teaser' || showingInlineVideo || !teaserActive) { if (animatedMode !== 'teaser' || showingInlineVideo || !teaserActive) {
teaserSpeedHoldRef.current = false
setTeaserPlaybackRate(1) setTeaserPlaybackRate(1)
return return
} }
@ -1237,8 +1242,9 @@ export default function FinishedVideoPreview({
el.playsInline = true el.playsInline = true
el.loop = true el.loop = true
el.autoplay = true el.autoplay = true
el.playbackRate = teaserSpeedHold ? 2 : 1 const playbackRate = teaserSpeedHoldRef.current ? 2 : 1
el.defaultPlaybackRate = teaserSpeedHold ? 2 : 1 el.playbackRate = playbackRate
el.defaultPlaybackRate = playbackRate
} catch {} } catch {}
if (opts?.resetToStart) { if (opts?.resetToStart) {
@ -1305,6 +1311,7 @@ export default function FinishedVideoPreview({
vv.addEventListener('canplay', onCanPlay) vv.addEventListener('canplay', onCanPlay)
vv.addEventListener('pause', onPause) vv.addEventListener('pause', onPause)
vv.addEventListener('waiting', onWaiting) vv.addEventListener('waiting', onWaiting)
teaserLastProgressTimeRef.current = performance.now()
// frischer Start für neue TopCard // frischer Start für neue TopCard
tryPlay({ resetToStart: true }) tryPlay({ resetToStart: true })
@ -1328,7 +1335,7 @@ export default function FinishedVideoPreview({
const stalledFor = now - teaserLastProgressTimeRef.current const stalledFor = now - teaserLastProgressTimeRef.current
const ct = Number(el.currentTime) const ct = Number(el.currentTime)
if ((!Number.isFinite(ct) || ct <= 0.05) && stalledFor > teaserWatchdogMs) { if ((!Number.isFinite(ct) || ct <= 0.05) && stalledFor > teaserStartResetWatchdogMs) {
tryPlay({ resetToStart: true }) tryPlay({ resetToStart: true })
return return
} }
@ -1359,7 +1366,7 @@ export default function FinishedVideoPreview({
pageVisible, pageVisible,
teaserRetryDelayMs, teaserRetryDelayMs,
teaserWatchdogMs, teaserWatchdogMs,
teaserSpeedHold, teaserStartResetWatchdogMs,
]) ])
useEffect(() => { useEffect(() => {
@ -1674,7 +1681,7 @@ export default function FinishedVideoPreview({
playsInline playsInline
autoPlay autoPlay
loop loop
preload={mobilePreviewConstrained ? 'metadata' : 'auto'} preload="auto"
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined} poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
onLoadedData={markTeaserAvailable} onLoadedData={markTeaserAvailable}
onCanPlay={markTeaserAvailable} onCanPlay={markTeaserAvailable}

View File

@ -901,7 +901,11 @@ export default function RecordJobActions({
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
setMenuOpen(false) setMenuOpen(false)
await onKeep?.(job) try {
await onKeep?.(job)
} catch {
// The app-level handler already reports delete/keep failures.
}
}} }}
> >
<BookmarkSquareIcon className={cn('size-4', colors.keep)} /> <BookmarkSquareIcon className={cn('size-4', colors.keep)} />
@ -921,7 +925,11 @@ export default function RecordJobActions({
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
setMenuOpen(false) setMenuOpen(false)
await onDelete?.(job) try {
await onDelete?.(job)
} catch {
// The app-level handler already reports delete/keep failures.
}
}} }}
> >
<TrashIcon className={cn('size-4', colors.del)} /> <TrashIcon className={cn('size-4', colors.del)} />

View File

@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import Button from './Button' import Button from './Button'
import LabeledSwitch from './LabeledSwitch'
import LoadingSpinner from './LoadingSpinner' import LoadingSpinner from './LoadingSpinner'
import { formatDuration } from './formatters' import { formatDuration } from './formatters'
import { import {
@ -128,6 +129,12 @@ type CorrectionState = {
boxes: TrainingBox[] boxes: TrainingBox[]
} }
type QueuedTrainingSample = {
sample: TrainingSample
correction?: CorrectionState
manualCorrection?: boolean
}
type TrainingBox = { type TrainingBox = {
label: string label: string
score?: number score?: number
@ -149,6 +156,9 @@ type TrainingPosePerson = {
score: number score: number
box: TrainingBox box: TrainingBox
keypoints: TrainingKeypoint[] keypoints: TrainingKeypoint[]
quality?: number
visibleKeypoints?: number
reliable?: boolean
} }
type BoxInteraction = type BoxInteraction =
@ -271,6 +281,170 @@ type TrainingFeedbackListResponse = {
type TrainingSampleMode = 'random' | 'uncertain' type TrainingSampleMode = 'random' | 'uncertain'
type FeedbackFilter = 'all' | 'accepted' | 'corrected' | 'negative' 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<readonly [string, string]> = [
['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<string, string> = {
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) { function backendText(data: any, fallback: string) {
return String( return String(
data?.message || 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 ( return (
(value.sexPosition && !isNoSexPositionValue(value.sexPosition)) || (value.sexPosition && !isNoSexPositionValue(value.sexPosition)) ||
(value.peoplePresent?.length ?? 0) > 0 || (value.boxes ?? []).some((box) => {
(value.bodyPartsPresent?.length ?? 0) > 0 || const normalized = normalizeBox(box)
(value.objectsPresent?.length ?? 0) > 0 || return Boolean(normalized.label && normalized.w > 0 && normalized.h > 0)
(value.clothingPresent?.length ?? 0) > 0 || })
(value.boxes?.length ?? 0) > 0
) )
} }
@ -1155,6 +1339,7 @@ function LabelToggleGrid(props: {
values: string[] values: string[]
selected: string[] selected: string[]
scores?: Record<string, number> scores?: Record<string, number>
activeCounts?: Record<string, number>
onToggle: (value: string) => void onToggle: (value: string) => void
drawLabel?: string drawLabel?: string
onDrawLabelChange?: (value: string) => void onDrawLabelChange?: (value: string) => void
@ -1172,7 +1357,8 @@ function LabelToggleGrid(props: {
return ( return (
<div className={props.gridClassName || 'grid grid-cols-2 gap-2 sm:grid-cols-3'}> <div className={props.gridClassName || 'grid grid-cols-2 gap-2 sm:grid-cols-3'}>
{props.values.map((value) => { {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 item = getSegmentLabelItem(value)
const Icon = item.icon const Icon = item.icon
const score = props.scores?.[value] const score = props.scores?.[value]
@ -1219,7 +1405,11 @@ function LabelToggleGrid(props: {
{item.text} {item.text}
</span> </span>
{hasScore ? ( {activeCount > 0 ? (
<span className="mt-0.5 rounded-full bg-indigo-50 px-1.5 py-0.5 text-[9px] font-bold text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-200 dark:ring-indigo-400/30">
{activeCount === 1 ? '1 Box' : `${activeCount} Boxen`}
</span>
) : hasScore ? (
<span <span
className={[ className={[
'mt-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-bold ring-1', 'mt-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-bold ring-1',
@ -1590,6 +1780,7 @@ function CollapsibleLabelSection(props: {
values: string[] values: string[]
selected: string[] selected: string[]
scores?: Record<string, number> scores?: Record<string, number>
activeCounts?: Record<string, number>
expanded: boolean expanded: boolean
onExpandedChange: (expanded: boolean) => void onExpandedChange: (expanded: boolean) => void
onToggle: (value: string) => void onToggle: (value: string) => void
@ -1603,9 +1794,18 @@ function CollapsibleLabelSection(props: {
const hasDrawLabelInSection = const hasDrawLabelInSection =
cleanDrawLabel !== '' && props.values.includes(cleanDrawLabel) cleanDrawLabel !== '' && props.values.includes(cleanDrawLabel)
const activeCount = props.singleDrawMode const countedActiveItems = props.activeCounts
? Math.max(props.selected.length, hasDrawLabelInSection ? 1 : 0) ? props.values.reduce(
: props.selected.length (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 hasActiveItems = activeCount > 0
const shown = props.expanded const shown = props.expanded
@ -1657,6 +1857,7 @@ function CollapsibleLabelSection(props: {
values={props.values} values={props.values}
selected={props.selected} selected={props.selected}
scores={props.scores} scores={props.scores}
activeCounts={props.activeCounts}
onToggle={props.onToggle} onToggle={props.onToggle}
drawLabel={props.drawLabel} drawLabel={props.drawLabel}
onDrawLabelChange={props.onDrawLabelChange} onDrawLabelChange={props.onDrawLabelChange}
@ -2559,9 +2760,9 @@ export default function TrainingTab(props: {
const tabActive = props.active ?? true const tabActive = props.active ?? true
const [labels, setLabels] = useState<TrainingLabels>(emptyLabels) const [labels, setLabels] = useState<TrainingLabels>(emptyLabels)
const [sample, setSample] = useState<TrainingSample | null>(null) const [sample, setSample] = useState<TrainingSample | null>(null)
const sampleRef = useRef<TrainingSample | null>(null)
const [correction, setCorrection] = useState<CorrectionState>(() => predictionToCorrection(null)) const [correction, setCorrection] = useState<CorrectionState>(() => predictionToCorrection(null))
const [hasManualCorrection, setHasManualCorrection] = useState(false) const [hasManualCorrection, setHasManualCorrection] = useState(false)
const [negativeExample, setNegativeExample] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [analysisProgress, setAnalysisProgress] = useState(0) const [analysisProgress, setAnalysisProgress] = useState(0)
const [analysisStep, setAnalysisStep] = useState('') const [analysisStep, setAnalysisStep] = useState('')
@ -2591,8 +2792,8 @@ export default function TrainingTab(props: {
} }
}) })
const [importedSampleQueue, setImportedSampleQueue] = useState<TrainingSample[]>([]) const [importedSampleQueue, setImportedSampleQueue] = useState<QueuedTrainingSample[]>([])
const importedSampleQueueRef = useRef<TrainingSample[]>([]) const importedSampleQueueRef = useRef<QueuedTrainingSample[]>([])
const [feedbackModalOpen, setFeedbackModalOpen] = useState(false) const [feedbackModalOpen, setFeedbackModalOpen] = useState(false)
const [feedbackItems, setFeedbackItems] = useState<TrainingAnnotation[]>([]) const [feedbackItems, setFeedbackItems] = useState<TrainingAnnotation[]>([])
@ -2628,6 +2829,7 @@ export default function TrainingTab(props: {
return false return false
} }
}) })
const [showPoseSkeleton, setShowPoseSkeleton] = useState(true)
const [frameNaturalSize, setFrameNaturalSize] = useState<{ const [frameNaturalSize, setFrameNaturalSize] = useState<{
width: number width: number
@ -2770,10 +2972,16 @@ export default function TrainingTab(props: {
]) ])
const editFeedbackItem = useCallback((item: TrainingAnnotation) => { const editFeedbackItem = useCallback((item: TrainingAnnotation) => {
setSample(annotationToTrainingSample(item)) const nextSample = annotationToTrainingSample(item)
setCorrection(annotationToCorrectionState(item)) const nextCorrection = annotationToCorrectionState(item)
setHasManualCorrection(!item.accepted) const nextManualCorrection = !item.accepted
setNegativeExample(Boolean(item.negative))
sampleRef.current = nextSample
correctionRef.current = nextCorrection
hasManualCorrectionRef.current = nextManualCorrection
setSample(nextSample)
setCorrection(nextCorrection)
setHasManualCorrection(nextManualCorrection)
setEditingFeedback({ setEditingFeedback({
sampleId: item.sampleId, sampleId: item.sampleId,
@ -3000,6 +3208,10 @@ export default function TrainingTab(props: {
importedSampleQueueRef.current = importedSampleQueue importedSampleQueueRef.current = importedSampleQueue
}, [importedSampleQueue]) }, [importedSampleQueue])
useEffect(() => {
sampleRef.current = sample
}, [sample])
useEffect(() => { useEffect(() => {
if (!feedbackModalOpen) return if (!feedbackModalOpen) return
@ -3050,8 +3262,11 @@ export default function TrainingTab(props: {
}, [labels.people, labels.bodyParts, labels.objects, labels.clothing]) }, [labels.people, labels.bodyParts, labels.objects, labels.clothing])
const correctionBoxes = correction.boxes ?? [] const correctionBoxes = correction.boxes ?? []
const isNegativeCorrection = const hasTrainableFeedbackContent = useMemo(
negativeExample && !correctionHasRelevantContent(correction) () => correctionHasTrainablePositionOrBoxes(correction),
[correction]
)
const willSaveAsNegative = Boolean(sample) && !hasTrainableFeedbackContent
const visibleBoxes = [ const visibleBoxes = [
...correctionBoxes.map((box, index) => ({ box, index, isDraft: false })), ...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(() => { const bodyPartScores = useMemo(() => {
return Object.fromEntries( return Object.fromEntries(
(sample?.prediction.bodyPartsPresent ?? []) (sample?.prediction.bodyPartsPresent ?? [])
@ -3103,6 +3326,19 @@ export default function TrainingTab(props: {
return Object.fromEntries(bestScores) return Object.fromEntries(bestScores)
}, [sample?.prediction.boxes, labels.people]) }, [sample?.prediction.boxes, labels.people])
const peopleBoxCounts = useMemo(() => {
const counts = new Map<string, number>()
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(() => { const selectedPeopleLabels = useMemo(() => {
return correction.peoplePresent return correction.peoplePresent
}, [correction.peoplePresent]) }, [correction.peoplePresent])
@ -3346,9 +3582,11 @@ export default function TrainingTab(props: {
const loadTrainingSampleIntoTab = useCallback(( const loadTrainingSampleIntoTab = useCallback((
nextSample: TrainingSample, 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) setDrawingBox(null)
setBoxInteraction(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) setSample(nextSample)
setCorrection(nextCorrection) setCorrection(nextCorrection)
setHasManualCorrection(Boolean(opts?.manualCorrection)) setHasManualCorrection(Boolean(opts?.manualCorrection))
setNegativeExample(false)
const initiallyExpandedSection: CorrectionSectionKey | null = const initiallyExpandedSection: CorrectionSectionKey | null =
nextCorrection.sexPosition && !isNoSexPositionValue(nextCorrection.sexPosition) nextCorrection.sexPosition && !isNoSexPositionValue(nextCorrection.sexPosition)
@ -3395,20 +3635,88 @@ export default function TrainingTab(props: {
) )
}, []) }, [])
const loadNextImportedQueuedSample = useCallback(() => { const setQueuedTrainingSamples = useCallback((nextQueue: QueuedTrainingSample[]) => {
const [nextSample, ...rest] = importedSampleQueueRef.current 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 return false
} }
importedSampleQueueRef.current = rest setQueuedTrainingSamples(rest)
setImportedSampleQueue(rest)
loadTrainingSampleIntoTab(nextSample) loadQueuedTrainingSample(nextItem)
return true 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?: { const loadNext = useCallback(async (opts?: {
forceNew?: boolean forceNew?: boolean
@ -3416,6 +3724,7 @@ export default function TrainingTab(props: {
preserveNotice?: boolean preserveNotice?: boolean
mode?: TrainingSampleMode mode?: TrainingSampleMode
previewUrl?: string previewUrl?: string
deferCurrentSampleToQueueEnd?: boolean
}) => { }) => {
const requestId = makeRequestId() const requestId = makeRequestId()
activeAnalysisRequestIdRef.current = requestId activeAnalysisRequestIdRef.current = requestId
@ -3477,6 +3786,10 @@ export default function TrainingTab(props: {
setAnalysisProgress(92) setAnalysisProgress(92)
setAnalysisStep('Analyse-Ergebnis wird übernommen…') setAnalysisStep('Analyse-Ergebnis wird übernommen…')
if (opts?.deferCurrentSampleToQueueEnd) {
deferCurrentSampleToQueueEnd()
}
loadTrainingSampleIntoTab(data as TrainingSample) loadTrainingSampleIntoTab(data as TrainingSample)
} catch (e) { } catch (e) {
if (isCurrentRequest()) { if (isCurrentRequest()) {
@ -3501,7 +3814,7 @@ export default function TrainingTab(props: {
setAnalysisStep('') setAnalysisStep('')
}, 500) }, 500)
} }
}, [loadTrainingSampleIntoTab, setLoadingPreviewCandidate]) }, [deferCurrentSampleToQueueEnd, loadTrainingSampleIntoTab, setLoadingPreviewCandidate])
const reloadCurrentImage = useCallback(async () => { const reloadCurrentImage = useCallback(async () => {
setDrawingBox(null) setDrawingBox(null)
@ -3595,12 +3908,11 @@ export default function TrainingTab(props: {
throw new Error('Es wurden keine Trainingsframes erzeugt.') throw new Error('Es wurden keine Trainingsframes erzeugt.')
} }
const [firstSample, ...queuedSamples] = samples const deferredCurrentSample = Boolean(sampleRef.current)
importedSampleQueueRef.current = queuedSamples loadPriorityTrainingSamples(samples, {
setImportedSampleQueue(queuedSamples) deferCurrentSampleToQueueEnd: deferredCurrentSample,
})
loadTrainingSampleIntoTab(firstSample)
setImageReloadKey((value) => value + 1) setImageReloadKey((value) => value + 1)
await loadTrainingStatus() await loadTrainingStatus()
@ -3613,6 +3925,14 @@ export default function TrainingTab(props: {
: `${samples.length} Frames ins Training übernommen.` : `${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 return true
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : String(e)) setError(e instanceof Error ? e.message : String(e))
@ -3638,7 +3958,7 @@ export default function TrainingTab(props: {
}, 500) }, 500)
} }
}, [ }, [
loadTrainingSampleIntoTab, loadPriorityTrainingSamples,
loadTrainingStatus, loadTrainingStatus,
setLoadingPreviewCandidate, setLoadingPreviewCandidate,
]) ])
@ -4221,7 +4541,13 @@ export default function TrainingTab(props: {
.map(normalizeBox) .map(normalizeBox)
.filter((box) => box.label && box.w > 0 && box.h > 0) .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 const correctionPayload: CorrectionState = negative
? { ? {
sexPosition: NO_SEX_POSITION_LABEL, sexPosition: NO_SEX_POSITION_LABEL,
@ -4329,7 +4655,6 @@ export default function TrainingTab(props: {
sample, sample,
correction, correction,
editingFeedback, editingFeedback,
isNegativeCorrection,
loadNext, loadNext,
loadTrainingStatus, loadTrainingStatus,
loadNextImportedQueuedSample, loadNextImportedQueuedSample,
@ -4358,10 +4683,12 @@ export default function TrainingTab(props: {
throw new Error(backendText(data, `HTTP ${res.status}`)) throw new Error(backendText(data, `HTTP ${res.status}`))
} }
sampleRef.current = null
correctionRef.current = predictionToCorrection(null)
hasManualCorrectionRef.current = false
setSample(null) setSample(null)
setCorrection(predictionToCorrection(null)) setCorrection(predictionToCorrection(null))
setHasManualCorrection(false) setHasManualCorrection(false)
setNegativeExample(false)
setDrawingBox(null) setDrawingBox(null)
setBoxInteraction(null) setBoxInteraction(null)
setTouchMagnifier(null) setTouchMagnifier(null)
@ -4474,9 +4801,11 @@ export default function TrainingTab(props: {
throw new Error(data?.error || `HTTP ${res.status}`) throw new Error(data?.error || `HTTP ${res.status}`)
} }
sampleRef.current = null
correctionRef.current = predictionToCorrection(null)
hasManualCorrectionRef.current = false
setSample(null) setSample(null)
setCorrection(predictionToCorrection(null)) setCorrection(predictionToCorrection(null))
setNegativeExample(false)
setTrainingStatus({ setTrainingStatus({
feedbackCount: 0, feedbackCount: 0,
requiredCount, requiredCount,
@ -5353,11 +5682,19 @@ export default function TrainingTab(props: {
setTrainingSampleMode(nextMode) setTrainingSampleMode(nextMode)
if (!uiLocked) { if (!uiLocked && nextMode === 'uncertain') {
void loadNext({ void loadNext({
forceNew: true, forceNew: true,
mode: nextMode, mode: nextMode,
deferCurrentSampleToQueueEnd: Boolean(sampleRef.current),
}) })
} else if (!uiLocked && !sampleRef.current) {
if (!loadNextImportedQueuedSample()) {
void loadNext({
forceNew: true,
mode: nextMode,
})
}
} }
}} }}
className={[ className={[
@ -6235,6 +6572,18 @@ export default function TrainingTab(props: {
].join(' ')} ].join(' ')}
style={imageStageStyle} style={imageStageStyle}
> >
{hasPosePersons ? (
<div className="absolute right-3 top-3 z-[620] max-w-[calc(100%-1.5rem)] rounded-lg border border-gray-200/80 bg-white/95 px-2.5 py-1.5 shadow-lg backdrop-blur dark:border-white/10 dark:bg-gray-950/90 sm:right-4 sm:top-4">
<LabeledSwitch
compact
size="short"
label={`Pose-Skelett (${posePersons.length})`}
checked={showPoseSkeleton}
onChange={setShowPoseSkeleton}
/>
</div>
) : null}
{imageSrc ? ( {imageSrc ? (
<div className="relative z-50 flex h-full min-h-0 w-full items-center justify-center overflow-visible"> <div className="relative z-50 flex h-full min-h-0 w-full items-center justify-center overflow-visible">
<div <div
@ -6290,6 +6639,175 @@ export default function TrainingTab(props: {
].join(' ')} ].join(' ')}
/> />
{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 (
<div
aria-hidden="true"
className="pointer-events-none absolute z-[295] overflow-visible"
style={imageLayerStyle}
>
{posePersons.map((person, personIndex) => {
const reliable = isPosePersonReliable(person)
const color = reliable
? POSE_PERSON_COLORS[personIndex % POSE_PERSON_COLORS.length]
: POSE_UNRELIABLE_COLOR
const score = Math.round(clamp01(Number(person.score)) * 100)
const quality = Math.round(posePersonQuality(person) * 100)
const visibleKeypoints = posePersonVisibleKeypoints(person)
return (
<div
key={`pose-box-${personIndex}`}
className={[
'absolute rounded border border-dashed',
reliable ? '' : 'bg-slate-500/5',
].join(' ')}
style={{
...poseBoxPixelStyle(person.box, layerWidth, layerHeight),
borderColor: color,
boxShadow: reliable ? `0 0 0 1px ${color}55` : `0 0 0 1px ${color}33`,
opacity: reliable ? 1 : 0.78,
}}
>
<span
className="absolute left-0 top-0 -translate-y-[calc(100%+3px)] rounded px-1 py-0.5 text-[9px] font-black leading-none text-white shadow"
style={{ backgroundColor: color }}
title={`Score ${score}% | Qualitaet ${quality}% | Keypoints ${visibleKeypoints}`}
>
{posePersons.length > 1 ? `Pose ${personIndex + 1}` : 'Pose'} {score}%
</span>
</div>
)
})}
<svg
className="absolute inset-0 h-full w-full overflow-visible"
viewBox={`0 0 ${layerWidth} ${layerHeight}`}
preserveAspectRatio="none"
>
{posePersons.map((person, personIndex) => {
const reliable = isPosePersonReliable(person)
const color = reliable
? POSE_PERSON_COLORS[personIndex % POSE_PERSON_COLORS.length]
: POSE_UNRELIABLE_COLOR
const keypointsByName = new Map(
person.keypoints.map((point) => [poseKeypointId(point.name), point])
)
return (
<g key={`pose-lines-${personIndex}`}>
{POSE_SKELETON_EDGES.map(([from, to]) => {
const fromPoint = keypointsByName.get(from)
const toPoint = keypointsByName.get(to)
if (
!isPoseKeypointVisible(fromPoint) ||
!isPoseKeypointVisible(toPoint)
) {
return null
}
return (
<line
key={`${from}-${to}`}
x1={poseCoordPx(fromPoint.x, layerWidth)}
y1={poseCoordPx(fromPoint.y, layerHeight)}
x2={poseCoordPx(toPoint.x, layerWidth)}
y2={poseCoordPx(toPoint.y, layerHeight)}
stroke={color}
strokeOpacity={reliable ? 0.9 : 0.45}
strokeWidth={2.5}
strokeLinecap="round"
strokeDasharray={reliable ? undefined : '4 4'}
vectorEffect="non-scaling-stroke"
/>
)
})}
</g>
)
})}
</svg>
{posePersons.map((person, personIndex) => {
const reliable = isPosePersonReliable(person)
const color = reliable
? POSE_PERSON_COLORS[personIndex % POSE_PERSON_COLORS.length]
: POSE_UNRELIABLE_COLOR
return person.keypoints
.filter(isPoseKeypointVisible)
.map((point, pointIndex) => {
const left = poseCoordPx(point.x, layerWidth)
const top = poseCoordPx(point.y, layerHeight)
const labelToLeft = left > layerWidth * 0.72
const label = poseKeypointLabel(point.name)
const confidence = Math.round(clamp01(Number(point.conf)) * 100)
const dot = (
<span
className="h-2 w-2 shrink-0 rounded-full border border-white shadow-[0_0_0_1px_rgba(0,0,0,0.45)]"
style={{ backgroundColor: color }}
/>
)
const text = (
<span
className={[
'rounded px-1 py-0.5 text-[9px] font-bold leading-none shadow ring-1',
reliable
? 'bg-white/95 text-gray-900 ring-black/10 dark:bg-gray-950/90 dark:text-white dark:ring-white/15'
: 'bg-slate-700/90 text-white ring-white/20',
].join(' ')}
>
{label}
</span>
)
return (
<div
key={`pose-point-${personIndex}-${poseKeypointId(point.name)}-${pointIndex}`}
className="absolute top-0 flex items-center gap-1 whitespace-nowrap"
style={{
left,
top,
transform: labelToLeft
? 'translate(calc(-100% + 4px), -50%)'
: 'translate(-4px, -50%)',
opacity: reliable ? 1 : 0.72,
}}
title={`${label} | ${confidence}%`}
>
{labelToLeft ? (
<>
{text}
{dot}
</>
) : (
<>
{dot}
{text}
</>
)}
</div>
)
})
})}
</div>
)
})() : null}
{showImageBoxes && imageLayerStyle ? ( {showImageBoxes && imageLayerStyle ? (
<div <div
className="absolute z-[300] overflow-visible" className="absolute z-[300] overflow-visible"
@ -6814,18 +7332,26 @@ export default function TrainingTab(props: {
> >
<Button <Button
size="md" size="md"
variant={hasManualCorrection ? 'primary' : 'soft'} variant={hasManualCorrection || willSaveAsNegative ? 'primary' : 'soft'}
color={hasManualCorrection ? undefined : 'emerald'} color={
willSaveAsNegative
? 'blue'
: hasManualCorrection
? undefined
: 'emerald'
}
disabled={ disabled={
uiLocked || uiLocked ||
frameBusy || frameBusy ||
!sample || !sample ||
(!hasManualCorrection && !sample.prediction.modelAvailable) (!hasManualCorrection && !willSaveAsNegative && !sample.prediction.modelAvailable)
} }
onClick={() => void saveFeedback(!hasManualCorrection)} onClick={() => void saveFeedback(!hasManualCorrection)}
className="w-full justify-center px-2 text-xs sm:text-sm" className="w-full justify-center px-2 text-xs sm:text-sm"
title={ title={
hasManualCorrection willSaveAsNegative
? 'Keine Box und keine Position gesetzt. Das Bild wird als Negativbeispiel gespeichert.'
: hasManualCorrection
? 'Die korrigierten Werte werden gespeichert.' ? 'Die korrigierten Werte werden gespeichert.'
: sample?.prediction.modelAvailable : sample?.prediction.modelAvailable
? 'Die Erkennung stimmt. Prediction wird als korrekt gespeichert.' ? 'Die Erkennung stimmt. Prediction wird als korrekt gespeichert.'
@ -6837,6 +7363,12 @@ export default function TrainingTab(props: {
<ArrowPathIcon className="h-3.5 w-3.5 animate-spin" aria-hidden="true" /> <ArrowPathIcon className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
Speichere Speichere
</span> </span>
) : willSaveAsNegative ? (
<span className="inline-flex items-center gap-1.5">
<XCircleIcon className="h-3.5 w-3.5" aria-hidden="true" />
<span className="sm:hidden">Negativ</span>
<span className="hidden sm:inline">Negativbeispiel & weiter</span>
</span>
) : hasManualCorrection ? ( ) : hasManualCorrection ? (
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<InboxArrowDownIcon className="h-3.5 w-3.5" aria-hidden="true" /> <InboxArrowDownIcon className="h-3.5 w-3.5" aria-hidden="true" />
@ -6870,22 +7402,6 @@ export default function TrainingTab(props: {
</span> </span>
</Button> </Button>
<Button
size="md"
variant={isNegativeCorrection ? 'primary' : 'soft'}
color="blue"
disabled={uiLocked || frameBusy || !sample}
onClick={() => void saveFeedback(false, { negative: true })}
className="col-span-2 w-full justify-center px-2 text-xs sm:text-sm"
title="Speichert das Bild ausdrücklich ohne relevante Labels oder Boxen als YOLO-Negativbeispiel."
>
<span className="inline-flex items-center gap-1.5">
<XCircleIcon className="h-3.5 w-3.5" aria-hidden="true" />
<span className="sm:hidden">Negativbeispiel</span>
<span className="hidden sm:inline">Keine relevanten Inhalte & weiter</span>
</span>
</Button>
<div className="col-span-2 hidden lg:block"> <div className="col-span-2 hidden lg:block">
<Button <Button
size="md" size="md"
@ -7102,6 +7618,7 @@ export default function TrainingTab(props: {
values={labels.people} values={labels.people}
selected={selectedPeopleLabels} selected={selectedPeopleLabels}
scores={peopleScores} scores={peopleScores}
activeCounts={peopleBoxCounts}
expanded={expandedCorrectionSections.people} expanded={expandedCorrectionSections.people}
onExpandedChange={(expanded) => onExpandedChange={(expanded) =>
toggleMobileCorrectionSection('people', expanded) toggleMobileCorrectionSection('people', expanded)
@ -7288,6 +7805,7 @@ export default function TrainingTab(props: {
values={labels.people} values={labels.people}
selected={selectedPeopleLabels} selected={selectedPeopleLabels}
scores={peopleScores} scores={peopleScores}
activeCounts={peopleBoxCounts}
expanded={expandedCorrectionSections.people} expanded={expandedCorrectionSections.people}
onExpandedChange={(expanded) => onExpandedChange={(expanded) =>
toggleCorrectionSection('people', expanded) toggleCorrectionSection('people', expanded)

View File

@ -0,0 +1,354 @@
'use client'
import {
useCallback,
useEffect,
useRef,
type MutableRefObject,
type RefObject,
} from 'react'
export const FINISHED_DOWNLOADS_AUTO_PAGE_SIZE_MAX = 120
const DEFAULT_BOTTOM_RESERVE_PX = 112
const DEFAULT_SHRINK_DEBOUNCE_MS = 220
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value))
}
function isHTMLElement(el: Element): el is HTMLElement {
return el instanceof HTMLElement
}
function availableViewportHeight(
el: HTMLElement,
bottomReservePx = DEFAULT_BOTTOM_RESERVE_PX
) {
const rect = el.getBoundingClientRect()
const viewportHeight =
window.innerHeight || document.documentElement.clientHeight || 0
return Math.max(0, viewportHeight - Math.max(0, rect.top) - bottomReservePx)
}
function numericStylePx(style: CSSStyleDeclaration, key: keyof CSSStyleDeclaration) {
const value = Number.parseFloat(String(style[key] ?? ''))
return Number.isFinite(value) ? value : 0
}
function measuredMaxHeight(elements: HTMLElement[], limit: number) {
let maxHeight = 0
for (const el of elements.slice(0, limit)) {
const height = el.getBoundingClientRect().height
if (Number.isFinite(height) && height > maxHeight) {
maxHeight = height
}
}
return maxHeight
}
type ApplyRecommendedSizeOptions = {
nextPageSize: number
onRecommendedPageSizeChange: (size: number) => void
lastRecommendedPageSizeRef: MutableRefObject<number | null>
shrinkTimerRef: MutableRefObject<number | null>
shrinkDebounceMs: number
}
function applyRecommendedSize({
nextPageSize,
onRecommendedPageSizeChange,
lastRecommendedPageSizeRef,
shrinkTimerRef,
shrinkDebounceMs,
}: ApplyRecommendedSizeOptions) {
const currentPageSize = lastRecommendedPageSizeRef.current
if (currentPageSize === nextPageSize) return
if (currentPageSize == null || nextPageSize > currentPageSize) {
if (shrinkTimerRef.current !== null) {
window.clearTimeout(shrinkTimerRef.current)
shrinkTimerRef.current = null
}
lastRecommendedPageSizeRef.current = nextPageSize
onRecommendedPageSizeChange(nextPageSize)
return
}
if (shrinkTimerRef.current !== null) {
window.clearTimeout(shrinkTimerRef.current)
}
shrinkTimerRef.current = window.setTimeout(() => {
shrinkTimerRef.current = null
lastRecommendedPageSizeRef.current = nextPageSize
onRecommendedPageSizeChange(nextPageSize)
}, shrinkDebounceMs)
}
type AutoGridPageSizeOptions<T extends HTMLElement> = {
rootRef: RefObject<T | null>
enabled?: boolean
minItemWidth: number
gapPx?: number
minPageSize?: number
maxPageSize?: number
bottomReservePx?: number
shrinkDebounceMs?: number
fallbackItemHeight?: (info: {
columns: number
itemWidth: number
containerWidth: number
}) => number
onRecommendedPageSizeChange?: (size: number) => void
}
export function useFinishedDownloadsAutoGridPageSize<T extends HTMLElement>({
rootRef,
enabled = true,
minItemWidth,
gapPx = 12,
minPageSize,
maxPageSize = FINISHED_DOWNLOADS_AUTO_PAGE_SIZE_MAX,
bottomReservePx = DEFAULT_BOTTOM_RESERVE_PX,
shrinkDebounceMs = DEFAULT_SHRINK_DEBOUNCE_MS,
fallbackItemHeight,
onRecommendedPageSizeChange,
}: AutoGridPageSizeOptions<T>) {
const lastRecommendedPageSizeRef = useRef<number | null>(null)
const shrinkTimerRef = useRef<number | null>(null)
const rafRef = useRef<number | null>(null)
const recommendPageSize = useCallback(() => {
if (!enabled || !onRecommendedPageSizeChange) return
const el = rootRef.current
if (!el) return
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current)
}
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null
const containerWidth = el.clientWidth
if (!Number.isFinite(containerWidth) || containerWidth <= 0) return
const safeMinItemWidth = Math.max(1, minItemWidth)
const columns = Math.max(
1,
Math.floor((containerWidth + gapPx) / (safeMinItemWidth + gapPx))
)
const itemWidth =
columns > 0
? (containerWidth - gapPx * (columns - 1)) / columns
: containerWidth
const fallbackHeight =
fallbackItemHeight?.({ columns, itemWidth, containerWidth }) ??
Math.max(1, Math.round(itemWidth))
const grid = el.firstElementChild
const items =
grid && isHTMLElement(grid)
? Array.from(grid.children).filter(isHTMLElement)
: []
const measuredHeight = measuredMaxHeight(items, 36)
const itemHeight = Math.max(1, measuredHeight || fallbackHeight)
const availableHeight = availableViewportHeight(el, bottomReservePx)
const rowsPerPage = Math.max(
1,
Math.floor((availableHeight + gapPx) / (itemHeight + gapPx))
)
const nextPageSize = clamp(
columns * rowsPerPage,
Math.max(1, minPageSize ?? columns),
Math.max(1, maxPageSize)
)
applyRecommendedSize({
nextPageSize,
onRecommendedPageSizeChange,
lastRecommendedPageSizeRef,
shrinkTimerRef,
shrinkDebounceMs,
})
})
}, [
enabled,
rootRef,
minItemWidth,
gapPx,
minPageSize,
maxPageSize,
bottomReservePx,
shrinkDebounceMs,
fallbackItemHeight,
onRecommendedPageSizeChange,
])
useEffect(() => {
if (!enabled || !onRecommendedPageSizeChange) return
recommendPageSize()
const el = rootRef.current
if (!el) return
const ro = new ResizeObserver(() => {
recommendPageSize()
})
ro.observe(el)
window.addEventListener('resize', recommendPageSize)
return () => {
ro.disconnect()
window.removeEventListener('resize', recommendPageSize)
if (shrinkTimerRef.current !== null) {
window.clearTimeout(shrinkTimerRef.current)
shrinkTimerRef.current = null
}
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}
}, [enabled, rootRef, onRecommendedPageSizeChange, recommendPageSize])
}
type AutoTablePageSizeOptions<T extends HTMLElement> = {
rootRef: RefObject<T | null>
enabled?: boolean
fallbackRowHeight?: number
fallbackHeaderHeight?: number
minPageSize?: number
maxPageSize?: number
bottomReservePx?: number
shrinkDebounceMs?: number
onRecommendedPageSizeChange?: (size: number) => void
}
export function useFinishedDownloadsAutoTablePageSize<T extends HTMLElement>({
rootRef,
enabled = true,
fallbackRowHeight = 88,
fallbackHeaderHeight = 48,
minPageSize = 1,
maxPageSize = FINISHED_DOWNLOADS_AUTO_PAGE_SIZE_MAX,
bottomReservePx = DEFAULT_BOTTOM_RESERVE_PX,
shrinkDebounceMs = DEFAULT_SHRINK_DEBOUNCE_MS,
onRecommendedPageSizeChange,
}: AutoTablePageSizeOptions<T>) {
const lastRecommendedPageSizeRef = useRef<number | null>(null)
const shrinkTimerRef = useRef<number | null>(null)
const rafRef = useRef<number | null>(null)
const recommendPageSize = useCallback(() => {
if (!enabled || !onRecommendedPageSizeChange) return
const el = rootRef.current
if (!el) return
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current)
}
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null
const table = el.querySelector('table')
const header = table?.querySelector('thead')
const rowEls = Array.from(table?.querySelectorAll('tbody tr') ?? [])
.filter(isHTMLElement)
const style = window.getComputedStyle(el)
const chromeHeight =
numericStylePx(style, 'paddingTop') +
numericStylePx(style, 'paddingBottom') +
numericStylePx(style, 'borderTopWidth') +
numericStylePx(style, 'borderBottomWidth')
const headerHeight = header instanceof HTMLElement
? header.getBoundingClientRect().height || fallbackHeaderHeight
: fallbackHeaderHeight
const measuredRowHeight = measuredMaxHeight(rowEls, 12)
const rowHeight = Math.max(1, measuredRowHeight || fallbackRowHeight)
const availableForRows =
availableViewportHeight(el, bottomReservePx) - chromeHeight - headerHeight
const rowsPerPage = Math.max(
1,
Math.floor(availableForRows / rowHeight)
)
const nextPageSize = clamp(
rowsPerPage,
Math.max(1, minPageSize),
Math.max(1, maxPageSize)
)
applyRecommendedSize({
nextPageSize,
onRecommendedPageSizeChange,
lastRecommendedPageSizeRef,
shrinkTimerRef,
shrinkDebounceMs,
})
})
}, [
enabled,
rootRef,
fallbackRowHeight,
fallbackHeaderHeight,
minPageSize,
maxPageSize,
bottomReservePx,
shrinkDebounceMs,
onRecommendedPageSizeChange,
])
useEffect(() => {
if (!enabled || !onRecommendedPageSizeChange) return
recommendPageSize()
const el = rootRef.current
if (!el) return
const ro = new ResizeObserver(() => {
recommendPageSize()
})
ro.observe(el)
window.addEventListener('resize', recommendPageSize)
return () => {
ro.disconnect()
window.removeEventListener('resize', recommendPageSize)
if (shrinkTimerRef.current !== null) {
window.clearTimeout(shrinkTimerRef.current)
shrinkTimerRef.current = null
}
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}
}, [enabled, rootRef, onRecommendedPageSizeChange, recommendPageSize])
}

View File

@ -8,8 +8,8 @@ export type SelectedItem = {
} }
export type UndoAction = export type UndoAction =
| { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep' } | { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep'; job?: RecordJob }
| { kind: 'keep'; keptFile: string; originalFile: string; rowKey?: string } | { kind: 'keep'; keptFile: string; originalFile: string; rowKey?: string; job?: RecordJob }
| { kind: 'hot'; currentFile: string } | { kind: 'hot'; currentFile: string }
export type PersistedUndoState = { export type PersistedUndoState = {