bugfixes
This commit is contained in:
parent
67d62c1fbc
commit
1c8071a275
@ -1,6 +1,7 @@
|
||||
# backend\ai_server.py
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
@ -182,6 +183,9 @@ _LABEL_ERROR = ""
|
||||
_DEVICE = os.environ.get("YOLO_DEVICE", "")
|
||||
_CONF = float(os.environ.get("YOLO_CONF", "0.25"))
|
||||
_POSE_CONF = float(os.environ.get("YOLO_POSE_CONF", "0.30"))
|
||||
_POSE_RELIABLE_MIN_SCORE = float(os.environ.get("YOLO_POSE_RELIABLE_MIN_SCORE", "0.45"))
|
||||
_POSE_RELIABLE_MIN_KEYPOINTS = int(os.environ.get("YOLO_POSE_RELIABLE_MIN_KEYPOINTS", "6"))
|
||||
_POSE_RELIABLE_MIN_QUALITY = float(os.environ.get("YOLO_POSE_RELIABLE_MIN_QUALITY", "0.45"))
|
||||
_BATCH = int(os.environ.get("YOLO_BATCH", "16"))
|
||||
_IMGSZ = int(os.environ.get("YOLO_IMGSZ", "640"))
|
||||
_HALF = os.environ.get("YOLO_HALF", "0").lower() in {"1", "true", "yes", "on"}
|
||||
@ -530,7 +534,7 @@ def pose_persons_from_result(result) -> list[dict]:
|
||||
"conf": kconf,
|
||||
})
|
||||
|
||||
persons.append({
|
||||
persons.append(annotate_pose_person_quality({
|
||||
"label": label,
|
||||
"score": score,
|
||||
"box": {
|
||||
@ -540,11 +544,679 @@ def pose_persons_from_result(result) -> list[dict]:
|
||||
"h": h,
|
||||
},
|
||||
"keypoints": keypoints,
|
||||
})
|
||||
}))
|
||||
|
||||
return persons
|
||||
|
||||
|
||||
def clamp01(value) -> float:
|
||||
try:
|
||||
n = float(value)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
if not math.isfinite(n):
|
||||
return 0.0
|
||||
|
||||
return max(0.0, min(1.0, n))
|
||||
|
||||
|
||||
def is_finite01(value) -> bool:
|
||||
try:
|
||||
n = float(value)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return math.isfinite(n) and 0.0 <= n <= 1.0
|
||||
|
||||
|
||||
def normalized_box(box: dict | None) -> dict | None:
|
||||
if not isinstance(box, dict):
|
||||
return None
|
||||
|
||||
x = clamp01(box.get("x", 0.0))
|
||||
y = clamp01(box.get("y", 0.0))
|
||||
w = clamp01(box.get("w", 0.0))
|
||||
h = clamp01(box.get("h", 0.0))
|
||||
|
||||
if w <= 0 or h <= 0:
|
||||
return None
|
||||
|
||||
if x + w > 1:
|
||||
w = 1 - x
|
||||
if y + h > 1:
|
||||
h = 1 - y
|
||||
|
||||
if w <= 0 or h <= 0:
|
||||
return None
|
||||
|
||||
out = dict(box)
|
||||
out.update({"x": x, "y": y, "w": w, "h": h})
|
||||
return out
|
||||
|
||||
|
||||
def box_area(box: dict) -> float:
|
||||
clean = normalized_box(box)
|
||||
if not clean:
|
||||
return 0.0
|
||||
return float(clean["w"]) * float(clean["h"])
|
||||
|
||||
|
||||
def box_center(box: dict) -> tuple[float, float] | None:
|
||||
clean = normalized_box(box)
|
||||
if not clean:
|
||||
return None
|
||||
return float(clean["x"]) + float(clean["w"]) / 2.0, float(clean["y"]) + float(clean["h"]) / 2.0
|
||||
|
||||
|
||||
def box_gap(a: dict, b: dict) -> float:
|
||||
a = normalized_box(a)
|
||||
b = normalized_box(b)
|
||||
if not a or not b:
|
||||
return 1.0
|
||||
|
||||
dx = max(0.0, max(float(a["x"]) - (float(b["x"]) + float(b["w"])), float(b["x"]) - (float(a["x"]) + float(a["w"]))))
|
||||
dy = max(0.0, max(float(a["y"]) - (float(b["y"]) + float(b["h"])), float(b["y"]) - (float(a["y"]) + float(a["h"]))))
|
||||
return math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
|
||||
def box_overlap_ratio(a: dict, b: dict) -> float:
|
||||
a = normalized_box(a)
|
||||
b = normalized_box(b)
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
|
||||
left = max(float(a["x"]), float(b["x"]))
|
||||
top = max(float(a["y"]), float(b["y"]))
|
||||
right = min(float(a["x"]) + float(a["w"]), float(b["x"]) + float(b["w"]))
|
||||
bottom = min(float(a["y"]) + float(a["h"]), float(b["y"]) + float(b["h"]))
|
||||
|
||||
if right <= left or bottom <= top:
|
||||
return 0.0
|
||||
|
||||
min_area = min(box_area(a), box_area(b))
|
||||
if min_area <= 0:
|
||||
return 0.0
|
||||
|
||||
return clamp01(((right - left) * (bottom - top)) / min_area)
|
||||
|
||||
|
||||
def box_horizontal_overlap_ratio(a: dict, b: dict) -> float:
|
||||
a = normalized_box(a)
|
||||
b = normalized_box(b)
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
|
||||
left = max(float(a["x"]), float(b["x"]))
|
||||
right = min(float(a["x"]) + float(a["w"]), float(b["x"]) + float(b["w"]))
|
||||
if right <= left:
|
||||
return 0.0
|
||||
|
||||
min_width = min(float(a["w"]), float(b["w"]))
|
||||
if min_width <= 0:
|
||||
return 0.0
|
||||
|
||||
return clamp01((right - left) / min_width)
|
||||
|
||||
|
||||
def is_person_like_label(label: str) -> bool:
|
||||
clean = str(label or "").strip().lower()
|
||||
return clean == "person" or clean.startswith("person_")
|
||||
|
||||
|
||||
def boxes_by_label(prediction: dict, *labels: str) -> list[dict]:
|
||||
wanted = {str(label).strip().lower() for label in labels if str(label).strip()}
|
||||
out = []
|
||||
|
||||
for box in prediction.get("boxes", []) or []:
|
||||
label = str(box.get("label", "")).strip().lower() if isinstance(box, dict) else ""
|
||||
if label not in wanted:
|
||||
continue
|
||||
|
||||
clean = normalized_box(box)
|
||||
if clean:
|
||||
out.append(clean)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def any_boxes_near(left_boxes: list[dict], right_boxes: list[dict], margin: float) -> bool:
|
||||
for left in left_boxes:
|
||||
for right in right_boxes:
|
||||
if box_gap(left, right) <= margin or box_overlap_ratio(left, right) > 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def point_near_box(x: float, y: float, box: dict, margin: float) -> bool:
|
||||
if not is_finite01(x) or not is_finite01(y):
|
||||
return False
|
||||
|
||||
clean = normalized_box(box)
|
||||
if not clean:
|
||||
return False
|
||||
|
||||
return (
|
||||
float(clean["x"]) - margin <= x <= float(clean["x"]) + float(clean["w"]) + margin
|
||||
and float(clean["y"]) - margin <= y <= float(clean["y"]) + float(clean["h"]) + margin
|
||||
)
|
||||
|
||||
|
||||
def any_pose_keypoint_near_boxes(
|
||||
persons: list[dict],
|
||||
keypoint_names: list[str],
|
||||
boxes: list[dict],
|
||||
margin: float,
|
||||
) -> bool:
|
||||
if not boxes:
|
||||
return False
|
||||
|
||||
wanted = {str(name).strip().lower() for name in keypoint_names}
|
||||
|
||||
for person in reliable_pose_persons(persons):
|
||||
for point in person.get("keypoints", []) or []:
|
||||
name = str(point.get("name", "")).strip().lower()
|
||||
if name not in wanted:
|
||||
continue
|
||||
if float(point.get("conf") or 0.0) < 0.20:
|
||||
continue
|
||||
|
||||
x = float(point.get("x") or 0.0)
|
||||
y = float(point.get("y") or 0.0)
|
||||
|
||||
for box in boxes:
|
||||
if point_near_box(x, y, box, margin):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def pose_keypoint_stats(person: dict) -> tuple[int, float]:
|
||||
keypoints = person.get("keypoints", []) or []
|
||||
if not keypoints:
|
||||
return 0, 0.0
|
||||
|
||||
visible = 0
|
||||
total_conf = 0.0
|
||||
|
||||
for point in keypoints:
|
||||
x = float(point.get("x") or 0.0)
|
||||
y = float(point.get("y") or 0.0)
|
||||
conf = float(point.get("conf") or 0.0)
|
||||
|
||||
if conf < 0.20 or not is_finite01(x) or not is_finite01(y):
|
||||
continue
|
||||
|
||||
visible += 1
|
||||
total_conf += clamp01(conf)
|
||||
|
||||
if visible == 0:
|
||||
return 0, 0.0
|
||||
|
||||
coverage = clamp01(visible / max(1, len(KEYPOINT_NAMES)))
|
||||
avg_conf = clamp01(total_conf / visible)
|
||||
return visible, clamp01(coverage * 0.45 + avg_conf * 0.55)
|
||||
|
||||
|
||||
def pose_keypoint_quality(person: dict) -> float:
|
||||
_, quality = pose_keypoint_stats(person)
|
||||
return quality
|
||||
|
||||
|
||||
def annotate_pose_person_quality(person: dict) -> dict:
|
||||
visible, quality = pose_keypoint_stats(person)
|
||||
score = clamp01(float(person.get("score") or 0.0))
|
||||
|
||||
person["visibleKeypoints"] = visible
|
||||
person["quality"] = quality
|
||||
person["reliable"] = (
|
||||
score >= _POSE_RELIABLE_MIN_SCORE
|
||||
and visible >= _POSE_RELIABLE_MIN_KEYPOINTS
|
||||
and quality >= _POSE_RELIABLE_MIN_QUALITY
|
||||
)
|
||||
|
||||
return person
|
||||
|
||||
|
||||
def reliable_pose_persons(persons: list[dict]) -> list[dict]:
|
||||
out = []
|
||||
|
||||
for person in persons:
|
||||
if not isinstance(person, dict):
|
||||
continue
|
||||
|
||||
if "reliable" not in person:
|
||||
annotate_pose_person_quality(person)
|
||||
|
||||
if bool(person.get("reliable")):
|
||||
out.append(person)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def scene_person_boxes(prediction: dict, persons: list[dict]) -> list[dict]:
|
||||
detector_boxes = []
|
||||
pose_boxes = []
|
||||
|
||||
for box in prediction.get("boxes", []) or []:
|
||||
if not isinstance(box, dict) or not is_person_like_label(box.get("label", "")):
|
||||
continue
|
||||
|
||||
clean = normalized_box(box)
|
||||
if clean:
|
||||
detector_boxes.append(clean)
|
||||
|
||||
for person in reliable_pose_persons(persons):
|
||||
clean = normalized_box(person.get("box"))
|
||||
if clean:
|
||||
pose_boxes.append(clean)
|
||||
|
||||
if detector_boxes and len(detector_boxes) >= len(pose_boxes):
|
||||
return detector_boxes
|
||||
|
||||
return pose_boxes
|
||||
|
||||
|
||||
def scene_person_pair_signals(boxes: list[dict]) -> dict:
|
||||
signals = {
|
||||
"close": False,
|
||||
"overlap": False,
|
||||
"horizontal": False,
|
||||
"vertical": False,
|
||||
"stacked": False,
|
||||
}
|
||||
|
||||
for i, left in enumerate(boxes):
|
||||
left = normalized_box(left)
|
||||
if not left:
|
||||
continue
|
||||
|
||||
for raw_right in boxes[i + 1:]:
|
||||
right = normalized_box(raw_right)
|
||||
if not right:
|
||||
continue
|
||||
|
||||
gap = box_gap(left, right)
|
||||
overlap = box_overlap_ratio(left, right)
|
||||
close = gap <= 0.08 or overlap >= 0.08
|
||||
|
||||
if close:
|
||||
signals["close"] = True
|
||||
if overlap >= 0.18:
|
||||
signals["overlap"] = True
|
||||
if close and float(left["w"]) > float(left["h"]) * 1.15 and float(right["w"]) > float(right["h"]) * 1.15:
|
||||
signals["horizontal"] = True
|
||||
if close and float(left["h"]) > float(left["w"]) * 1.25 and float(right["h"]) > float(right["w"]) * 1.25:
|
||||
signals["vertical"] = True
|
||||
|
||||
left_center = box_center(left)
|
||||
right_center = box_center(right)
|
||||
if (
|
||||
left_center
|
||||
and right_center
|
||||
and gap <= 0.12
|
||||
and box_horizontal_overlap_ratio(left, right) >= 0.35
|
||||
and abs(left_center[1] - right_center[1]) >= 0.12
|
||||
):
|
||||
signals["stacked"] = True
|
||||
|
||||
return signals
|
||||
|
||||
|
||||
def pose_point(person: dict, name: str, min_conf: float = 0.20) -> tuple[float, float] | None:
|
||||
wanted = str(name or "").strip().lower()
|
||||
if not wanted:
|
||||
return None
|
||||
|
||||
for point in person.get("keypoints", []) or []:
|
||||
point_name = str(point.get("name", "")).strip().lower()
|
||||
if point_name != wanted:
|
||||
continue
|
||||
|
||||
conf = float(point.get("conf") or 0.0)
|
||||
x = float(point.get("x") or 0.0)
|
||||
y = float(point.get("y") or 0.0)
|
||||
|
||||
if conf >= min_conf and is_finite01(x) and is_finite01(y):
|
||||
return x, y
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def pose_midpoint(
|
||||
person: dict,
|
||||
names: list[str],
|
||||
min_conf: float = 0.20,
|
||||
min_points: int = 1,
|
||||
) -> tuple[float, float] | None:
|
||||
points = [
|
||||
point
|
||||
for name in names
|
||||
if (point := pose_point(person, name, min_conf)) is not None
|
||||
]
|
||||
|
||||
if len(points) < min_points:
|
||||
return None
|
||||
|
||||
x = sum(point[0] for point in points) / len(points)
|
||||
y = sum(point[1] for point in points) / len(points)
|
||||
return x, y
|
||||
|
||||
|
||||
def point_distance(a: tuple[float, float] | None, b: tuple[float, float] | None) -> float:
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
|
||||
return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2)
|
||||
|
||||
|
||||
def pose_person_geometry(person: dict) -> dict:
|
||||
box = normalized_box(person.get("box"))
|
||||
box_center_point = box_center(box) if box else None
|
||||
|
||||
left_hip = pose_point(person, "left_hip")
|
||||
right_hip = pose_point(person, "right_hip")
|
||||
left_knee = pose_point(person, "left_knee")
|
||||
right_knee = pose_point(person, "right_knee")
|
||||
|
||||
hip = pose_midpoint(person, ["left_hip", "right_hip"])
|
||||
shoulder = pose_midpoint(person, ["left_shoulder", "right_shoulder"])
|
||||
knee = pose_midpoint(person, ["left_knee", "right_knee"])
|
||||
head = pose_midpoint(person, ["nose", "left_eye", "right_eye", "left_ear", "right_ear"])
|
||||
|
||||
center = hip or box_center_point or (0.5, 0.5)
|
||||
|
||||
torso_dx = 0.0
|
||||
torso_dy = 0.0
|
||||
torso_len = 0.0
|
||||
if hip and shoulder:
|
||||
torso_dx = abs(hip[0] - shoulder[0])
|
||||
torso_dy = abs(hip[1] - shoulder[1])
|
||||
torso_len = math.sqrt(torso_dx * torso_dx + torso_dy * torso_dy)
|
||||
|
||||
hip_width = point_distance(left_hip, right_hip)
|
||||
knee_width = point_distance(left_knee, right_knee)
|
||||
|
||||
knees_below_hips = bool(knee and hip and knee[1] > hip[1] + 0.045)
|
||||
knees_wide = bool(
|
||||
knee_width > 0
|
||||
and knee_width >= max(0.11, hip_width * 1.12)
|
||||
)
|
||||
straddling = knees_below_hips and knees_wide
|
||||
|
||||
torso_horizontal = torso_len >= 0.07 and torso_dx >= torso_dy * 1.15
|
||||
torso_vertical = torso_len >= 0.07 and torso_dy >= torso_dx * 1.15
|
||||
|
||||
box_horizontal = bool(box and float(box["w"]) >= float(box["h"]) * 1.05)
|
||||
box_vertical = bool(box and float(box["h"]) >= float(box["w"]) * 1.25)
|
||||
|
||||
lying = torso_horizontal or box_horizontal
|
||||
upright = torso_vertical or box_vertical
|
||||
all_fours = torso_horizontal and knees_below_hips
|
||||
bent_or_kneeling = all_fours or (knees_below_hips and not straddling)
|
||||
|
||||
return {
|
||||
"box": box,
|
||||
"center": center,
|
||||
"head": head,
|
||||
"hip": hip,
|
||||
"shoulder": shoulder,
|
||||
"knee": knee,
|
||||
"lying": lying,
|
||||
"upright": upright,
|
||||
"straddling": straddling,
|
||||
"knees_below_hips": knees_below_hips,
|
||||
"knees_wide": knees_wide,
|
||||
"all_fours": all_fours,
|
||||
"bent_or_kneeling": bent_or_kneeling,
|
||||
}
|
||||
|
||||
|
||||
def combine_position_score(scores: dict[str, float], label: str, score: float) -> None:
|
||||
label = normalize_sex_position_label(label)
|
||||
if is_no_sex_position_label(label) or label not in POSITION_LABELS or score <= 0:
|
||||
return
|
||||
|
||||
score = clamp01(score)
|
||||
current = clamp01(scores.get(label, 0.0))
|
||||
scores[label] = clamp01(1 - (1 - current) * (1 - score))
|
||||
|
||||
|
||||
def add_pose_pair_geometry_scores(scores: dict[str, float], persons: list[dict]) -> None:
|
||||
def add(label: str, score: float) -> None:
|
||||
combine_position_score(scores, label, score)
|
||||
|
||||
reliable_persons = reliable_pose_persons(persons)
|
||||
if len(reliable_persons) < 2:
|
||||
return
|
||||
|
||||
geometries = [pose_person_geometry(person) for person in reliable_persons]
|
||||
|
||||
for i, left in enumerate(geometries):
|
||||
left_box = left["box"]
|
||||
if not left_box:
|
||||
continue
|
||||
|
||||
for right in geometries[i + 1:]:
|
||||
right_box = right["box"]
|
||||
if not right_box:
|
||||
continue
|
||||
|
||||
gap = box_gap(left_box, right_box)
|
||||
overlap = box_overlap_ratio(left_box, right_box)
|
||||
horizontal_overlap = box_horizontal_overlap_ratio(left_box, right_box)
|
||||
close = gap <= 0.12 or overlap >= 0.08
|
||||
|
||||
if not close:
|
||||
continue
|
||||
|
||||
left_center = left["center"]
|
||||
right_center = right["center"]
|
||||
top = left if left_center[1] <= right_center[1] else right
|
||||
bottom = right if top is left else left
|
||||
|
||||
top_above = top["center"][1] <= bottom["center"][1] - 0.055
|
||||
strong_stack = horizontal_overlap >= 0.35 and (top_above or overlap >= 0.22)
|
||||
top_has_rider_shape = (
|
||||
top["straddling"]
|
||||
or top["knees_wide"]
|
||||
or (top["upright"] and top["knees_below_hips"])
|
||||
)
|
||||
|
||||
if strong_stack and top_has_rider_shape:
|
||||
add("cowgirl", 0.20)
|
||||
add("reverse_cowgirl", 0.17)
|
||||
|
||||
if bottom["lying"]:
|
||||
add("cowgirl", 0.12)
|
||||
add("reverse_cowgirl", 0.10)
|
||||
if top["straddling"]:
|
||||
add("cowgirl", 0.08)
|
||||
add("reverse_cowgirl", 0.06)
|
||||
|
||||
if strong_stack and bottom["lying"]:
|
||||
add("missionary", 0.14)
|
||||
if not top["straddling"]:
|
||||
add("missionary", 0.08)
|
||||
|
||||
both_lying = left["lying"] and right["lying"]
|
||||
same_level = abs(left_center[1] - right_center[1]) <= 0.15
|
||||
side_by_side = abs(left_center[0] - right_center[0]) >= 0.10
|
||||
|
||||
if both_lying and (same_level or side_by_side):
|
||||
add("spooning", 0.18)
|
||||
if overlap >= 0.10:
|
||||
add("prone_bone", 0.07)
|
||||
|
||||
left_bent = left["all_fours"] or left["bent_or_kneeling"]
|
||||
right_bent = right["all_fours"] or right["bent_or_kneeling"]
|
||||
|
||||
if left_bent != right_bent:
|
||||
other = right if left_bent else left
|
||||
add("doggy", 0.16)
|
||||
if other["upright"]:
|
||||
add("doggy", 0.06)
|
||||
add("standing_doggy", 0.07)
|
||||
if left["lying"] or right["lying"]:
|
||||
add("prone_bone", 0.06)
|
||||
elif left["all_fours"] or right["all_fours"]:
|
||||
add("doggy", 0.13)
|
||||
if left["lying"] or right["lying"]:
|
||||
add("prone_bone", 0.07)
|
||||
|
||||
|
||||
def add_pose_scene_context_scores(scores: dict[str, float], prediction: dict, persons: list[dict]) -> None:
|
||||
def add(label: str, score: float) -> None:
|
||||
combine_position_score(scores, label, score)
|
||||
|
||||
reliable_persons = reliable_pose_persons(persons)
|
||||
person_boxes = scene_person_boxes(prediction, reliable_persons)
|
||||
person_count = max(len(person_boxes), len(reliable_persons))
|
||||
pair = scene_person_pair_signals(person_boxes)
|
||||
add_pose_pair_geometry_scores(scores, reliable_persons)
|
||||
|
||||
penis_boxes = boxes_by_label(prediction, "penis")
|
||||
pussy_boxes = boxes_by_label(prediction, "pussy", "vagina", "vulva", "labia")
|
||||
ass_boxes = boxes_by_label(prediction, "ass", "anus")
|
||||
breast_boxes = boxes_by_label(prediction, "breasts")
|
||||
tongue_boxes = boxes_by_label(prediction, "tongue")
|
||||
toy_boxes = boxes_by_label(prediction, "dildo", "vibrator", "strapon", "buttplug")
|
||||
|
||||
has_penis = bool(penis_boxes)
|
||||
has_pussy = bool(pussy_boxes)
|
||||
has_ass = bool(ass_boxes)
|
||||
has_breasts = bool(breast_boxes)
|
||||
has_tongue = bool(tongue_boxes)
|
||||
has_toy = bool(toy_boxes)
|
||||
|
||||
head_names = ["nose", "left_eye", "right_eye", "left_ear", "right_ear"]
|
||||
hand_names = ["left_wrist", "right_wrist"]
|
||||
hip_names = ["left_hip", "right_hip"]
|
||||
|
||||
head_near_penis = any_pose_keypoint_near_boxes(reliable_persons, head_names, penis_boxes, 0.09)
|
||||
head_near_pussy = any_pose_keypoint_near_boxes(reliable_persons, head_names, pussy_boxes, 0.09)
|
||||
head_near_ass = any_pose_keypoint_near_boxes(reliable_persons, head_names, ass_boxes, 0.09)
|
||||
hand_near_penis = any_pose_keypoint_near_boxes(reliable_persons, hand_names, penis_boxes, 0.08)
|
||||
hand_near_pussy = any_pose_keypoint_near_boxes(reliable_persons, hand_names, pussy_boxes, 0.08)
|
||||
hand_near_toy = any_pose_keypoint_near_boxes(reliable_persons, hand_names, toy_boxes, 0.08)
|
||||
hips_near_genitals = any_pose_keypoint_near_boxes(reliable_persons, hip_names, penis_boxes + pussy_boxes, 0.08)
|
||||
|
||||
if person_count >= 2:
|
||||
for label, score in [
|
||||
("missionary", 0.04),
|
||||
("doggy", 0.04),
|
||||
("cowgirl", 0.04),
|
||||
("reverse_cowgirl", 0.04),
|
||||
("standing_doggy", 0.04),
|
||||
("spooning", 0.04),
|
||||
("69", 0.03),
|
||||
]:
|
||||
add(label, score)
|
||||
|
||||
if pair["close"]:
|
||||
for label in ["missionary", "doggy", "cowgirl", "spooning"]:
|
||||
add(label, 0.04)
|
||||
if pair["overlap"]:
|
||||
add("missionary", 0.05)
|
||||
add("cowgirl", 0.05)
|
||||
add("prone_bone", 0.04)
|
||||
if pair["horizontal"]:
|
||||
add("spooning", 0.12)
|
||||
add("prone_bone", 0.07)
|
||||
if pair["vertical"]:
|
||||
add("standing", 0.08)
|
||||
add("standing_doggy", 0.10)
|
||||
if pair["stacked"]:
|
||||
add("missionary", 0.07)
|
||||
add("cowgirl", 0.07)
|
||||
add("reverse_cowgirl", 0.06)
|
||||
add("facesitting", 0.05)
|
||||
|
||||
if has_penis and has_pussy:
|
||||
for label, score in [
|
||||
("missionary", 0.08),
|
||||
("doggy", 0.08),
|
||||
("cowgirl", 0.08),
|
||||
("reverse_cowgirl", 0.07),
|
||||
("prone_bone", 0.06),
|
||||
("standing_doggy", 0.06),
|
||||
("spooning", 0.05),
|
||||
]:
|
||||
add(label, score)
|
||||
|
||||
if any_boxes_near(penis_boxes, pussy_boxes, 0.09) or hips_near_genitals:
|
||||
add("missionary", 0.08)
|
||||
add("doggy", 0.08)
|
||||
add("cowgirl", 0.08)
|
||||
add("reverse_cowgirl", 0.07)
|
||||
|
||||
if has_penis and (head_near_penis or has_tongue):
|
||||
add("blowjob", 0.16)
|
||||
if head_near_penis:
|
||||
add("blowjob", 0.10)
|
||||
if has_pussy and (head_near_pussy or has_tongue or any_boxes_near(tongue_boxes, pussy_boxes, 0.08)):
|
||||
add("cunnilingus", 0.16)
|
||||
if head_near_pussy or any_boxes_near(tongue_boxes, pussy_boxes, 0.08):
|
||||
add("cunnilingus", 0.10)
|
||||
if has_penis and hand_near_penis:
|
||||
add("handjob", 0.18)
|
||||
if has_pussy and hand_near_pussy:
|
||||
add("fingering", 0.18)
|
||||
if has_penis and has_breasts and any_boxes_near(penis_boxes, breast_boxes, 0.10):
|
||||
add("boobjob", 0.20)
|
||||
if has_toy:
|
||||
add("toy_play", 0.12)
|
||||
if (
|
||||
hand_near_toy
|
||||
or any_boxes_near(toy_boxes, pussy_boxes, 0.10)
|
||||
or any_boxes_near(toy_boxes, penis_boxes, 0.10)
|
||||
or any_boxes_near(toy_boxes, ass_boxes, 0.10)
|
||||
):
|
||||
add("toy_play", 0.12)
|
||||
if person_count >= 2 and (head_near_ass or head_near_pussy):
|
||||
if has_ass:
|
||||
add("facesitting", 0.14)
|
||||
if has_pussy and has_penis:
|
||||
add("69", 0.10)
|
||||
if has_ass and has_pussy and pair["horizontal"]:
|
||||
add("doggy", 0.07)
|
||||
add("prone_bone", 0.07)
|
||||
|
||||
|
||||
def best_pose_scene_position(prediction: dict, persons: list[dict]) -> tuple[str, float]:
|
||||
scores: dict[str, float] = {}
|
||||
has_direct_pose_position = False
|
||||
reliable_persons = reliable_pose_persons(persons)
|
||||
|
||||
for person in reliable_persons:
|
||||
label = normalize_sex_position_label(person.get("label"))
|
||||
score = float(person.get("score") or 0.0)
|
||||
|
||||
if is_no_sex_position_label(label) or label not in POSITION_LABELS:
|
||||
continue
|
||||
|
||||
has_direct_pose_position = True
|
||||
combine_position_score(scores, label, score)
|
||||
|
||||
quality = pose_keypoint_quality(person)
|
||||
if quality > 0:
|
||||
combine_position_score(scores, label, 0.04 * quality)
|
||||
|
||||
add_pose_scene_context_scores(scores, prediction, reliable_persons)
|
||||
|
||||
best_position = ""
|
||||
best_score = 0.0
|
||||
for label, score in scores.items():
|
||||
if score > best_score:
|
||||
best_position = label
|
||||
best_score = score
|
||||
|
||||
if best_position and (has_direct_pose_position or best_score >= 0.30):
|
||||
return best_position, clamp01(best_score)
|
||||
|
||||
return "", 0.0
|
||||
|
||||
|
||||
def apply_pose_result_to_prediction(prediction: dict, result) -> dict:
|
||||
persons = pose_persons_from_result(result)
|
||||
if not persons:
|
||||
@ -552,19 +1224,7 @@ def apply_pose_result_to_prediction(prediction: dict, result) -> dict:
|
||||
|
||||
prediction["persons"] = persons
|
||||
|
||||
best_position = ""
|
||||
best_score_value = 0.0
|
||||
|
||||
for person in persons:
|
||||
label = str(person.get("label") or "").strip().lower()
|
||||
score = float(person.get("score") or 0.0)
|
||||
|
||||
if is_no_sex_position_label(label) or label not in POSITION_LABELS:
|
||||
continue
|
||||
|
||||
if score > best_score_value:
|
||||
best_position = label
|
||||
best_score_value = score
|
||||
best_position, best_score_value = best_pose_scene_position(prediction, persons)
|
||||
|
||||
if best_position:
|
||||
prediction["sexPosition"] = best_position
|
||||
|
||||
3
backend/data/pending-autostart/admin.json
Normal file
3
backend/data/pending-autostart/admin.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
@ -21,6 +22,73 @@ KEYPOINT_NAMES = [
|
||||
]
|
||||
|
||||
BASE_MODEL_NAME = "yolo26n-pose.pt"
|
||||
POSE_KEYPOINT_MIN_CONFIDENCE = 0.20
|
||||
POSE_RELIABLE_MIN_SCORE = 0.30
|
||||
POSE_RELIABLE_MIN_KEYPOINTS = 6
|
||||
POSE_RELIABLE_MIN_QUALITY = 0.45
|
||||
|
||||
|
||||
def clamp01(value):
|
||||
try:
|
||||
n = float(value)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
if not math.isfinite(n):
|
||||
return 0.0
|
||||
|
||||
return max(0.0, min(1.0, n))
|
||||
|
||||
|
||||
def is_finite01(value):
|
||||
try:
|
||||
n = float(value)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return math.isfinite(n) and 0.0 <= n <= 1.0
|
||||
|
||||
|
||||
def pose_keypoint_stats(person):
|
||||
keypoints = person.get("keypoints", []) or []
|
||||
if not keypoints:
|
||||
return 0, 0.0
|
||||
|
||||
visible = 0
|
||||
total_conf = 0.0
|
||||
|
||||
for point in keypoints:
|
||||
x = point.get("x", 0.0)
|
||||
y = point.get("y", 0.0)
|
||||
conf = float(point.get("conf") or 0.0)
|
||||
|
||||
if conf < POSE_KEYPOINT_MIN_CONFIDENCE or not is_finite01(x) or not is_finite01(y):
|
||||
continue
|
||||
|
||||
visible += 1
|
||||
total_conf += clamp01(conf)
|
||||
|
||||
if visible == 0:
|
||||
return 0, 0.0
|
||||
|
||||
coverage = clamp01(visible / max(1, len(KEYPOINT_NAMES)))
|
||||
avg_conf = clamp01(total_conf / visible)
|
||||
return visible, clamp01(coverage * 0.45 + avg_conf * 0.55)
|
||||
|
||||
|
||||
def annotate_pose_person_quality(person):
|
||||
visible, quality = pose_keypoint_stats(person)
|
||||
score = clamp01(person.get("score", 0.0))
|
||||
|
||||
person["visibleKeypoints"] = visible
|
||||
person["quality"] = quality
|
||||
person["reliable"] = (
|
||||
score >= POSE_RELIABLE_MIN_SCORE
|
||||
and visible >= POSE_RELIABLE_MIN_KEYPOINTS
|
||||
and quality >= POSE_RELIABLE_MIN_QUALITY
|
||||
)
|
||||
|
||||
return person
|
||||
|
||||
|
||||
def existing_file(path):
|
||||
@ -184,12 +252,12 @@ def main():
|
||||
"conf": kconf,
|
||||
})
|
||||
|
||||
persons.append({
|
||||
persons.append(annotate_pose_person_quality({
|
||||
"label": label,
|
||||
"score": score,
|
||||
"box": box,
|
||||
"keypoints": keypoints,
|
||||
})
|
||||
}))
|
||||
|
||||
print(json.dumps({
|
||||
"available": True,
|
||||
|
||||
@ -240,10 +240,33 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) {
|
||||
From string `json:"from,omitempty"`
|
||||
}
|
||||
|
||||
streamResults := strings.TrimSpace(r.URL.Query().Get("stream")) == "1"
|
||||
var streamEnc *json.Encoder
|
||||
var streamFlusher http.Flusher
|
||||
|
||||
if streamResults {
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
streamEnc = json.NewEncoder(w)
|
||||
streamFlusher, _ = w.(http.Flusher)
|
||||
}
|
||||
|
||||
results := make([]itemResult, 0, len(req.Files))
|
||||
deletedCount := 0
|
||||
seen := make(map[string]struct{}, len(req.Files))
|
||||
|
||||
addResult := func(result itemResult) {
|
||||
results = append(results, result)
|
||||
|
||||
if streamResults && streamEnc != nil {
|
||||
_ = streamEnc.Encode(result)
|
||||
if streamFlusher != nil {
|
||||
streamFlusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, raw := range req.Files {
|
||||
file := strings.TrimSpace(raw)
|
||||
if file == "" {
|
||||
@ -255,7 +278,7 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) {
|
||||
seen[file] = struct{}{}
|
||||
|
||||
if !isAllowedVideoExt(file) {
|
||||
results = append(results, itemResult{
|
||||
addResult(itemResult{
|
||||
File: file,
|
||||
OK: false,
|
||||
Error: "nicht erlaubt",
|
||||
@ -268,7 +291,7 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
target, from, fi, err := resolveDoneFileByName(doneAbs, file)
|
||||
if err != nil {
|
||||
results = append(results, itemResult{
|
||||
addResult(itemResult{
|
||||
File: file,
|
||||
OK: false,
|
||||
Error: "datei nicht gefunden",
|
||||
@ -276,7 +299,7 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
if fi != nil && fi.IsDir() {
|
||||
results = append(results, itemResult{
|
||||
addResult(itemResult{
|
||||
File: file,
|
||||
OK: false,
|
||||
Error: "ist ein verzeichnis",
|
||||
@ -287,7 +310,7 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err := removeWithRetry(target); err != nil {
|
||||
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
||||
results = append(results, itemResult{
|
||||
addResult(itemResult{
|
||||
File: file,
|
||||
OK: false,
|
||||
Error: "datei wird gerade verwendet",
|
||||
@ -296,7 +319,7 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, itemResult{
|
||||
addResult(itemResult{
|
||||
File: file,
|
||||
OK: false,
|
||||
Error: err.Error(),
|
||||
@ -323,7 +346,7 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
deletedCount++
|
||||
results = append(results, itemResult{
|
||||
addResult(itemResult{
|
||||
File: file,
|
||||
OK: true,
|
||||
From: from,
|
||||
@ -334,6 +357,20 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) {
|
||||
notifyDoneChanged()
|
||||
}
|
||||
|
||||
if streamResults {
|
||||
if streamEnc != nil {
|
||||
_ = streamEnc.Encode(map[string]any{
|
||||
"done": true,
|
||||
"ok": deletedCount > 0,
|
||||
"deleted": deletedCount,
|
||||
})
|
||||
if streamFlusher != nil {
|
||||
streamFlusher.Flush()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"ok": deletedCount > 0,
|
||||
|
||||
1008
backend/training.go
1008
backend/training.go
File diff suppressed because it is too large
Load Diff
@ -131,3 +131,292 @@ func TestTrainingEffectiveCorrectionClearsNegativeAnnotation(t *testing.T) {
|
||||
t.Fatalf("negative annotation has %d boxes, want 0", len(effective.Boxes))
|
||||
}
|
||||
}
|
||||
|
||||
func trainingTestPoseKeypoints(overrides map[string]TrainingKeypoint) []TrainingKeypoint {
|
||||
names := []string{
|
||||
"nose",
|
||||
"left_eye", "right_eye",
|
||||
"left_ear", "right_ear",
|
||||
"left_shoulder", "right_shoulder",
|
||||
"left_elbow", "right_elbow",
|
||||
"left_wrist", "right_wrist",
|
||||
"left_hip", "right_hip",
|
||||
"left_knee", "right_knee",
|
||||
"left_ankle", "right_ankle",
|
||||
}
|
||||
|
||||
out := make([]TrainingKeypoint, 0, len(names))
|
||||
for i, name := range names {
|
||||
point := TrainingKeypoint{
|
||||
Name: name,
|
||||
X: 0.20 + float64(i)*0.01,
|
||||
Y: 0.25 + float64(i)*0.01,
|
||||
Conf: 0.85,
|
||||
}
|
||||
|
||||
if override, ok := overrides[name]; ok {
|
||||
override.Name = name
|
||||
point = override
|
||||
}
|
||||
|
||||
out = append(out, point)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func TestTrainingApplyPoseToPredictionAggregatesMultiplePersons(t *testing.T) {
|
||||
pred := TrainingPrediction{
|
||||
SexPosition: trainingNoSexPositionLabel,
|
||||
Boxes: []TrainingBox{
|
||||
{Label: "person_female", X: 0.10, Y: 0.10, W: 0.35, H: 0.75},
|
||||
{Label: "person_male", X: 0.42, Y: 0.12, W: 0.34, H: 0.72},
|
||||
},
|
||||
}
|
||||
|
||||
pose := TrainingPosePrediction{
|
||||
Available: true,
|
||||
Persons: []TrainingPosePerson{
|
||||
{
|
||||
Label: "missionary",
|
||||
Score: 0.58,
|
||||
Box: TrainingBox{X: 0.10, Y: 0.10, W: 0.35, H: 0.75},
|
||||
Keypoints: trainingTestPoseKeypoints(nil),
|
||||
},
|
||||
{
|
||||
Label: "doggy",
|
||||
Score: 0.55,
|
||||
Box: TrainingBox{X: 0.42, Y: 0.12, W: 0.34, H: 0.72},
|
||||
Keypoints: trainingTestPoseKeypoints(nil),
|
||||
},
|
||||
{
|
||||
Label: "doggy",
|
||||
Score: 0.54,
|
||||
Box: TrainingBox{X: 0.52, Y: 0.14, W: 0.30, H: 0.70},
|
||||
Keypoints: trainingTestPoseKeypoints(nil),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := trainingApplyPoseToPrediction(pred, pose)
|
||||
if got.SexPosition != "doggy" {
|
||||
t.Fatalf("sex position = %q, want doggy", got.SexPosition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrainingApplyPoseToPredictionUsesObjectPositionContext(t *testing.T) {
|
||||
pred := TrainingPrediction{
|
||||
SexPosition: trainingNoSexPositionLabel,
|
||||
Boxes: []TrainingBox{
|
||||
{Label: "person_female", X: 0.08, Y: 0.08, W: 0.42, H: 0.78},
|
||||
{Label: "person_male", X: 0.42, Y: 0.12, W: 0.35, H: 0.76},
|
||||
{Label: "penis", X: 0.50, Y: 0.48, W: 0.07, H: 0.08},
|
||||
},
|
||||
}
|
||||
|
||||
pose := TrainingPosePrediction{
|
||||
Available: true,
|
||||
Persons: []TrainingPosePerson{
|
||||
{
|
||||
Label: "missionary",
|
||||
Score: 0.58,
|
||||
Box: TrainingBox{X: 0.08, Y: 0.08, W: 0.42, H: 0.78},
|
||||
Keypoints: trainingTestPoseKeypoints(map[string]TrainingKeypoint{
|
||||
"nose": {X: 0.18, Y: 0.18, Conf: 0.90},
|
||||
}),
|
||||
},
|
||||
{
|
||||
Label: "blowjob",
|
||||
Score: 0.55,
|
||||
Box: TrainingBox{X: 0.42, Y: 0.12, W: 0.35, H: 0.76},
|
||||
Keypoints: trainingTestPoseKeypoints(map[string]TrainingKeypoint{
|
||||
"nose": {X: 0.53, Y: 0.52, Conf: 0.95},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := trainingApplyPoseToPrediction(pred, pose)
|
||||
if got.SexPosition != "blowjob" {
|
||||
t.Fatalf("sex position = %q, want blowjob", got.SexPosition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrainingApplyPoseToPredictionUsesOcclusionTolerantCowgirlGeometry(t *testing.T) {
|
||||
pred := TrainingPrediction{
|
||||
SexPosition: trainingNoSexPositionLabel,
|
||||
Boxes: []TrainingBox{
|
||||
{Label: "person_female", X: 0.30, Y: 0.10, W: 0.28, H: 0.62},
|
||||
{Label: "person_male", X: 0.20, Y: 0.42, W: 0.55, H: 0.24},
|
||||
},
|
||||
}
|
||||
|
||||
pose := TrainingPosePrediction{
|
||||
Available: true,
|
||||
Persons: []TrainingPosePerson{
|
||||
{
|
||||
Label: "person",
|
||||
Score: 0.72,
|
||||
Box: TrainingBox{X: 0.30, Y: 0.10, W: 0.28, H: 0.62},
|
||||
Keypoints: trainingTestPoseKeypoints(map[string]TrainingKeypoint{
|
||||
"left_shoulder": {X: 0.40, Y: 0.22, Conf: 0.92},
|
||||
"right_shoulder": {X: 0.48, Y: 0.22, Conf: 0.92},
|
||||
"left_hip": {X: 0.38, Y: 0.38, Conf: 0.92},
|
||||
"right_hip": {X: 0.50, Y: 0.38, Conf: 0.92},
|
||||
"left_knee": {X: 0.31, Y: 0.58, Conf: 0.90},
|
||||
"right_knee": {X: 0.57, Y: 0.58, Conf: 0.90},
|
||||
}),
|
||||
},
|
||||
{
|
||||
Label: "person",
|
||||
Score: 0.70,
|
||||
Box: TrainingBox{X: 0.20, Y: 0.42, W: 0.55, H: 0.24},
|
||||
Keypoints: trainingTestPoseKeypoints(map[string]TrainingKeypoint{
|
||||
"left_shoulder": {X: 0.25, Y: 0.50, Conf: 0.90},
|
||||
"right_shoulder": {X: 0.38, Y: 0.50, Conf: 0.90},
|
||||
"left_hip": {X: 0.52, Y: 0.53, Conf: 0.90},
|
||||
"right_hip": {X: 0.66, Y: 0.53, Conf: 0.90},
|
||||
"left_knee": {X: 0.58, Y: 0.56, Conf: 0.88},
|
||||
"right_knee": {X: 0.70, Y: 0.56, Conf: 0.88},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := trainingApplyPoseToPrediction(pred, pose)
|
||||
if got.SexPosition != "cowgirl" {
|
||||
t.Fatalf("sex position = %q, want cowgirl", got.SexPosition)
|
||||
}
|
||||
if got.SexPositionScore < 0.30 {
|
||||
t.Fatalf("sex position score = %.3f, want >= 0.30", got.SexPositionScore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrainingApplyPoseToPredictionKeepsUnreliablePoseOutOfContext(t *testing.T) {
|
||||
pred := TrainingPrediction{
|
||||
SexPosition: trainingNoSexPositionLabel,
|
||||
Boxes: []TrainingBox{
|
||||
{Label: "person_female", X: 0.08, Y: 0.08, W: 0.42, H: 0.78},
|
||||
{Label: "penis", X: 0.50, Y: 0.48, W: 0.07, H: 0.08},
|
||||
},
|
||||
}
|
||||
|
||||
pose := TrainingPosePrediction{
|
||||
Available: true,
|
||||
Persons: []TrainingPosePerson{
|
||||
{
|
||||
Label: "blowjob",
|
||||
Score: 0.24,
|
||||
Box: TrainingBox{X: 0.08, Y: 0.08, W: 0.42, H: 0.78},
|
||||
Keypoints: trainingTestPoseKeypoints(map[string]TrainingKeypoint{
|
||||
"nose": {X: 0.53, Y: 0.52, Conf: 0.95},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := trainingApplyPoseToPrediction(pred, pose)
|
||||
if got.SexPosition != trainingNoSexPositionLabel {
|
||||
t.Fatalf("sex position = %q, want %s", got.SexPosition, trainingNoSexPositionLabel)
|
||||
}
|
||||
if len(got.Persons) != 1 {
|
||||
t.Fatalf("persons = %d, want 1", len(got.Persons))
|
||||
}
|
||||
if got.Persons[0].Reliable {
|
||||
t.Fatalf("low-score pose should be marked unreliable: %+v", got.Persons[0])
|
||||
}
|
||||
if got.Persons[0].VisibleKeypoints == 0 || got.Persons[0].Quality == 0 {
|
||||
t.Fatalf("pose quality should be annotated: %+v", got.Persons[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrainingApplyPoseToPredictionUsesBoxContextWithoutPose(t *testing.T) {
|
||||
pred := TrainingPrediction{
|
||||
ModelAvailable: true,
|
||||
Source: "yolo26_detector",
|
||||
SexPosition: trainingNoSexPositionLabel,
|
||||
Boxes: []TrainingBox{
|
||||
{Label: "person_female", X: 0.18, Y: 0.12, W: 0.42, H: 0.72},
|
||||
{Label: "person_male", X: 0.42, Y: 0.18, W: 0.38, H: 0.68},
|
||||
{Label: "penis", X: 0.48, Y: 0.50, W: 0.08, H: 0.08},
|
||||
{Label: "pussy", X: 0.54, Y: 0.51, W: 0.08, H: 0.08},
|
||||
},
|
||||
}
|
||||
|
||||
got := trainingApplyPoseToPrediction(pred, TrainingPosePrediction{Available: false})
|
||||
if got.SexPosition == trainingNoSexPositionLabel {
|
||||
t.Fatalf("sex position = %q, want uncertain context prediction", got.SexPosition)
|
||||
}
|
||||
if got.SexPositionScore < trainingPositionContextMinScore {
|
||||
t.Fatalf("sex position score = %.3f, want >= %.3f", got.SexPositionScore, trainingPositionContextMinScore)
|
||||
}
|
||||
if got.SexPositionScore > trainingPositionContextMaxScore {
|
||||
t.Fatalf("sex position score = %.3f, want <= %.3f", got.SexPositionScore, trainingPositionContextMaxScore)
|
||||
}
|
||||
if !strings.Contains(got.Source, "box_context") {
|
||||
t.Fatalf("source = %q, want box_context", got.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrainingApplyPoseToPredictionBoostsPoseWithBoxContext(t *testing.T) {
|
||||
pred := TrainingPrediction{
|
||||
ModelAvailable: true,
|
||||
Source: "yolo26_detector",
|
||||
SexPosition: trainingNoSexPositionLabel,
|
||||
Boxes: []TrainingBox{
|
||||
{Label: "penis", X: 0.48, Y: 0.50, W: 0.08, H: 0.08},
|
||||
{Label: "pussy", X: 0.54, Y: 0.51, W: 0.08, H: 0.08},
|
||||
},
|
||||
}
|
||||
|
||||
pose := TrainingPosePrediction{
|
||||
Available: true,
|
||||
Persons: []TrainingPosePerson{
|
||||
{
|
||||
Label: "doggy",
|
||||
Score: 0.31,
|
||||
Box: TrainingBox{X: 0.20, Y: 0.12, W: 0.60, H: 0.76},
|
||||
Keypoints: trainingTestPoseKeypoints(nil),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := trainingApplyPoseToPrediction(pred, pose)
|
||||
if got.SexPosition != "doggy" {
|
||||
t.Fatalf("sex position = %q, want doggy", got.SexPosition)
|
||||
}
|
||||
if got.SexPositionScore <= 0.31 {
|
||||
t.Fatalf("sex position score = %.3f, want context boost over 0.31", got.SexPositionScore)
|
||||
}
|
||||
if !strings.Contains(got.Source, "yolo_pose") || !strings.Contains(got.Source, "box_context") {
|
||||
t.Fatalf("source = %q, want yolo_pose and box_context", got.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrainingFilterPosePersonsByContextDropsUnannotatedPeople(t *testing.T) {
|
||||
persons := []TrainingPosePerson{
|
||||
{
|
||||
Label: "doggy",
|
||||
Score: 0.80,
|
||||
Box: TrainingBox{X: 0.10, Y: 0.10, W: 0.30, H: 0.70},
|
||||
Keypoints: trainingTestPoseKeypoints(nil),
|
||||
},
|
||||
{
|
||||
Label: "doggy",
|
||||
Score: 0.70,
|
||||
Box: TrainingBox{X: 0.70, Y: 0.10, W: 0.20, H: 0.70},
|
||||
Keypoints: trainingTestPoseKeypoints(nil),
|
||||
},
|
||||
}
|
||||
|
||||
filtered := trainingFilterPosePersonsByContext(persons, []TrainingBox{
|
||||
{Label: "person_female", X: 0.08, Y: 0.08, W: 0.34, H: 0.74},
|
||||
})
|
||||
|
||||
if len(filtered) != 1 {
|
||||
t.Fatalf("filtered persons = %d, want 1", len(filtered))
|
||||
}
|
||||
if filtered[0].Box.X != persons[0].Box.X {
|
||||
t.Fatalf("kept wrong person: %+v", filtered[0].Box)
|
||||
}
|
||||
}
|
||||
|
||||
@ -714,7 +714,28 @@ export default function App() {
|
||||
const [splitModalKey, setSplitModalKey] = useState(0)
|
||||
|
||||
const [assetNonce, setAssetNonce] = useState(0)
|
||||
const [assetNonceByFile, setAssetNonceByFile] = useState<Record<string, number>>({})
|
||||
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)
|
||||
|
||||
@ -1091,6 +1112,51 @@ export default function App() {
|
||||
loadDonePageRef.current = 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(() => {
|
||||
try {
|
||||
window.localStorage.setItem(INCLUDE_KEEP_KEY, includeKeep ? '1' : '0')
|
||||
@ -2821,8 +2887,7 @@ export default function App() {
|
||||
void loadTaskStateOnce()
|
||||
|
||||
if (selectedTabRef.current === 'finished') {
|
||||
donePrefetchRef.current = null
|
||||
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
|
||||
scheduleDoneRefresh(500)
|
||||
}
|
||||
}, document.hidden ? 60000 : 5000)
|
||||
}
|
||||
@ -2848,12 +2913,12 @@ export default function App() {
|
||||
}
|
||||
|
||||
const onDoneChanged = () => {
|
||||
void loadDoneCount()
|
||||
|
||||
if (selectedTabRef.current === 'finished') {
|
||||
donePrefetchRef.current = null
|
||||
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
|
||||
scheduleDoneRefresh(900)
|
||||
return
|
||||
}
|
||||
|
||||
void loadDoneCount()
|
||||
}
|
||||
|
||||
const onAutostart = (ev: MessageEvent) => {
|
||||
@ -2898,7 +2963,7 @@ export default function App() {
|
||||
|
||||
// ✅ Wenn Assets fertig / fehlerhaft / entfernt sind, Preview-/Sprite-Cache neu ziehen
|
||||
if (state === 'done' || state === 'error' || state === 'missing') {
|
||||
bumpAssets()
|
||||
bumpAssetForFile(file)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@ -2975,7 +3040,7 @@ export default function App() {
|
||||
)
|
||||
|
||||
if (state === 'done' || state === 'error') {
|
||||
bumpAssets()
|
||||
bumpAssetForFile(file)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@ -3132,6 +3197,8 @@ export default function App() {
|
||||
loadPendingAutoStarts,
|
||||
applyAutostartState,
|
||||
loadAutostartState,
|
||||
bumpAssetForFile,
|
||||
scheduleDoneRefresh,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
@ -3430,7 +3497,7 @@ export default function App() {
|
||||
// Einheitliche Start-Event-Phase (delete/keep animieren dieselbe Row)
|
||||
if (kind === 'delete' || kind === 'keep') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const } })
|
||||
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const, kind } })
|
||||
)
|
||||
}
|
||||
|
||||
@ -3439,7 +3506,7 @@ export default function App() {
|
||||
|
||||
if (kind === 'delete' || kind === 'keep') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } })
|
||||
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const, kind } })
|
||||
)
|
||||
}
|
||||
|
||||
@ -3448,11 +3515,21 @@ export default function App() {
|
||||
} catch (e) {
|
||||
if (kind === 'delete' || kind === 'keep') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } })
|
||||
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const, kind } })
|
||||
)
|
||||
}
|
||||
|
||||
await onError?.(e)
|
||||
if (e && typeof e === 'object') {
|
||||
try {
|
||||
Object.defineProperty(e, '__finishedDownloadsNotified', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
})
|
||||
} catch {
|
||||
;(e as any).__finishedDownloadsNotified = true
|
||||
}
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
@ -3484,8 +3561,7 @@ export default function App() {
|
||||
|
||||
donePrefetchRef.current = null
|
||||
|
||||
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
|
||||
void loadDoneCount()
|
||||
scheduleDoneRefresh(900)
|
||||
}, 320)
|
||||
},
|
||||
onError: async () => {
|
||||
@ -3497,14 +3573,14 @@ export default function App() {
|
||||
const from = typeof data?.from === 'string' ? data.from : undefined
|
||||
|
||||
return undoToken ? { undoToken, from } : {}
|
||||
} catch {
|
||||
return
|
||||
} catch (e) {
|
||||
throw e
|
||||
}
|
||||
},
|
||||
[
|
||||
runFinishedFileAction,
|
||||
notify,
|
||||
loadDoneCount,
|
||||
scheduleDoneRefresh,
|
||||
]
|
||||
)
|
||||
|
||||
@ -3529,8 +3605,7 @@ export default function App() {
|
||||
|
||||
donePrefetchRef.current = null
|
||||
|
||||
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
|
||||
void loadDoneCount()
|
||||
scheduleDoneRefresh(900)
|
||||
}, 320)
|
||||
},
|
||||
onError: async () => {
|
||||
@ -3539,11 +3614,11 @@ export default function App() {
|
||||
})
|
||||
|
||||
return data
|
||||
} catch {
|
||||
return
|
||||
} catch (e) {
|
||||
throw e
|
||||
}
|
||||
},
|
||||
[runFinishedFileAction, notify, loadDoneCount]
|
||||
[runFinishedFileAction, notify, scheduleDoneRefresh]
|
||||
)
|
||||
|
||||
const handleToggleHot = useCallback(
|
||||
@ -4328,6 +4403,7 @@ export default function App() {
|
||||
teaserAudio={Boolean(recSettings.teaserAudio)}
|
||||
pauseTeasers={Boolean(detailsModelKey) || splitModalOpen || Boolean(playerJob)}
|
||||
assetNonce={assetNonce}
|
||||
assetNonceByFile={assetNonceByFile}
|
||||
sortMode={doneSort}
|
||||
onSortModeChange={(m) => {
|
||||
setDoneSort(m)
|
||||
|
||||
@ -1,14 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { CheckIcon } from '@heroicons/react/24/solid'
|
||||
import LoadingSpinner from './LoadingSpinner'
|
||||
|
||||
export default function DeletingPreviewOverlay({
|
||||
label = 'Wird gelöscht',
|
||||
done = false,
|
||||
label,
|
||||
className = 'rounded-t-lg',
|
||||
compact = false,
|
||||
}: {
|
||||
done?: boolean
|
||||
label?: string
|
||||
className?: string
|
||||
compact?: boolean
|
||||
}) {
|
||||
const displayLabel = label ?? (done ? 'Gelöscht' : 'Wird gelöscht')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
@ -16,19 +23,35 @@ export default function DeletingPreviewOverlay({
|
||||
className,
|
||||
].join(' ')}
|
||||
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">
|
||||
<LoadingSpinner
|
||||
size="md"
|
||||
className="text-white"
|
||||
srLabel={label}
|
||||
/>
|
||||
<div
|
||||
className={[
|
||||
'flex flex-col items-center rounded-xl bg-black/35 shadow-lg ring-1 ring-white/15',
|
||||
compact ? 'gap-1 px-2 py-1.5' : 'gap-2 px-4 py-3',
|
||||
].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">
|
||||
{label}
|
||||
<div className={['font-semibold tracking-wide text-white/95', compact ? 'text-[10px]' : 'text-xs'].join(' ')}>
|
||||
{displayLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -53,6 +53,7 @@ import PreviewRatingOverlay from './PreviewRatingOverlay'
|
||||
import PreviewMetaOverlay from './PreviewMetaOverlay'
|
||||
import DeletingPreviewOverlay from './DeletingPreviewOverlay'
|
||||
import { autoTagsForJob, mergeTags } from './autoTags'
|
||||
import { useFinishedDownloadsAutoGridPageSize } from './useFinishedDownloadsAutoPageSize'
|
||||
|
||||
|
||||
type SwipeRefs = {
|
||||
@ -75,12 +76,14 @@ type Props = {
|
||||
teaserState: FinishedDownloadsTeaserState
|
||||
|
||||
deletingKeys: Set<string>
|
||||
deletedSuccessKeys: Set<string>
|
||||
keepingKeys: Set<string>
|
||||
removingKeys: Set<string>
|
||||
|
||||
swipeRefs: SwipeRefs
|
||||
|
||||
assetNonce?: number
|
||||
assetNonceForJob?: (job: RecordJob) => number
|
||||
|
||||
jobForDetails: (job: RecordJob) => RecordJob
|
||||
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
|
||||
@ -108,7 +111,10 @@ type Props = {
|
||||
handleResolution: (job: RecordJob, w: number, h: number) => void
|
||||
resolutionLabelOf: (job: RecordJob) => string
|
||||
|
||||
deleteVideo: (job: RecordJob) => Promise<boolean>
|
||||
deleteVideo: (
|
||||
job: RecordJob,
|
||||
opts?: { alreadyRemoved?: boolean; optimisticRemove?: boolean }
|
||||
) => Promise<boolean>
|
||||
keepVideo: (job: RecordJob) => Promise<boolean>
|
||||
|
||||
releasePlayingFile: (file: string, opts?: { close?: boolean }) => Promise<void>
|
||||
@ -136,6 +142,8 @@ type Props = {
|
||||
onUnlockMobileTeaserAudio?: () => void
|
||||
renderIntegrityBadge?: (job: RecordJob) => ReactNode
|
||||
renderRatingOverlay?: (job: RecordJob) => ReactNode
|
||||
onRecommendedPageSizeChange?: (size: number) => void
|
||||
suspendAutoPageSize?: boolean
|
||||
}
|
||||
|
||||
function CardBlurWrapper({
|
||||
@ -330,11 +338,13 @@ function FinishedDownloadsCardsView({
|
||||
teaserState,
|
||||
inlinePlay,
|
||||
deletingKeys,
|
||||
deletedSuccessKeys,
|
||||
keepingKeys,
|
||||
removingKeys,
|
||||
|
||||
swipeRefs,
|
||||
assetNonce,
|
||||
assetNonceForJob,
|
||||
jobForDetails,
|
||||
handleScrubberClickIndex,
|
||||
|
||||
@ -378,8 +388,29 @@ function FinishedDownloadsCardsView({
|
||||
onUnlockMobileTeaserAudio,
|
||||
renderIntegrityBadge,
|
||||
renderRatingOverlay,
|
||||
onRecommendedPageSizeChange,
|
||||
suspendAutoPageSize,
|
||||
}: Props) {
|
||||
|
||||
const rootRef = useRef<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 teaserPlayFnsRef = useRef<Record<string, (() => void) | null>>({})
|
||||
@ -451,6 +482,7 @@ function FinishedDownloadsCardsView({
|
||||
}
|
||||
) => {
|
||||
const k = keyFor(j)
|
||||
const rowAssetNonce = assetNonceForJob?.(j) ?? assetNonce ?? 0
|
||||
|
||||
const handleSelectOrOpen = () => {
|
||||
if (selectionModeActive) {
|
||||
@ -497,7 +529,8 @@ function FinishedDownloadsCardsView({
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
||||
const deleteDone = deletedSuccessKeys.has(k)
|
||||
const busy = deletingKeys.has(k) || deleteDone || keepingKeys.has(k) || removingKeys.has(k)
|
||||
|
||||
const model = modelNameFromOutput(j.output)
|
||||
const fileRaw = baseName(j.output || '')
|
||||
@ -516,7 +549,7 @@ function FinishedDownloadsCardsView({
|
||||
const sprite = readPreviewSpriteInfo((j as any)?.meta, {
|
||||
fallbackPath: flags?.previewScrubberPath,
|
||||
fallbackCount: flags?.previewScrubberCount,
|
||||
versionFallback: assetNonce ?? 0,
|
||||
versionFallback: rowAssetNonce,
|
||||
})
|
||||
|
||||
const spriteUrl = sprite.url
|
||||
@ -603,6 +636,7 @@ function FinishedDownloadsCardsView({
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
|
||||
busy && 'pointer-events-none opacity-70',
|
||||
deletingKeys.has(k) && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
|
||||
deleteDone && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||||
keepingKeys.has(k) && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||||
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
|
||||
]
|
||||
@ -737,7 +771,7 @@ function FinishedDownloadsCardsView({
|
||||
inlineLoop={false}
|
||||
muted={previewMuted}
|
||||
popoverMuted={previewMuted}
|
||||
assetNonce={assetNonce ?? 0}
|
||||
assetNonce={rowAssetNonce}
|
||||
alwaysLoadStill={forceLoadStill}
|
||||
teaserPreloadEnabled={teaserPreloadEnabled}
|
||||
teaserPreloadRootMargin={
|
||||
@ -854,8 +888,8 @@ function FinishedDownloadsCardsView({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{deletingKeys.has(k) ? (
|
||||
<DeletingPreviewOverlay />
|
||||
{deletingKeys.has(k) || deleteDone ? (
|
||||
<DeletingPreviewOverlay done={deleteDone} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@ -1167,9 +1201,14 @@ function FinishedDownloadsCardsView({
|
||||
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div ref={rootRef} className="relative">
|
||||
{!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) => {
|
||||
const k = keyFor(j)
|
||||
|
||||
@ -1180,6 +1219,7 @@ function FinishedDownloadsCardsView({
|
||||
selectionStore={selectionStore}
|
||||
selectionModeActive={selectionModeActive}
|
||||
isDeleting={deletingKeys.has(k)}
|
||||
isDeleted={deletedSuccessKeys.has(k)}
|
||||
isKeeping={keepingKeys.has(k)}
|
||||
isRemoving={removingKeys.has(k)}
|
||||
postworkBadge={getPostworkSummaryForOutput(j.output, postworkByFile)}
|
||||
@ -1187,7 +1227,7 @@ function FinishedDownloadsCardsView({
|
||||
durationSeconds={durations[k]}
|
||||
teaserState={teaserState}
|
||||
inlinePlay={inlinePlay}
|
||||
assetNonce={assetNonce}
|
||||
assetNonce={assetNonceForJob?.(j) ?? assetNonce}
|
||||
forcePreviewMuted={forcePreviewMuted}
|
||||
keyFor={keyFor}
|
||||
baseName={baseName}
|
||||
@ -1390,10 +1430,11 @@ function FinishedDownloadsCardsView({
|
||||
}
|
||||
})
|
||||
}}
|
||||
onSwipeLeft={async () => {
|
||||
onSwipeLeft={() => {
|
||||
lockMobileStackHeight()
|
||||
setMobileTopTeaserEnabled(false)
|
||||
return await deleteVideo(j)
|
||||
void deleteVideo(j, { optimisticRemove: true })
|
||||
return true
|
||||
}}
|
||||
onSwipeRight={async () => {
|
||||
lockMobileStackHeight()
|
||||
|
||||
@ -41,6 +41,7 @@ import {
|
||||
} from './videoHeatmap'
|
||||
import PreviewRatingOverlay from './PreviewRatingOverlay'
|
||||
import PreviewMetaOverlay from './PreviewMetaOverlay'
|
||||
import DeletingPreviewOverlay from './DeletingPreviewOverlay'
|
||||
import { autoTagsForJob, mergeTags } from './autoTags'
|
||||
|
||||
export type FinishedDownloadsDesktopCardProps = {
|
||||
@ -48,6 +49,7 @@ export type FinishedDownloadsDesktopCardProps = {
|
||||
selectionStore: FinishedSelectionStore
|
||||
selectionModeActive: boolean
|
||||
isDeleting: boolean
|
||||
isDeleted: boolean
|
||||
isKeeping: boolean
|
||||
isRemoving: boolean
|
||||
postworkBadge?: FinishedPostworkSummary
|
||||
@ -140,6 +142,7 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
selectionStore,
|
||||
selectionModeActive,
|
||||
isDeleting,
|
||||
isDeleted,
|
||||
isKeeping,
|
||||
isRemoving,
|
||||
postworkBadge,
|
||||
@ -183,7 +186,7 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
renderRatingOverlay,
|
||||
}: FinishedDownloadsDesktopCardProps) {
|
||||
const k = keyFor(j)
|
||||
const busy = isDeleting || isKeeping || isRemoving
|
||||
const busy = isDeleting || isDeleted || isKeeping || isRemoving
|
||||
const checked = useSelectionChecked(selectionStore, k)
|
||||
const longPressTimerRef = useRef<number | null>(null)
|
||||
const longPressTriggeredRef = useRef(false)
|
||||
@ -334,6 +337,7 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
checked && 'ring-2 ring-indigo-500 bg-indigo-50/60 dark:bg-indigo-500/10 dark:ring-indigo-400',
|
||||
busy && 'pointer-events-none opacity-70',
|
||||
isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
|
||||
isDeleted && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||||
isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||||
isRemoving && 'opacity-0 translate-y-2 scale-[0.98]',
|
||||
].filter(Boolean).join(' ')}
|
||||
@ -487,6 +491,10 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isDeleting || isDeleted ? (
|
||||
<DeletingPreviewOverlay done={isDeleted} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -606,6 +614,7 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
return (
|
||||
prev.job === next.job &&
|
||||
prev.isDeleting === next.isDeleting &&
|
||||
prev.isDeleted === next.isDeleted &&
|
||||
prev.isKeeping === next.isKeeping &&
|
||||
prev.isRemoving === next.isRemoving &&
|
||||
samePostworkSummaryLite(prev.postworkBadge, next.postworkBadge) &&
|
||||
|
||||
@ -74,6 +74,7 @@ type Props = {
|
||||
activeTagSet: Set<string>
|
||||
|
||||
deletingKeys: Set<string>
|
||||
deletedSuccessKeys: Set<string>
|
||||
keepingKeys: Set<string>
|
||||
removingKeys: Set<string>
|
||||
|
||||
@ -132,6 +133,7 @@ function FinishedDownloadsGalleryCardInner({
|
||||
modelsByKey,
|
||||
activeTagSet,
|
||||
deletingKeys,
|
||||
deletedSuccessKeys,
|
||||
keepingKeys,
|
||||
removingKeys,
|
||||
registerTeaserHost,
|
||||
@ -304,8 +306,9 @@ function FinishedDownloadsGalleryCardInner({
|
||||
|
||||
const isRemoving = removingKeys.has(k)
|
||||
const isDeleting = deletingKeys.has(k)
|
||||
const isDeleted = deletedSuccessKeys.has(k)
|
||||
const isKeeping = keepingKeys.has(k)
|
||||
const busy = isDeleting || isKeeping || isRemoving
|
||||
const busy = isDeleting || isDeleted || isKeeping || isRemoving
|
||||
|
||||
const sprite = useMemo(
|
||||
() =>
|
||||
@ -404,6 +407,7 @@ function FinishedDownloadsGalleryCardInner({
|
||||
checked && 'ring-2 ring-indigo-500 bg-indigo-50/60 dark:bg-indigo-500/10 dark:ring-indigo-400',
|
||||
busy && !isRemoving && 'pointer-events-none opacity-70',
|
||||
isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
|
||||
isDeleted && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||||
isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||||
isRemoving ? 'pointer-events-none opacity-0 translate-y-2 scale-[0.98]' : 'opacity-100 translate-y-0 scale-100',
|
||||
].filter(Boolean).join(' ')}
|
||||
@ -559,8 +563,8 @@ function FinishedDownloadsGalleryCardInner({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isDeleting ? (
|
||||
<DeletingPreviewOverlay />
|
||||
{isDeleting || isDeleted ? (
|
||||
<DeletingPreviewOverlay done={isDeleted} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@ -676,4 +680,4 @@ function FinishedDownloadsGalleryCardInner({
|
||||
}
|
||||
|
||||
const FinishedDownloadsGalleryCard = memo(FinishedDownloadsGalleryCardInner)
|
||||
export default FinishedDownloadsGalleryCard
|
||||
export default FinishedDownloadsGalleryCard
|
||||
|
||||
@ -46,6 +46,7 @@ type Props = {
|
||||
formatBytes: (bytes?: number | null) => string
|
||||
|
||||
deletingKeys: Set<string>
|
||||
deletedSuccessKeys: Set<string>
|
||||
keepingKeys: Set<string>
|
||||
removingKeys: Set<string>
|
||||
|
||||
@ -68,11 +69,13 @@ type Props = {
|
||||
onSplit?: (job: RecordJob) => void | Promise<void>
|
||||
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
||||
onRecommendedPageSizeChange?: (size: number) => void
|
||||
suspendAutoPageSize?: boolean
|
||||
|
||||
forcePreviewMuted?: boolean
|
||||
mobileTeaserAudioUnlocked?: boolean
|
||||
onUnlockMobileTeaserAudio?: () => void
|
||||
assetNonce?: number
|
||||
assetNonceForJob?: (job: RecordJob) => number
|
||||
renderIntegrityBadge?: (job: RecordJob) => ReactNode
|
||||
renderRatingOverlay?: (job: RecordJob) => ReactNode
|
||||
}
|
||||
@ -114,6 +117,7 @@ function FinishedDownloadsGalleryView({
|
||||
sizeBytesOf,
|
||||
formatBytes,
|
||||
deletingKeys,
|
||||
deletedSuccessKeys,
|
||||
keepingKeys,
|
||||
removingKeys,
|
||||
registerTeaserHost,
|
||||
@ -136,8 +140,10 @@ function FinishedDownloadsGalleryView({
|
||||
mobileTeaserAudioUnlocked,
|
||||
onUnlockMobileTeaserAudio,
|
||||
assetNonce,
|
||||
assetNonceForJob,
|
||||
renderIntegrityBadge,
|
||||
renderRatingOverlay,
|
||||
suspendAutoPageSize,
|
||||
}: Props) {
|
||||
const observeTeasers = shouldObserveTeasers(teaserState.mode)
|
||||
|
||||
@ -181,7 +187,7 @@ function FinishedDownloadsGalleryView({
|
||||
const rafRef = useRef<number | null>(null)
|
||||
|
||||
const recommendPageSize = useCallback(() => {
|
||||
if (!onRecommendedPageSizeChange) return
|
||||
if (suspendAutoPageSize || !onRecommendedPageSizeChange) return
|
||||
|
||||
const el = rootRef.current
|
||||
if (!el) return
|
||||
@ -197,8 +203,40 @@ function FinishedDownloadsGalleryView({
|
||||
Math.floor((containerWidth + gapPx) / (minCardWidth + gapPx))
|
||||
)
|
||||
|
||||
const rowsPerPage = 2
|
||||
const nextPageSize = clamp(columns * rowsPerPage, rowsPerPage, 24)
|
||||
const grid = el.firstElementChild
|
||||
const itemEls =
|
||||
grid instanceof HTMLElement
|
||||
? Array.from(grid.children).filter((child): child is HTMLElement => child instanceof HTMLElement)
|
||||
: []
|
||||
const measuredCardHeight = itemEls.reduce((maxHeight, child) => {
|
||||
const height = child.getBoundingClientRect().height
|
||||
return Number.isFinite(height) && height > maxHeight ? height : maxHeight
|
||||
}, 0)
|
||||
const estimatedCardWidth =
|
||||
columns > 0
|
||||
? (containerWidth - gapPx * (columns - 1)) / columns
|
||||
: minCardWidth
|
||||
const fallbackFooterHeight =
|
||||
sizePreset === 'xl'
|
||||
? 132
|
||||
: sizePreset === 'lg'
|
||||
? 124
|
||||
: sizePreset === 'md'
|
||||
? 118
|
||||
: 110
|
||||
const cardHeight = Math.max(
|
||||
1,
|
||||
measuredCardHeight || Math.round(estimatedCardWidth * 9 / 16 + fallbackFooterHeight + 2)
|
||||
)
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0
|
||||
const top = Math.max(0, el.getBoundingClientRect().top)
|
||||
const bottomReservePx = 112
|
||||
const availableHeight = Math.max(0, viewportHeight - top - bottomReservePx)
|
||||
const rowsPerPage = Math.max(
|
||||
1,
|
||||
Math.floor((availableHeight + gapPx) / (cardHeight + gapPx))
|
||||
)
|
||||
const nextPageSize = clamp(columns * rowsPerPage, columns, 120)
|
||||
const currentPageSize = lastRecommendedPageSizeRef.current
|
||||
|
||||
if (currentPageSize === nextPageSize) return
|
||||
@ -233,7 +271,7 @@ function FinishedDownloadsGalleryView({
|
||||
Math.floor((stableWidth + gapPx) / (minCardWidth + gapPx))
|
||||
)
|
||||
|
||||
const stablePageSize = clamp(stableColumns * rowsPerPage, rowsPerPage, 24)
|
||||
const stablePageSize = clamp(stableColumns * rowsPerPage, stableColumns, 120)
|
||||
|
||||
if (stablePageSize >= (lastRecommendedPageSizeRef.current ?? 0)) {
|
||||
return
|
||||
@ -252,9 +290,11 @@ function FinishedDownloadsGalleryView({
|
||||
rafRef.current = null
|
||||
measureAndApply()
|
||||
})
|
||||
}, [minCardWidth, onRecommendedPageSizeChange])
|
||||
}, [minCardWidth, onRecommendedPageSizeChange, suspendAutoPageSize])
|
||||
|
||||
useEffect(() => {
|
||||
if (suspendAutoPageSize) return
|
||||
|
||||
recommendPageSize()
|
||||
|
||||
const el = rootRef.current
|
||||
@ -281,7 +321,7 @@ function FinishedDownloadsGalleryView({
|
||||
rafRef.current = null
|
||||
}
|
||||
}
|
||||
}, [recommendPageSize])
|
||||
}, [recommendPageSize, suspendAutoPageSize])
|
||||
|
||||
const contentPaddingClass =
|
||||
sizePreset === 'xl'
|
||||
@ -356,6 +396,7 @@ function FinishedDownloadsGalleryView({
|
||||
modelsByKey={modelsByKey}
|
||||
activeTagSet={activeTagSet}
|
||||
deletingKeys={deletingKeys}
|
||||
deletedSuccessKeys={deletedSuccessKeys}
|
||||
keepingKeys={keepingKeys}
|
||||
removingKeys={removingKeys}
|
||||
registerTeaserHost={registerTeaserHostIfNeeded}
|
||||
@ -381,7 +422,7 @@ function FinishedDownloadsGalleryView({
|
||||
footerMinHeightClass={footerMinHeightClass}
|
||||
titleClass={titleClass}
|
||||
sublineClass={sublineClass}
|
||||
assetNonce={assetNonce ?? 0}
|
||||
assetNonce={assetNonceForJob?.(j) ?? assetNonce ?? 0}
|
||||
renderIntegrityBadge={renderIntegrityBadge}
|
||||
renderRatingOverlay={renderRatingOverlay}
|
||||
mobileTeaserAudioUnlocked={mobileTeaserAudioUnlocked}
|
||||
@ -405,4 +446,4 @@ function FinishedDownloadsGalleryView({
|
||||
}
|
||||
|
||||
const MemoFinishedDownloadsGalleryView = memo(FinishedDownloadsGalleryView)
|
||||
export default MemoFinishedDownloadsGalleryView
|
||||
export default MemoFinishedDownloadsGalleryView
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo, useCallback, type Dispatch, type ReactNode, type SetStateAction } from 'react'
|
||||
import { useState, useMemo, useCallback, useRef, type Dispatch, type ReactNode, type SetStateAction } from 'react'
|
||||
import Table, { type Column, type SortState } from './Table'
|
||||
import type {
|
||||
RecordJob,
|
||||
@ -26,6 +26,8 @@ import ModelGenderIcon from './ModelGenderIcon'
|
||||
import { resolveFinishedModelFlags } from './finishedModelFlags'
|
||||
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
||||
import PreviewRatingOverlay from './PreviewRatingOverlay'
|
||||
import { useFinishedDownloadsAutoTablePageSize } from './useFinishedDownloadsAutoPageSize'
|
||||
import DeletingPreviewOverlay from './DeletingPreviewOverlay'
|
||||
|
||||
type Props = {
|
||||
rows: RecordJob[]
|
||||
@ -62,9 +64,11 @@ type Props = {
|
||||
handleResolution: (job: RecordJob, w: number, h: number) => void
|
||||
blurPreviews?: boolean
|
||||
assetNonce?: number
|
||||
assetNonceForJob?: (job: RecordJob) => number
|
||||
|
||||
// state/flags for actions row + rowClassName
|
||||
deletingKeys: Set<string>
|
||||
deletedSuccessKeys: Set<string>
|
||||
keepingKeys: Set<string>
|
||||
removingKeys: Set<string>
|
||||
|
||||
@ -91,6 +95,8 @@ type Props = {
|
||||
forcePreviewMuted?: boolean
|
||||
renderIntegrityBadge?: (job: RecordJob) => ReactNode
|
||||
renderRatingOverlay?: (job: RecordJob) => ReactNode
|
||||
onRecommendedPageSizeChange?: (size: number) => void
|
||||
suspendAutoPageSize?: boolean
|
||||
}
|
||||
|
||||
export default function FinishedDownloadsTableView({
|
||||
@ -122,8 +128,10 @@ export default function FinishedDownloadsTableView({
|
||||
handleResolution,
|
||||
blurPreviews,
|
||||
assetNonce,
|
||||
assetNonceForJob,
|
||||
|
||||
deletingKeys,
|
||||
deletedSuccessKeys,
|
||||
keepingKeys,
|
||||
removingKeys,
|
||||
|
||||
@ -148,8 +156,19 @@ export default function FinishedDownloadsTableView({
|
||||
forcePreviewMuted,
|
||||
renderIntegrityBadge,
|
||||
renderRatingOverlay,
|
||||
onRecommendedPageSizeChange,
|
||||
suspendAutoPageSize,
|
||||
}: Props) {
|
||||
const [sort, setSort] = useState<SortState>(null)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useFinishedDownloadsAutoTablePageSize({
|
||||
rootRef,
|
||||
enabled: !suspendAutoPageSize,
|
||||
fallbackRowHeight: 96,
|
||||
fallbackHeaderHeight: 48,
|
||||
onRecommendedPageSizeChange,
|
||||
})
|
||||
|
||||
const runtimeSecondsForSort = useCallback((job: RecordJob) => {
|
||||
// 1) Prefer real video duration (ffprobe / backend)
|
||||
@ -292,6 +311,8 @@ export default function FinishedDownloadsTableView({
|
||||
widthClassName: 'w-[140px]',
|
||||
cell: (j) => {
|
||||
const k = keyFor(j)
|
||||
const isDeleting = deletingKeys.has(k)
|
||||
const isDeleted = deletedSuccessKeys.has(k)
|
||||
const isPreviewActive =
|
||||
teaserState.activeKey === k || teaserState.hoverKey === k
|
||||
const allowSound = shouldEnableTeaserAudio({
|
||||
@ -304,6 +325,7 @@ export default function FinishedDownloadsTableView({
|
||||
|
||||
const fileRaw = baseName(j.output || '')
|
||||
const previewId = stripHotPrefix(fileRaw.replace(/\.[^.]+$/, '')).trim()
|
||||
const rowAssetNonce = assetNonceForJob?.(j) ?? assetNonce ?? 0
|
||||
|
||||
const scrubTimeSec =
|
||||
spriteInfo && typeof scrubActiveIndex === 'number'
|
||||
@ -312,7 +334,7 @@ export default function FinishedDownloadsTableView({
|
||||
|
||||
const scrubFrameSrc =
|
||||
previewId && typeof scrubTimeSec === 'number'
|
||||
? `/api/preview?id=${encodeURIComponent(previewId)}&t=${scrubTimeSec.toFixed(3)}&v=${assetNonce ?? 0}`
|
||||
? `/api/preview?id=${encodeURIComponent(previewId)}&t=${scrubTimeSec.toFixed(3)}&v=${rowAssetNonce}`
|
||||
: ''
|
||||
|
||||
return (
|
||||
@ -347,7 +369,7 @@ export default function FinishedDownloadsTableView({
|
||||
})}
|
||||
animatedMode="teaser"
|
||||
animatedTrigger="always"
|
||||
assetNonce={assetNonce}
|
||||
assetNonce={rowAssetNonce}
|
||||
forceActive={isPreviewActive}
|
||||
teaserPreloadEnabled={false}
|
||||
/>
|
||||
@ -365,6 +387,10 @@ export default function FinishedDownloadsTableView({
|
||||
draggable={false}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isDeleting || isDeleted ? (
|
||||
<DeletingPreviewOverlay done={isDeleted} compact className="rounded-md" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -531,7 +557,7 @@ export default function FinishedDownloadsTableView({
|
||||
srOnlyHeader: true,
|
||||
cell: (j) => {
|
||||
const k = keyFor(j)
|
||||
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
||||
const busy = deletingKeys.has(k) || deletedSuccessKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
||||
|
||||
const fileRaw = baseName(j.output || '')
|
||||
const isHot = isHotName(fileRaw)
|
||||
@ -585,6 +611,7 @@ export default function FinishedDownloadsTableView({
|
||||
blurPreviews,
|
||||
teaserState,
|
||||
assetNonce,
|
||||
assetNonceForJob,
|
||||
lower,
|
||||
modelNameFromOutput,
|
||||
modelsByKey,
|
||||
@ -593,6 +620,7 @@ export default function FinishedDownloadsTableView({
|
||||
handlePrimaryAction,
|
||||
resolutions,
|
||||
deletingKeys,
|
||||
deletedSuccessKeys,
|
||||
keepingKeys,
|
||||
removingKeys,
|
||||
previewSpriteInfoOf,
|
||||
@ -651,6 +679,7 @@ export default function FinishedDownloadsTableView({
|
||||
'transition-all duration-300',
|
||||
(deletingKeys.has(k) || removingKeys.has(k)) && 'bg-red-50/60 dark:bg-red-500/10 pointer-events-none',
|
||||
deletingKeys.has(k) && 'animate-pulse',
|
||||
deletedSuccessKeys.has(k) && 'bg-emerald-50/60 dark:bg-emerald-500/10 pointer-events-none',
|
||||
(keepingKeys.has(k) || removingKeys.has(k)) && 'pointer-events-none',
|
||||
keepingKeys.has(k) && 'bg-emerald-50/60 dark:bg-emerald-500/10 animate-pulse',
|
||||
removingKeys.has(k) && 'opacity-0',
|
||||
@ -658,11 +687,11 @@ export default function FinishedDownloadsTableView({
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
},
|
||||
[keyFor, deletingKeys, keepingKeys, removingKeys]
|
||||
[keyFor, deletingKeys, deletedSuccessKeys, keepingKeys, removingKeys]
|
||||
)
|
||||
|
||||
return (
|
||||
<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
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
|
||||
@ -520,7 +520,8 @@ export default function FinishedVideoPreview({
|
||||
? inView
|
||||
: forceActive || effectiveNearView || effectiveInView
|
||||
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' =
|
||||
inlineVideo === true || inlineVideo === 'always' ? 'always' : inlineVideo === 'hover' ? 'hover' : 'never'
|
||||
@ -548,6 +549,7 @@ export default function FinishedVideoPreview({
|
||||
const clipsRef = useRef<HTMLVideoElement | null>(null)
|
||||
const teaserSoundForcedRef = useRef(false)
|
||||
const teaserMutedRef = useRef(muted)
|
||||
const teaserSpeedHoldRef = useRef(false)
|
||||
|
||||
const setTeaserPlaybackRate = useCallback((rate: number) => {
|
||||
const el = teaserMp4Ref.current
|
||||
@ -945,7 +947,10 @@ export default function FinishedVideoPreview({
|
||||
(forceActive || effectiveNearView || effectiveInView || (wantsHover && hovered))
|
||||
|
||||
useEffect(() => {
|
||||
teaserSpeedHoldRef.current = teaserSpeedHold
|
||||
|
||||
if (animatedMode !== 'teaser' || showingInlineVideo || !teaserActive) {
|
||||
teaserSpeedHoldRef.current = false
|
||||
setTeaserPlaybackRate(1)
|
||||
return
|
||||
}
|
||||
@ -1237,8 +1242,9 @@ export default function FinishedVideoPreview({
|
||||
el.playsInline = true
|
||||
el.loop = true
|
||||
el.autoplay = true
|
||||
el.playbackRate = teaserSpeedHold ? 2 : 1
|
||||
el.defaultPlaybackRate = teaserSpeedHold ? 2 : 1
|
||||
const playbackRate = teaserSpeedHoldRef.current ? 2 : 1
|
||||
el.playbackRate = playbackRate
|
||||
el.defaultPlaybackRate = playbackRate
|
||||
} catch {}
|
||||
|
||||
if (opts?.resetToStart) {
|
||||
@ -1305,6 +1311,7 @@ export default function FinishedVideoPreview({
|
||||
vv.addEventListener('canplay', onCanPlay)
|
||||
vv.addEventListener('pause', onPause)
|
||||
vv.addEventListener('waiting', onWaiting)
|
||||
teaserLastProgressTimeRef.current = performance.now()
|
||||
|
||||
// frischer Start für neue TopCard
|
||||
tryPlay({ resetToStart: true })
|
||||
@ -1328,7 +1335,7 @@ export default function FinishedVideoPreview({
|
||||
const stalledFor = now - teaserLastProgressTimeRef.current
|
||||
const ct = Number(el.currentTime)
|
||||
|
||||
if ((!Number.isFinite(ct) || ct <= 0.05) && stalledFor > teaserWatchdogMs) {
|
||||
if ((!Number.isFinite(ct) || ct <= 0.05) && stalledFor > teaserStartResetWatchdogMs) {
|
||||
tryPlay({ resetToStart: true })
|
||||
return
|
||||
}
|
||||
@ -1359,7 +1366,7 @@ export default function FinishedVideoPreview({
|
||||
pageVisible,
|
||||
teaserRetryDelayMs,
|
||||
teaserWatchdogMs,
|
||||
teaserSpeedHold,
|
||||
teaserStartResetWatchdogMs,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
@ -1674,7 +1681,7 @@ export default function FinishedVideoPreview({
|
||||
playsInline
|
||||
autoPlay
|
||||
loop
|
||||
preload={mobilePreviewConstrained ? 'metadata' : 'auto'}
|
||||
preload="auto"
|
||||
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
|
||||
onLoadedData={markTeaserAvailable}
|
||||
onCanPlay={markTeaserAvailable}
|
||||
|
||||
@ -901,7 +901,11 @@ export default function RecordJobActions({
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setMenuOpen(false)
|
||||
await onKeep?.(job)
|
||||
try {
|
||||
await onKeep?.(job)
|
||||
} catch {
|
||||
// The app-level handler already reports delete/keep failures.
|
||||
}
|
||||
}}
|
||||
>
|
||||
<BookmarkSquareIcon className={cn('size-4', colors.keep)} />
|
||||
@ -921,7 +925,11 @@ export default function RecordJobActions({
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setMenuOpen(false)
|
||||
await onDelete?.(job)
|
||||
try {
|
||||
await onDelete?.(job)
|
||||
} catch {
|
||||
// The app-level handler already reports delete/keep failures.
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TrashIcon className={cn('size-4', colors.del)} />
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
||||
import Button from './Button'
|
||||
import LabeledSwitch from './LabeledSwitch'
|
||||
import LoadingSpinner from './LoadingSpinner'
|
||||
import { formatDuration } from './formatters'
|
||||
import {
|
||||
@ -128,6 +129,12 @@ type CorrectionState = {
|
||||
boxes: TrainingBox[]
|
||||
}
|
||||
|
||||
type QueuedTrainingSample = {
|
||||
sample: TrainingSample
|
||||
correction?: CorrectionState
|
||||
manualCorrection?: boolean
|
||||
}
|
||||
|
||||
type TrainingBox = {
|
||||
label: string
|
||||
score?: number
|
||||
@ -149,6 +156,9 @@ type TrainingPosePerson = {
|
||||
score: number
|
||||
box: TrainingBox
|
||||
keypoints: TrainingKeypoint[]
|
||||
quality?: number
|
||||
visibleKeypoints?: number
|
||||
reliable?: boolean
|
||||
}
|
||||
|
||||
type BoxInteraction =
|
||||
@ -271,6 +281,170 @@ type TrainingFeedbackListResponse = {
|
||||
type TrainingSampleMode = 'random' | 'uncertain'
|
||||
type FeedbackFilter = 'all' | 'accepted' | 'corrected' | 'negative'
|
||||
|
||||
const POSE_KEYPOINT_MIN_CONFIDENCE = 0.15
|
||||
const POSE_PERSON_MIN_SCORE = 0.30
|
||||
const POSE_RELIABLE_KEYPOINT_MIN_CONFIDENCE = 0.20
|
||||
const POSE_RELIABLE_MIN_SCORE = 0.30
|
||||
const POSE_RELIABLE_MIN_KEYPOINTS = 6
|
||||
const POSE_RELIABLE_MIN_QUALITY = 0.45
|
||||
const POSE_UNRELIABLE_COLOR = '#94a3b8'
|
||||
const POSE_PERSON_COLORS = ['#38bdf8', '#a78bfa', '#34d399', '#f59e0b', '#fb7185']
|
||||
const POSE_SKELETON_EDGES: Array<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) {
|
||||
return String(
|
||||
data?.message ||
|
||||
@ -761,14 +935,24 @@ function predictionToCorrection(sample: TrainingSample | null): CorrectionState
|
||||
}
|
||||
}
|
||||
|
||||
function correctionHasRelevantContent(value: CorrectionState) {
|
||||
function cloneCorrectionState(value: CorrectionState): CorrectionState {
|
||||
return {
|
||||
sexPosition: value.sexPosition,
|
||||
peoplePresent: [...value.peoplePresent],
|
||||
bodyPartsPresent: [...value.bodyPartsPresent],
|
||||
objectsPresent: [...value.objectsPresent],
|
||||
clothingPresent: [...value.clothingPresent],
|
||||
boxes: (value.boxes ?? []).map((box) => ({ ...box })),
|
||||
}
|
||||
}
|
||||
|
||||
function correctionHasTrainablePositionOrBoxes(value: CorrectionState) {
|
||||
return (
|
||||
(value.sexPosition && !isNoSexPositionValue(value.sexPosition)) ||
|
||||
(value.peoplePresent?.length ?? 0) > 0 ||
|
||||
(value.bodyPartsPresent?.length ?? 0) > 0 ||
|
||||
(value.objectsPresent?.length ?? 0) > 0 ||
|
||||
(value.clothingPresent?.length ?? 0) > 0 ||
|
||||
(value.boxes?.length ?? 0) > 0
|
||||
(value.boxes ?? []).some((box) => {
|
||||
const normalized = normalizeBox(box)
|
||||
return Boolean(normalized.label && normalized.w > 0 && normalized.h > 0)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@ -1155,6 +1339,7 @@ function LabelToggleGrid(props: {
|
||||
values: string[]
|
||||
selected: string[]
|
||||
scores?: Record<string, number>
|
||||
activeCounts?: Record<string, number>
|
||||
onToggle: (value: string) => void
|
||||
drawLabel?: string
|
||||
onDrawLabelChange?: (value: string) => void
|
||||
@ -1172,7 +1357,8 @@ function LabelToggleGrid(props: {
|
||||
return (
|
||||
<div className={props.gridClassName || 'grid grid-cols-2 gap-2 sm:grid-cols-3'}>
|
||||
{props.values.map((value) => {
|
||||
const active = props.selected.includes(value)
|
||||
const activeCount = Math.max(0, Math.floor(Number(props.activeCounts?.[value] ?? 0)))
|
||||
const active = props.selected.includes(value) || activeCount > 0
|
||||
const item = getSegmentLabelItem(value)
|
||||
const Icon = item.icon
|
||||
const score = props.scores?.[value]
|
||||
@ -1219,7 +1405,11 @@ function LabelToggleGrid(props: {
|
||||
{item.text}
|
||||
</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
|
||||
className={[
|
||||
'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[]
|
||||
selected: string[]
|
||||
scores?: Record<string, number>
|
||||
activeCounts?: Record<string, number>
|
||||
expanded: boolean
|
||||
onExpandedChange: (expanded: boolean) => void
|
||||
onToggle: (value: string) => void
|
||||
@ -1603,9 +1794,18 @@ function CollapsibleLabelSection(props: {
|
||||
const hasDrawLabelInSection =
|
||||
cleanDrawLabel !== '' && props.values.includes(cleanDrawLabel)
|
||||
|
||||
const activeCount = props.singleDrawMode
|
||||
? Math.max(props.selected.length, hasDrawLabelInSection ? 1 : 0)
|
||||
: props.selected.length
|
||||
const countedActiveItems = props.activeCounts
|
||||
? props.values.reduce(
|
||||
(sum, value) => sum + Math.max(0, Math.floor(Number(props.activeCounts?.[value] ?? 0))),
|
||||
0
|
||||
)
|
||||
: null
|
||||
|
||||
const activeCount = countedActiveItems !== null
|
||||
? Math.max(countedActiveItems, hasDrawLabelInSection ? 1 : 0)
|
||||
: props.singleDrawMode
|
||||
? Math.max(props.selected.length, hasDrawLabelInSection ? 1 : 0)
|
||||
: props.selected.length
|
||||
|
||||
const hasActiveItems = activeCount > 0
|
||||
const shown = props.expanded
|
||||
@ -1657,6 +1857,7 @@ function CollapsibleLabelSection(props: {
|
||||
values={props.values}
|
||||
selected={props.selected}
|
||||
scores={props.scores}
|
||||
activeCounts={props.activeCounts}
|
||||
onToggle={props.onToggle}
|
||||
drawLabel={props.drawLabel}
|
||||
onDrawLabelChange={props.onDrawLabelChange}
|
||||
@ -2559,9 +2760,9 @@ export default function TrainingTab(props: {
|
||||
const tabActive = props.active ?? true
|
||||
const [labels, setLabels] = useState<TrainingLabels>(emptyLabels)
|
||||
const [sample, setSample] = useState<TrainingSample | null>(null)
|
||||
const sampleRef = useRef<TrainingSample | null>(null)
|
||||
const [correction, setCorrection] = useState<CorrectionState>(() => predictionToCorrection(null))
|
||||
const [hasManualCorrection, setHasManualCorrection] = useState(false)
|
||||
const [negativeExample, setNegativeExample] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [analysisProgress, setAnalysisProgress] = useState(0)
|
||||
const [analysisStep, setAnalysisStep] = useState('')
|
||||
@ -2591,8 +2792,8 @@ export default function TrainingTab(props: {
|
||||
}
|
||||
})
|
||||
|
||||
const [importedSampleQueue, setImportedSampleQueue] = useState<TrainingSample[]>([])
|
||||
const importedSampleQueueRef = useRef<TrainingSample[]>([])
|
||||
const [importedSampleQueue, setImportedSampleQueue] = useState<QueuedTrainingSample[]>([])
|
||||
const importedSampleQueueRef = useRef<QueuedTrainingSample[]>([])
|
||||
|
||||
const [feedbackModalOpen, setFeedbackModalOpen] = useState(false)
|
||||
const [feedbackItems, setFeedbackItems] = useState<TrainingAnnotation[]>([])
|
||||
@ -2628,6 +2829,7 @@ export default function TrainingTab(props: {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const [showPoseSkeleton, setShowPoseSkeleton] = useState(true)
|
||||
|
||||
const [frameNaturalSize, setFrameNaturalSize] = useState<{
|
||||
width: number
|
||||
@ -2770,10 +2972,16 @@ export default function TrainingTab(props: {
|
||||
])
|
||||
|
||||
const editFeedbackItem = useCallback((item: TrainingAnnotation) => {
|
||||
setSample(annotationToTrainingSample(item))
|
||||
setCorrection(annotationToCorrectionState(item))
|
||||
setHasManualCorrection(!item.accepted)
|
||||
setNegativeExample(Boolean(item.negative))
|
||||
const nextSample = annotationToTrainingSample(item)
|
||||
const nextCorrection = annotationToCorrectionState(item)
|
||||
const nextManualCorrection = !item.accepted
|
||||
|
||||
sampleRef.current = nextSample
|
||||
correctionRef.current = nextCorrection
|
||||
hasManualCorrectionRef.current = nextManualCorrection
|
||||
setSample(nextSample)
|
||||
setCorrection(nextCorrection)
|
||||
setHasManualCorrection(nextManualCorrection)
|
||||
|
||||
setEditingFeedback({
|
||||
sampleId: item.sampleId,
|
||||
@ -3000,6 +3208,10 @@ export default function TrainingTab(props: {
|
||||
importedSampleQueueRef.current = importedSampleQueue
|
||||
}, [importedSampleQueue])
|
||||
|
||||
useEffect(() => {
|
||||
sampleRef.current = sample
|
||||
}, [sample])
|
||||
|
||||
useEffect(() => {
|
||||
if (!feedbackModalOpen) return
|
||||
|
||||
@ -3050,8 +3262,11 @@ export default function TrainingTab(props: {
|
||||
}, [labels.people, labels.bodyParts, labels.objects, labels.clothing])
|
||||
|
||||
const correctionBoxes = correction.boxes ?? []
|
||||
const isNegativeCorrection =
|
||||
negativeExample && !correctionHasRelevantContent(correction)
|
||||
const hasTrainableFeedbackContent = useMemo(
|
||||
() => correctionHasTrainablePositionOrBoxes(correction),
|
||||
[correction]
|
||||
)
|
||||
const willSaveAsNegative = Boolean(sample) && !hasTrainableFeedbackContent
|
||||
|
||||
const visibleBoxes = [
|
||||
...correctionBoxes.map((box, index) => ({ box, index, isDraft: false })),
|
||||
@ -3060,6 +3275,14 @@ export default function TrainingTab(props: {
|
||||
: []),
|
||||
]
|
||||
|
||||
const posePersons = useMemo(() => {
|
||||
return (sample?.prediction.persons ?? []).filter((person) =>
|
||||
hasVisiblePoseBox(person) || person.keypoints?.some(isPoseKeypointVisible)
|
||||
)
|
||||
}, [sample?.prediction.persons])
|
||||
|
||||
const hasPosePersons = posePersons.length > 0
|
||||
|
||||
const bodyPartScores = useMemo(() => {
|
||||
return Object.fromEntries(
|
||||
(sample?.prediction.bodyPartsPresent ?? [])
|
||||
@ -3103,6 +3326,19 @@ export default function TrainingTab(props: {
|
||||
return Object.fromEntries(bestScores)
|
||||
}, [sample?.prediction.boxes, labels.people])
|
||||
|
||||
const peopleBoxCounts = useMemo(() => {
|
||||
const counts = new Map<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(() => {
|
||||
return correction.peoplePresent
|
||||
}, [correction.peoplePresent])
|
||||
@ -3346,9 +3582,11 @@ export default function TrainingTab(props: {
|
||||
|
||||
const loadTrainingSampleIntoTab = useCallback((
|
||||
nextSample: TrainingSample,
|
||||
opts?: { manualCorrection?: boolean }
|
||||
opts?: { manualCorrection?: boolean; correction?: CorrectionState }
|
||||
) => {
|
||||
const nextCorrection = predictionToCorrection(nextSample)
|
||||
const nextCorrection = opts?.correction
|
||||
? cloneCorrectionState(opts.correction)
|
||||
: predictionToCorrection(nextSample)
|
||||
|
||||
setDrawingBox(null)
|
||||
setBoxInteraction(null)
|
||||
@ -3364,10 +3602,12 @@ export default function TrainingTab(props: {
|
||||
})
|
||||
})
|
||||
|
||||
sampleRef.current = nextSample
|
||||
correctionRef.current = nextCorrection
|
||||
hasManualCorrectionRef.current = Boolean(opts?.manualCorrection)
|
||||
setSample(nextSample)
|
||||
setCorrection(nextCorrection)
|
||||
setHasManualCorrection(Boolean(opts?.manualCorrection))
|
||||
setNegativeExample(false)
|
||||
|
||||
const initiallyExpandedSection: CorrectionSectionKey | null =
|
||||
nextCorrection.sexPosition && !isNoSexPositionValue(nextCorrection.sexPosition)
|
||||
@ -3395,20 +3635,88 @@ export default function TrainingTab(props: {
|
||||
)
|
||||
}, [])
|
||||
|
||||
const loadNextImportedQueuedSample = useCallback(() => {
|
||||
const [nextSample, ...rest] = importedSampleQueueRef.current
|
||||
const setQueuedTrainingSamples = useCallback((nextQueue: QueuedTrainingSample[]) => {
|
||||
importedSampleQueueRef.current = nextQueue
|
||||
setImportedSampleQueue(nextQueue)
|
||||
}, [])
|
||||
|
||||
if (!nextSample) {
|
||||
const currentSampleToQueuedItem = useCallback((): QueuedTrainingSample | null => {
|
||||
const currentSample = sampleRef.current
|
||||
|
||||
if (!currentSample) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
sample: currentSample,
|
||||
correction: cloneCorrectionState(correctionRef.current),
|
||||
manualCorrection: hasManualCorrectionRef.current,
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadQueuedTrainingSample = useCallback((item: QueuedTrainingSample) => {
|
||||
loadTrainingSampleIntoTab(item.sample, {
|
||||
correction: item.correction,
|
||||
manualCorrection: item.manualCorrection,
|
||||
})
|
||||
}, [loadTrainingSampleIntoTab])
|
||||
|
||||
const loadNextImportedQueuedSample = useCallback(() => {
|
||||
const [nextItem, ...rest] = importedSampleQueueRef.current
|
||||
|
||||
if (!nextItem) {
|
||||
return false
|
||||
}
|
||||
|
||||
importedSampleQueueRef.current = rest
|
||||
setImportedSampleQueue(rest)
|
||||
setQueuedTrainingSamples(rest)
|
||||
|
||||
loadTrainingSampleIntoTab(nextSample)
|
||||
loadQueuedTrainingSample(nextItem)
|
||||
|
||||
return true
|
||||
}, [loadTrainingSampleIntoTab])
|
||||
}, [loadQueuedTrainingSample, setQueuedTrainingSamples])
|
||||
|
||||
const deferCurrentSampleToQueueEnd = useCallback(() => {
|
||||
const currentItem = currentSampleToQueuedItem()
|
||||
|
||||
if (!currentItem) {
|
||||
return false
|
||||
}
|
||||
|
||||
setQueuedTrainingSamples([...importedSampleQueueRef.current, currentItem])
|
||||
return true
|
||||
}, [currentSampleToQueuedItem, setQueuedTrainingSamples])
|
||||
|
||||
const loadPriorityTrainingSamples = useCallback((
|
||||
prioritySamples: TrainingSample[],
|
||||
opts?: { deferCurrentSampleToQueueEnd?: boolean }
|
||||
) => {
|
||||
const priorityItems = prioritySamples.map((prioritySample) => ({
|
||||
sample: prioritySample,
|
||||
}))
|
||||
|
||||
if (priorityItems.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const currentItem = opts?.deferCurrentSampleToQueueEnd
|
||||
? currentSampleToQueuedItem()
|
||||
: null
|
||||
const nextQueue = [
|
||||
...priorityItems,
|
||||
...importedSampleQueueRef.current,
|
||||
...(currentItem ? [currentItem] : []),
|
||||
]
|
||||
const [nextItem, ...rest] = nextQueue
|
||||
|
||||
if (!nextItem) {
|
||||
return false
|
||||
}
|
||||
|
||||
setQueuedTrainingSamples(rest)
|
||||
loadQueuedTrainingSample(nextItem)
|
||||
|
||||
return true
|
||||
}, [currentSampleToQueuedItem, loadQueuedTrainingSample, setQueuedTrainingSamples])
|
||||
|
||||
const loadNext = useCallback(async (opts?: {
|
||||
forceNew?: boolean
|
||||
@ -3416,6 +3724,7 @@ export default function TrainingTab(props: {
|
||||
preserveNotice?: boolean
|
||||
mode?: TrainingSampleMode
|
||||
previewUrl?: string
|
||||
deferCurrentSampleToQueueEnd?: boolean
|
||||
}) => {
|
||||
const requestId = makeRequestId()
|
||||
activeAnalysisRequestIdRef.current = requestId
|
||||
@ -3477,6 +3786,10 @@ export default function TrainingTab(props: {
|
||||
setAnalysisProgress(92)
|
||||
setAnalysisStep('Analyse-Ergebnis wird übernommen…')
|
||||
|
||||
if (opts?.deferCurrentSampleToQueueEnd) {
|
||||
deferCurrentSampleToQueueEnd()
|
||||
}
|
||||
|
||||
loadTrainingSampleIntoTab(data as TrainingSample)
|
||||
} catch (e) {
|
||||
if (isCurrentRequest()) {
|
||||
@ -3501,7 +3814,7 @@ export default function TrainingTab(props: {
|
||||
setAnalysisStep('')
|
||||
}, 500)
|
||||
}
|
||||
}, [loadTrainingSampleIntoTab, setLoadingPreviewCandidate])
|
||||
}, [deferCurrentSampleToQueueEnd, loadTrainingSampleIntoTab, setLoadingPreviewCandidate])
|
||||
|
||||
const reloadCurrentImage = useCallback(async () => {
|
||||
setDrawingBox(null)
|
||||
@ -3595,12 +3908,11 @@ export default function TrainingTab(props: {
|
||||
throw new Error('Es wurden keine Trainingsframes erzeugt.')
|
||||
}
|
||||
|
||||
const [firstSample, ...queuedSamples] = samples
|
||||
const deferredCurrentSample = Boolean(sampleRef.current)
|
||||
|
||||
importedSampleQueueRef.current = queuedSamples
|
||||
setImportedSampleQueue(queuedSamples)
|
||||
|
||||
loadTrainingSampleIntoTab(firstSample)
|
||||
loadPriorityTrainingSamples(samples, {
|
||||
deferCurrentSampleToQueueEnd: deferredCurrentSample,
|
||||
})
|
||||
setImageReloadKey((value) => value + 1)
|
||||
|
||||
await loadTrainingStatus()
|
||||
@ -3613,6 +3925,14 @@ export default function TrainingTab(props: {
|
||||
: `${samples.length} Frames ins Training übernommen.`
|
||||
)
|
||||
|
||||
if (deferredCurrentSample) {
|
||||
setMessage(
|
||||
errorCount > 0
|
||||
? `${samples.length} Frames ins Training übernommen, ${errorCount} Frames fehlgeschlagen. Das aktuelle Bild wurde ans Ende der Queue gelegt.`
|
||||
: `${samples.length} Frames ins Training übernommen. Das aktuelle Bild wurde ans Ende der Queue gelegt.`
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
@ -3638,7 +3958,7 @@ export default function TrainingTab(props: {
|
||||
}, 500)
|
||||
}
|
||||
}, [
|
||||
loadTrainingSampleIntoTab,
|
||||
loadPriorityTrainingSamples,
|
||||
loadTrainingStatus,
|
||||
setLoadingPreviewCandidate,
|
||||
])
|
||||
@ -4221,7 +4541,13 @@ export default function TrainingTab(props: {
|
||||
.map(normalizeBox)
|
||||
.filter((box) => box.label && box.w > 0 && box.h > 0)
|
||||
|
||||
const negative = options?.negative ?? isNegativeCorrection
|
||||
const feedbackCorrection = {
|
||||
...correction,
|
||||
boxes: normalizedBoxes,
|
||||
}
|
||||
const negative =
|
||||
options?.negative ??
|
||||
!correctionHasTrainablePositionOrBoxes(feedbackCorrection)
|
||||
const correctionPayload: CorrectionState = negative
|
||||
? {
|
||||
sexPosition: NO_SEX_POSITION_LABEL,
|
||||
@ -4329,7 +4655,6 @@ export default function TrainingTab(props: {
|
||||
sample,
|
||||
correction,
|
||||
editingFeedback,
|
||||
isNegativeCorrection,
|
||||
loadNext,
|
||||
loadTrainingStatus,
|
||||
loadNextImportedQueuedSample,
|
||||
@ -4358,10 +4683,12 @@ export default function TrainingTab(props: {
|
||||
throw new Error(backendText(data, `HTTP ${res.status}`))
|
||||
}
|
||||
|
||||
sampleRef.current = null
|
||||
correctionRef.current = predictionToCorrection(null)
|
||||
hasManualCorrectionRef.current = false
|
||||
setSample(null)
|
||||
setCorrection(predictionToCorrection(null))
|
||||
setHasManualCorrection(false)
|
||||
setNegativeExample(false)
|
||||
setDrawingBox(null)
|
||||
setBoxInteraction(null)
|
||||
setTouchMagnifier(null)
|
||||
@ -4474,9 +4801,11 @@ export default function TrainingTab(props: {
|
||||
throw new Error(data?.error || `HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
sampleRef.current = null
|
||||
correctionRef.current = predictionToCorrection(null)
|
||||
hasManualCorrectionRef.current = false
|
||||
setSample(null)
|
||||
setCorrection(predictionToCorrection(null))
|
||||
setNegativeExample(false)
|
||||
setTrainingStatus({
|
||||
feedbackCount: 0,
|
||||
requiredCount,
|
||||
@ -5353,11 +5682,19 @@ export default function TrainingTab(props: {
|
||||
|
||||
setTrainingSampleMode(nextMode)
|
||||
|
||||
if (!uiLocked) {
|
||||
if (!uiLocked && nextMode === 'uncertain') {
|
||||
void loadNext({
|
||||
forceNew: true,
|
||||
mode: nextMode,
|
||||
deferCurrentSampleToQueueEnd: Boolean(sampleRef.current),
|
||||
})
|
||||
} else if (!uiLocked && !sampleRef.current) {
|
||||
if (!loadNextImportedQueuedSample()) {
|
||||
void loadNext({
|
||||
forceNew: true,
|
||||
mode: nextMode,
|
||||
})
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
@ -6235,6 +6572,18 @@ export default function TrainingTab(props: {
|
||||
].join(' ')}
|
||||
style={imageStageStyle}
|
||||
>
|
||||
{hasPosePersons ? (
|
||||
<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 ? (
|
||||
<div className="relative z-50 flex h-full min-h-0 w-full items-center justify-center overflow-visible">
|
||||
<div
|
||||
@ -6290,6 +6639,175 @@ export default function TrainingTab(props: {
|
||||
].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 ? (
|
||||
<div
|
||||
className="absolute z-[300] overflow-visible"
|
||||
@ -6814,18 +7332,26 @@ export default function TrainingTab(props: {
|
||||
>
|
||||
<Button
|
||||
size="md"
|
||||
variant={hasManualCorrection ? 'primary' : 'soft'}
|
||||
color={hasManualCorrection ? undefined : 'emerald'}
|
||||
variant={hasManualCorrection || willSaveAsNegative ? 'primary' : 'soft'}
|
||||
color={
|
||||
willSaveAsNegative
|
||||
? 'blue'
|
||||
: hasManualCorrection
|
||||
? undefined
|
||||
: 'emerald'
|
||||
}
|
||||
disabled={
|
||||
uiLocked ||
|
||||
frameBusy ||
|
||||
!sample ||
|
||||
(!hasManualCorrection && !sample.prediction.modelAvailable)
|
||||
(!hasManualCorrection && !willSaveAsNegative && !sample.prediction.modelAvailable)
|
||||
}
|
||||
onClick={() => void saveFeedback(!hasManualCorrection)}
|
||||
className="w-full justify-center px-2 text-xs sm:text-sm"
|
||||
title={
|
||||
hasManualCorrection
|
||||
willSaveAsNegative
|
||||
? 'Keine Box und keine Position gesetzt. Das Bild wird als Negativbeispiel gespeichert.'
|
||||
: hasManualCorrection
|
||||
? 'Die korrigierten Werte werden gespeichert.'
|
||||
: sample?.prediction.modelAvailable
|
||||
? '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" />
|
||||
Speichere…
|
||||
</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 ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<InboxArrowDownIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
@ -6870,22 +7402,6 @@ export default function TrainingTab(props: {
|
||||
</span>
|
||||
</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">
|
||||
<Button
|
||||
size="md"
|
||||
@ -7102,6 +7618,7 @@ export default function TrainingTab(props: {
|
||||
values={labels.people}
|
||||
selected={selectedPeopleLabels}
|
||||
scores={peopleScores}
|
||||
activeCounts={peopleBoxCounts}
|
||||
expanded={expandedCorrectionSections.people}
|
||||
onExpandedChange={(expanded) =>
|
||||
toggleMobileCorrectionSection('people', expanded)
|
||||
@ -7288,6 +7805,7 @@ export default function TrainingTab(props: {
|
||||
values={labels.people}
|
||||
selected={selectedPeopleLabels}
|
||||
scores={peopleScores}
|
||||
activeCounts={peopleBoxCounts}
|
||||
expanded={expandedCorrectionSections.people}
|
||||
onExpandedChange={(expanded) =>
|
||||
toggleCorrectionSection('people', expanded)
|
||||
|
||||
354
frontend/src/components/ui/useFinishedDownloadsAutoPageSize.ts
Normal file
354
frontend/src/components/ui/useFinishedDownloadsAutoPageSize.ts
Normal 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])
|
||||
}
|
||||
@ -8,8 +8,8 @@ export type SelectedItem = {
|
||||
}
|
||||
|
||||
export type UndoAction =
|
||||
| { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep' }
|
||||
| { kind: 'keep'; keptFile: string; originalFile: string; rowKey?: string }
|
||||
| { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep'; job?: RecordJob }
|
||||
| { kind: 'keep'; keptFile: string; originalFile: string; rowKey?: string; job?: RecordJob }
|
||||
| { kind: 'hot'; currentFile: string }
|
||||
|
||||
export type PersistedUndoState = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user