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