bugfixes
This commit is contained in:
parent
4320f040ff
commit
cf86cdd868
@ -1,2 +1,2 @@
|
|||||||
HTTPS_ENABLED=0
|
HTTPS_ENABLED=1
|
||||||
AUTH_RP_ORIGINS=https://l14pbbk95100006.tegdssd.de:9999,https://l14pbbk95100006.tegdssd.de:5173,https://localhost:9999,https://127.0.0.1:9999,https://10.0.1.25:9999,http://localhost:5173,http://127.0.0.1:5173,http://10.0.1.25:5173
|
AUTH_RP_ORIGINS=https://l14pbbk95100006.tegdssd.de:9999,https://l14pbbk95100006.tegdssd.de:5173,https://localhost:9999,https://127.0.0.1:9999,https://10.0.1.25:9999,http://localhost:5173,http://127.0.0.1:5173,http://10.0.1.25:5173
|
||||||
Binary file not shown.
@ -12,6 +12,52 @@ from ultralytics import YOLO
|
|||||||
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
KEYPOINT_NAMES = [
|
||||||
|
"nose",
|
||||||
|
"left_eye", "right_eye",
|
||||||
|
"left_ear", "right_ear",
|
||||||
|
"left_shoulder", "right_shoulder",
|
||||||
|
"left_elbow", "right_elbow",
|
||||||
|
"left_wrist", "right_wrist",
|
||||||
|
"left_hip", "right_hip",
|
||||||
|
"left_knee", "right_knee",
|
||||||
|
"left_ankle", "right_ankle",
|
||||||
|
]
|
||||||
|
|
||||||
|
NO_SEX_POSITION_LABEL = "keine"
|
||||||
|
NO_SEX_POSITION_ALIASES = {
|
||||||
|
"",
|
||||||
|
NO_SEX_POSITION_LABEL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_sex_position_label(value) -> str:
|
||||||
|
clean = str(value or "").strip().lower()
|
||||||
|
if clean in NO_SEX_POSITION_ALIASES:
|
||||||
|
return NO_SEX_POSITION_LABEL
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
def is_no_sex_position_label(value) -> bool:
|
||||||
|
return normalize_sex_position_label(value) == NO_SEX_POSITION_LABEL
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_sex_position_labels(values) -> set[str]:
|
||||||
|
labels = set()
|
||||||
|
has_no_position = False
|
||||||
|
|
||||||
|
for value in values or []:
|
||||||
|
clean = normalize_sex_position_label(value)
|
||||||
|
if is_no_sex_position_label(clean):
|
||||||
|
has_no_position = True
|
||||||
|
continue
|
||||||
|
if clean:
|
||||||
|
labels.add(clean)
|
||||||
|
|
||||||
|
if has_no_position:
|
||||||
|
labels.add(NO_SEX_POSITION_LABEL)
|
||||||
|
|
||||||
|
return labels
|
||||||
|
|
||||||
|
|
||||||
def existing_file(path: Path) -> Optional[Path]:
|
def existing_file(path: Path) -> Optional[Path]:
|
||||||
@ -60,6 +106,7 @@ def resolve_training_root() -> Path:
|
|||||||
|
|
||||||
TRAINING_ROOT = resolve_training_root()
|
TRAINING_ROOT = resolve_training_root()
|
||||||
DEFAULT_MODEL_PATH = TRAINING_ROOT / "detector" / "model" / "best.pt"
|
DEFAULT_MODEL_PATH = TRAINING_ROOT / "detector" / "model" / "best.pt"
|
||||||
|
DEFAULT_POSE_MODEL_PATH = TRAINING_ROOT / "pose" / "model" / "best.pt"
|
||||||
|
|
||||||
|
|
||||||
def resolve_detection_labels_path() -> Path:
|
def resolve_detection_labels_path() -> Path:
|
||||||
@ -91,12 +138,26 @@ def resolve_model_path() -> str:
|
|||||||
raise RuntimeError(f"YOLO model not found: {DEFAULT_MODEL_PATH}")
|
raise RuntimeError(f"YOLO model not found: {DEFAULT_MODEL_PATH}")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_pose_model_path() -> Optional[Path]:
|
||||||
|
env_path = os.environ.get("YOLO_POSE_MODEL", "").strip()
|
||||||
|
if env_path:
|
||||||
|
p = Path(env_path).expanduser().resolve()
|
||||||
|
if existing_file(p):
|
||||||
|
return p
|
||||||
|
raise RuntimeError(f"YOLO_POSE_MODEL not found: {p}")
|
||||||
|
|
||||||
|
if existing_file(DEFAULT_POSE_MODEL_PATH):
|
||||||
|
return DEFAULT_POSE_MODEL_PATH.resolve()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Server darf auch ohne Labels/Model starten.
|
# Server darf auch ohne Labels/Model starten.
|
||||||
DETECTION_LABELS_PATH: Optional[Path] = None
|
DETECTION_LABELS_PATH: Optional[Path] = None
|
||||||
|
|
||||||
LABEL_GROUPS = {
|
LABEL_GROUPS = {
|
||||||
"people": set(),
|
"people": set(),
|
||||||
"sexPositions": {"unknown"},
|
"sexPositions": {NO_SEX_POSITION_LABEL},
|
||||||
"bodyParts": set(),
|
"bodyParts": set(),
|
||||||
"objects": set(),
|
"objects": set(),
|
||||||
"clothing": set(),
|
"clothing": set(),
|
||||||
@ -114,15 +175,19 @@ PERSON_LABELS = {
|
|||||||
|
|
||||||
_MODEL_PATH = ""
|
_MODEL_PATH = ""
|
||||||
_MODEL_ERROR = ""
|
_MODEL_ERROR = ""
|
||||||
|
_POSE_MODEL_PATH = ""
|
||||||
|
_POSE_MODEL_ERROR = ""
|
||||||
_LABEL_ERROR = ""
|
_LABEL_ERROR = ""
|
||||||
|
|
||||||
_DEVICE = os.environ.get("YOLO_DEVICE", "")
|
_DEVICE = os.environ.get("YOLO_DEVICE", "")
|
||||||
_CONF = float(os.environ.get("YOLO_CONF", "0.25"))
|
_CONF = float(os.environ.get("YOLO_CONF", "0.25"))
|
||||||
|
_POSE_CONF = float(os.environ.get("YOLO_POSE_CONF", "0.30"))
|
||||||
_BATCH = int(os.environ.get("YOLO_BATCH", "16"))
|
_BATCH = int(os.environ.get("YOLO_BATCH", "16"))
|
||||||
_IMGSZ = int(os.environ.get("YOLO_IMGSZ", "640"))
|
_IMGSZ = int(os.environ.get("YOLO_IMGSZ", "640"))
|
||||||
_HALF = os.environ.get("YOLO_HALF", "0").lower() in {"1", "true", "yes", "on"}
|
_HALF = os.environ.get("YOLO_HALF", "0").lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
model = None
|
model = None
|
||||||
|
pose_model = None
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
@ -174,13 +239,14 @@ def empty_prediction(source: str = "model_missing") -> dict:
|
|||||||
return {
|
return {
|
||||||
"modelAvailable": False,
|
"modelAvailable": False,
|
||||||
"source": source,
|
"source": source,
|
||||||
"sexPosition": "unknown",
|
"sexPosition": NO_SEX_POSITION_LABEL,
|
||||||
"sexPositionScore": 0.0,
|
"sexPositionScore": 0.0,
|
||||||
"peoplePresent": [],
|
"peoplePresent": [],
|
||||||
"bodyPartsPresent": [],
|
"bodyPartsPresent": [],
|
||||||
"objectsPresent": [],
|
"objectsPresent": [],
|
||||||
"clothingPresent": [],
|
"clothingPresent": [],
|
||||||
"boxes": [],
|
"boxes": [],
|
||||||
|
"persons": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -207,11 +273,7 @@ def load_label_groups_safe() -> None:
|
|||||||
for x in data.get("people", [])
|
for x in data.get("people", [])
|
||||||
if str(x).strip()
|
if str(x).strip()
|
||||||
),
|
),
|
||||||
"sexPositions": set(
|
"sexPositions": normalize_sex_position_labels(data.get("sexPositions", [])),
|
||||||
str(x).strip().lower()
|
|
||||||
for x in data.get("sexPositions", [])
|
|
||||||
if str(x).strip()
|
|
||||||
),
|
|
||||||
"bodyParts": set(
|
"bodyParts": set(
|
||||||
str(x).strip().lower()
|
str(x).strip().lower()
|
||||||
for x in data.get("bodyParts", [])
|
for x in data.get("bodyParts", [])
|
||||||
@ -230,7 +292,7 @@ def load_label_groups_safe() -> None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if not LABEL_GROUPS["sexPositions"]:
|
if not LABEL_GROUPS["sexPositions"]:
|
||||||
LABEL_GROUPS["sexPositions"] = {"unknown"}
|
LABEL_GROUPS["sexPositions"] = {NO_SEX_POSITION_LABEL}
|
||||||
|
|
||||||
_LABEL_ERROR = ""
|
_LABEL_ERROR = ""
|
||||||
|
|
||||||
@ -240,7 +302,7 @@ def load_label_groups_safe() -> None:
|
|||||||
|
|
||||||
LABEL_GROUPS = {
|
LABEL_GROUPS = {
|
||||||
"people": set(),
|
"people": set(),
|
||||||
"sexPositions": {"unknown"},
|
"sexPositions": {NO_SEX_POSITION_LABEL},
|
||||||
"bodyParts": set(),
|
"bodyParts": set(),
|
||||||
"objects": set(),
|
"objects": set(),
|
||||||
"clothing": set(),
|
"clothing": set(),
|
||||||
@ -252,7 +314,7 @@ def load_label_groups_safe() -> None:
|
|||||||
|
|
||||||
POSITION_LABELS = {
|
POSITION_LABELS = {
|
||||||
label for label in LABEL_GROUPS["sexPositions"]
|
label for label in LABEL_GROUPS["sexPositions"]
|
||||||
if label and label != "unknown"
|
if label and not is_no_sex_position_label(label)
|
||||||
}
|
}
|
||||||
|
|
||||||
PERSON_LABELS = {
|
PERSON_LABELS = {
|
||||||
@ -289,6 +351,37 @@ def get_model():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_pose_model():
|
||||||
|
global pose_model
|
||||||
|
global _POSE_MODEL_PATH
|
||||||
|
global _POSE_MODEL_ERROR
|
||||||
|
|
||||||
|
if pose_model is not None:
|
||||||
|
return pose_model
|
||||||
|
|
||||||
|
try:
|
||||||
|
path = resolve_pose_model_path()
|
||||||
|
if path is None:
|
||||||
|
pose_model = None
|
||||||
|
_POSE_MODEL_PATH = ""
|
||||||
|
_POSE_MODEL_ERROR = ""
|
||||||
|
return None
|
||||||
|
|
||||||
|
loaded = YOLO(str(path))
|
||||||
|
|
||||||
|
pose_model = loaded
|
||||||
|
_POSE_MODEL_PATH = str(path)
|
||||||
|
_POSE_MODEL_ERROR = ""
|
||||||
|
|
||||||
|
return pose_model
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
pose_model = None
|
||||||
|
_POSE_MODEL_PATH = ""
|
||||||
|
_POSE_MODEL_ERROR = str(exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def scored(label: str, score: float) -> dict:
|
def scored(label: str, score: float) -> dict:
|
||||||
return {
|
return {
|
||||||
"label": label,
|
"label": label,
|
||||||
@ -315,9 +408,6 @@ def prediction_from_result(result) -> dict:
|
|||||||
objects = []
|
objects = []
|
||||||
clothing = []
|
clothing = []
|
||||||
|
|
||||||
sex_position = "unknown"
|
|
||||||
sex_position_score = 0.0
|
|
||||||
|
|
||||||
if result.boxes is not None:
|
if result.boxes is not None:
|
||||||
xywhn = result.boxes.xywhn.cpu().tolist()
|
xywhn = result.boxes.xywhn.cpu().tolist()
|
||||||
cls_values = result.boxes.cls.cpu().tolist()
|
cls_values = result.boxes.cls.cpu().tolist()
|
||||||
@ -341,13 +431,6 @@ def prediction_from_result(result) -> dict:
|
|||||||
is_body = label in BODY_LABELS
|
is_body = label in BODY_LABELS
|
||||||
is_object = label in OBJECT_LABELS
|
is_object = label in OBJECT_LABELS
|
||||||
is_clothing = label in CLOTHING_LABELS
|
is_clothing = label in CLOTHING_LABELS
|
||||||
is_position = label in POSITION_LABELS
|
|
||||||
|
|
||||||
if is_position:
|
|
||||||
if score > sex_position_score:
|
|
||||||
sex_position = label
|
|
||||||
sex_position_score = score
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not (is_person or is_body or is_object or is_clothing):
|
if not (is_person or is_body or is_object or is_clothing):
|
||||||
continue
|
continue
|
||||||
@ -376,13 +459,165 @@ def prediction_from_result(result) -> dict:
|
|||||||
return {
|
return {
|
||||||
"modelAvailable": True,
|
"modelAvailable": True,
|
||||||
"source": f"yolo-server:{Path(_MODEL_PATH).name}",
|
"source": f"yolo-server:{Path(_MODEL_PATH).name}",
|
||||||
"sexPosition": sex_position,
|
"sexPosition": NO_SEX_POSITION_LABEL,
|
||||||
"sexPositionScore": sex_position_score,
|
"sexPositionScore": 0.0,
|
||||||
"peoplePresent": people_present,
|
"peoplePresent": people_present,
|
||||||
"bodyPartsPresent": body_parts,
|
"bodyPartsPresent": body_parts,
|
||||||
"objectsPresent": objects,
|
"objectsPresent": objects,
|
||||||
"clothingPresent": clothing,
|
"clothingPresent": clothing,
|
||||||
"boxes": boxes_out,
|
"boxes": boxes_out,
|
||||||
|
"persons": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def pose_persons_from_result(result) -> list[dict]:
|
||||||
|
names = result.names or {}
|
||||||
|
persons = []
|
||||||
|
|
||||||
|
if result.boxes is None:
|
||||||
|
return persons
|
||||||
|
|
||||||
|
kpts_xyn = None
|
||||||
|
kpts_conf = None
|
||||||
|
|
||||||
|
if result.keypoints is not None:
|
||||||
|
try:
|
||||||
|
kpts_xyn = result.keypoints.xyn.cpu().tolist()
|
||||||
|
except Exception:
|
||||||
|
kpts_xyn = None
|
||||||
|
try:
|
||||||
|
kpts_conf = result.keypoints.conf.cpu().tolist()
|
||||||
|
except Exception:
|
||||||
|
kpts_conf = None
|
||||||
|
|
||||||
|
xywhn_values = result.boxes.xywhn.cpu().tolist()
|
||||||
|
cls_values = result.boxes.cls.cpu().tolist()
|
||||||
|
conf_values = result.boxes.conf.cpu().tolist()
|
||||||
|
|
||||||
|
for i, (box_xywhn, cls_id, conf) in enumerate(
|
||||||
|
zip(xywhn_values, cls_values, conf_values)
|
||||||
|
):
|
||||||
|
label = str(names.get(int(cls_id), int(cls_id))).strip().lower()
|
||||||
|
score = float(conf)
|
||||||
|
|
||||||
|
cx, cy, w, h = [float(v) for v in box_xywhn]
|
||||||
|
x = max(0.0, min(1.0, cx - w / 2.0))
|
||||||
|
y = max(0.0, min(1.0, cy - h / 2.0))
|
||||||
|
w = max(0.0, min(1.0 - x, w))
|
||||||
|
h = max(0.0, min(1.0 - y, h))
|
||||||
|
|
||||||
|
if w <= 0 or h <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
keypoints = []
|
||||||
|
if kpts_xyn is not None and i < len(kpts_xyn):
|
||||||
|
for ki, point in enumerate(kpts_xyn[i]):
|
||||||
|
if len(point) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
kconf = 0.0
|
||||||
|
if (
|
||||||
|
kpts_conf is not None
|
||||||
|
and i < len(kpts_conf)
|
||||||
|
and ki < len(kpts_conf[i])
|
||||||
|
):
|
||||||
|
kconf = float(kpts_conf[i][ki])
|
||||||
|
|
||||||
|
keypoints.append({
|
||||||
|
"name": KEYPOINT_NAMES[ki] if ki < len(KEYPOINT_NAMES) else str(ki),
|
||||||
|
"x": float(point[0]),
|
||||||
|
"y": float(point[1]),
|
||||||
|
"conf": kconf,
|
||||||
|
})
|
||||||
|
|
||||||
|
persons.append({
|
||||||
|
"label": label,
|
||||||
|
"score": score,
|
||||||
|
"box": {
|
||||||
|
"x": x,
|
||||||
|
"y": y,
|
||||||
|
"w": w,
|
||||||
|
"h": h,
|
||||||
|
},
|
||||||
|
"keypoints": keypoints,
|
||||||
|
})
|
||||||
|
|
||||||
|
return persons
|
||||||
|
|
||||||
|
|
||||||
|
def apply_pose_result_to_prediction(prediction: dict, result) -> dict:
|
||||||
|
persons = pose_persons_from_result(result)
|
||||||
|
if not persons:
|
||||||
|
return prediction
|
||||||
|
|
||||||
|
prediction["persons"] = persons
|
||||||
|
|
||||||
|
best_position = ""
|
||||||
|
best_score_value = 0.0
|
||||||
|
|
||||||
|
for person in persons:
|
||||||
|
label = str(person.get("label") or "").strip().lower()
|
||||||
|
score = float(person.get("score") or 0.0)
|
||||||
|
|
||||||
|
if is_no_sex_position_label(label) or label not in POSITION_LABELS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if score > best_score_value:
|
||||||
|
best_position = label
|
||||||
|
best_score_value = score
|
||||||
|
|
||||||
|
if best_position:
|
||||||
|
prediction["sexPosition"] = best_position
|
||||||
|
prediction["sexPositionScore"] = best_score_value
|
||||||
|
|
||||||
|
source = str(prediction.get("source") or "").strip()
|
||||||
|
prediction["source"] = f"{source}+yolo_pose" if source else "yolo_pose"
|
||||||
|
|
||||||
|
return prediction
|
||||||
|
|
||||||
|
|
||||||
|
def apply_pose_batch_to_predictions(paths: list[str], predictions: list[dict], imgsz: int) -> None:
|
||||||
|
global _POSE_MODEL_ERROR
|
||||||
|
|
||||||
|
current_pose_model = get_pose_model()
|
||||||
|
if current_pose_model is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
pose_results = current_pose_model.predict(
|
||||||
|
source=paths,
|
||||||
|
imgsz=imgsz,
|
||||||
|
conf=_POSE_CONF,
|
||||||
|
batch=_BATCH,
|
||||||
|
device=_DEVICE or None,
|
||||||
|
half=_HALF,
|
||||||
|
verbose=False,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_POSE_MODEL_ERROR = str(exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
for prediction, pose_result in zip(predictions, pose_results):
|
||||||
|
apply_pose_result_to_prediction(prediction, pose_result)
|
||||||
|
|
||||||
|
|
||||||
|
def pose_model_status() -> dict:
|
||||||
|
try:
|
||||||
|
expected = resolve_pose_model_path()
|
||||||
|
expected_text = str(expected) if expected else str(DEFAULT_POSE_MODEL_PATH)
|
||||||
|
exists = expected is not None
|
||||||
|
error = _POSE_MODEL_ERROR
|
||||||
|
except Exception as exc:
|
||||||
|
expected_text = str(DEFAULT_POSE_MODEL_PATH)
|
||||||
|
exists = False
|
||||||
|
error = str(exc)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"poseModelAvailable": exists,
|
||||||
|
"poseModelLoaded": pose_model is not None,
|
||||||
|
"poseModel": _POSE_MODEL_PATH or (expected_text if exists else ""),
|
||||||
|
"poseModelError": error,
|
||||||
|
"expectedPoseModel": expected_text,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -426,6 +661,9 @@ def predict_batch(req: PredictBatchRequest):
|
|||||||
|
|
||||||
predictions = [prediction_from_result(result) for result in results]
|
predictions = [prediction_from_result(result) for result in results]
|
||||||
|
|
||||||
|
if not req.detectorOnly:
|
||||||
|
apply_pose_batch_to_predictions(paths, predictions, imgsz)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"predictions": predictions,
|
"predictions": predictions,
|
||||||
@ -444,7 +682,7 @@ def health():
|
|||||||
current_model = get_model()
|
current_model = get_model()
|
||||||
names = getattr(current_model, "names", {}) or {} if current_model is not None else {}
|
names = getattr(current_model, "names", {}) or {} if current_model is not None else {}
|
||||||
|
|
||||||
return {
|
status_payload = {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"ready": current_model is not None,
|
"ready": current_model is not None,
|
||||||
"modelAvailable": current_model is not None,
|
"modelAvailable": current_model is not None,
|
||||||
@ -458,22 +696,31 @@ def health():
|
|||||||
"labelError": _LABEL_ERROR,
|
"labelError": _LABEL_ERROR,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
status_payload.update(pose_model_status())
|
||||||
|
return status_payload
|
||||||
|
|
||||||
@app.post("/reload", dependencies=[Depends(require_ai_server_auth)])
|
@app.post("/reload", dependencies=[Depends(require_ai_server_auth)])
|
||||||
def reload_model():
|
def reload_model():
|
||||||
global model
|
global model
|
||||||
|
global pose_model
|
||||||
global _MODEL_PATH
|
global _MODEL_PATH
|
||||||
global _MODEL_ERROR
|
global _MODEL_ERROR
|
||||||
|
global _POSE_MODEL_PATH
|
||||||
|
global _POSE_MODEL_ERROR
|
||||||
global DETECTION_LABELS_PATH
|
global DETECTION_LABELS_PATH
|
||||||
|
|
||||||
model = None
|
model = None
|
||||||
|
pose_model = None
|
||||||
_MODEL_PATH = ""
|
_MODEL_PATH = ""
|
||||||
_MODEL_ERROR = ""
|
_MODEL_ERROR = ""
|
||||||
|
_POSE_MODEL_PATH = ""
|
||||||
|
_POSE_MODEL_ERROR = ""
|
||||||
DETECTION_LABELS_PATH = None
|
DETECTION_LABELS_PATH = None
|
||||||
|
|
||||||
current_model = get_model()
|
current_model = get_model()
|
||||||
names = getattr(current_model, "names", {}) or {} if current_model is not None else {}
|
names = getattr(current_model, "names", {}) or {} if current_model is not None else {}
|
||||||
|
|
||||||
return {
|
status_payload = {
|
||||||
"ok": current_model is not None,
|
"ok": current_model is not None,
|
||||||
"ready": current_model is not None,
|
"ready": current_model is not None,
|
||||||
"modelAvailable": current_model is not None,
|
"modelAvailable": current_model is not None,
|
||||||
@ -486,3 +733,6 @@ def reload_model():
|
|||||||
"labelConfig": str(DETECTION_LABELS_PATH) if DETECTION_LABELS_PATH else "",
|
"labelConfig": str(DETECTION_LABELS_PATH) if DETECTION_LABELS_PATH else "",
|
||||||
"labelError": _LABEL_ERROR,
|
"labelError": _LABEL_ERROR,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
status_payload.update(pose_model_status())
|
||||||
|
return status_payload
|
||||||
|
|||||||
@ -135,7 +135,7 @@ func autoSelectedAILabelSet() map[string]struct{} {
|
|||||||
add := func(values []string) {
|
add := func(values []string) {
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
label := strings.ToLower(strings.TrimSpace(value))
|
label := strings.ToLower(strings.TrimSpace(value))
|
||||||
if label == "" || label == "unknown" {
|
if isNoSexPositionLabel(label) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out[label] = struct{}{}
|
out[label] = struct{}{}
|
||||||
@ -155,7 +155,7 @@ var autoSelectedAILabelsCache map[string]struct{}
|
|||||||
|
|
||||||
func shouldAutoSelectAnalyzeHit(label string) bool {
|
func shouldAutoSelectAnalyzeHit(label string) bool {
|
||||||
label = strings.ToLower(strings.TrimSpace(label))
|
label = strings.ToLower(strings.TrimSpace(label))
|
||||||
if label == "" || label == "unknown" {
|
if isNoSexPositionLabel(label) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -202,7 +202,7 @@ func isIgnoredNSFWLabel(label string) bool {
|
|||||||
|
|
||||||
func addHighlightResult(best map[string]float64, label string, score float64) {
|
func addHighlightResult(best map[string]float64, label string, score float64) {
|
||||||
label = strings.ToLower(strings.TrimSpace(label))
|
label = strings.ToLower(strings.TrimSpace(label))
|
||||||
if label == "" || label == "unknown" {
|
if isNoSexPositionLabel(label) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -223,7 +223,7 @@ func addScoredHighlightLabels(best map[string]float64, prefix string, items []Tr
|
|||||||
|
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
label := strings.ToLower(strings.TrimSpace(item.Label))
|
label := strings.ToLower(strings.TrimSpace(item.Label))
|
||||||
if label == "" || label == "unknown" {
|
if isNoSexPositionLabel(label) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -235,7 +235,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe
|
|||||||
best := map[string]float64{}
|
best := map[string]float64{}
|
||||||
|
|
||||||
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
|
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
|
||||||
if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) {
|
if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) {
|
||||||
addHighlightResult(best, "position:"+sexPosition, pred.SexPositionScore)
|
addHighlightResult(best, "position:"+sexPosition, pred.SexPositionScore)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -245,7 +245,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe
|
|||||||
|
|
||||||
for _, box := range pred.Boxes {
|
for _, box := range pred.Boxes {
|
||||||
label := strings.ToLower(strings.TrimSpace(box.Label))
|
label := strings.ToLower(strings.TrimSpace(box.Label))
|
||||||
if label == "" || label == "unknown" {
|
if isNoSexPositionLabel(label) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -264,7 +264,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Kombis nur erzeugen, wenn wirklich Position + Zusatz vorhanden ist.
|
// Kombis nur erzeugen, wenn wirklich Position + Zusatz vorhanden ist.
|
||||||
if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) {
|
if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) {
|
||||||
positionScore := pred.SexPositionScore
|
positionScore := pred.SexPositionScore
|
||||||
if positionScore <= 0 {
|
if positionScore <= 0 {
|
||||||
positionScore = 1
|
positionScore = 1
|
||||||
@ -273,7 +273,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe
|
|||||||
addCombo := func(prefix string, items []TrainingScoredLabel) {
|
addCombo := func(prefix string, items []TrainingScoredLabel) {
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
label := strings.ToLower(strings.TrimSpace(item.Label))
|
label := strings.ToLower(strings.TrimSpace(item.Label))
|
||||||
if label == "" || label == "unknown" {
|
if isNoSexPositionLabel(label) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -316,7 +316,7 @@ func pickHighlightResults(results []NsfwFrameResult) []NsfwFrameResult {
|
|||||||
|
|
||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
label := strings.ToLower(strings.TrimSpace(r.Label))
|
label := strings.ToLower(strings.TrimSpace(r.Label))
|
||||||
if label == "" || label == "unknown" {
|
if isNoSexPositionLabel(label) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1075,7 +1075,7 @@ type highlightSignal struct {
|
|||||||
|
|
||||||
func normalizeHighlightSignalLabel(label string) string {
|
func normalizeHighlightSignalLabel(label string) string {
|
||||||
label = strings.ToLower(strings.TrimSpace(label))
|
label = strings.ToLower(strings.TrimSpace(label))
|
||||||
if label == "" || label == "unknown" {
|
if isNoSexPositionLabel(label) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1094,28 +1094,28 @@ func normalizeHighlightSignalLabel(label string) string {
|
|||||||
|
|
||||||
case strings.HasPrefix(label, "body:"):
|
case strings.HasPrefix(label, "body:"):
|
||||||
raw := strings.TrimPrefix(label, "body:")
|
raw := strings.TrimPrefix(label, "body:")
|
||||||
if raw == "" || raw == "unknown" {
|
if isNoSexPositionLabel(raw) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return "body:" + raw
|
return "body:" + raw
|
||||||
|
|
||||||
case strings.HasPrefix(label, "object:"):
|
case strings.HasPrefix(label, "object:"):
|
||||||
raw := strings.TrimPrefix(label, "object:")
|
raw := strings.TrimPrefix(label, "object:")
|
||||||
if raw == "" || raw == "unknown" {
|
if isNoSexPositionLabel(raw) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return "object:" + raw
|
return "object:" + raw
|
||||||
|
|
||||||
case strings.HasPrefix(label, "clothing:"):
|
case strings.HasPrefix(label, "clothing:"):
|
||||||
raw := strings.TrimPrefix(label, "clothing:")
|
raw := strings.TrimPrefix(label, "clothing:")
|
||||||
if raw == "" || raw == "unknown" {
|
if isNoSexPositionLabel(raw) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return "clothing:" + raw
|
return "clothing:" + raw
|
||||||
|
|
||||||
case strings.HasPrefix(label, "position:"):
|
case strings.HasPrefix(label, "position:"):
|
||||||
raw := strings.TrimPrefix(label, "position:")
|
raw := strings.TrimPrefix(label, "position:")
|
||||||
if raw == "" || raw == "unknown" || !isKnownPositionLabel(raw) {
|
if isNoSexPositionLabel(raw) || !isKnownPositionLabel(raw) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return "position:" + raw
|
return "position:" + raw
|
||||||
@ -1173,7 +1173,7 @@ func rawAnalyzeSignalSeverityWeight(label string) float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
raw := normalizeSegmentLabel(label)
|
raw := normalizeSegmentLabel(label)
|
||||||
if raw == "" || raw == "unknown" {
|
if isNoSexPositionLabel(raw) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1273,7 +1273,7 @@ func addHighlightSignalsFromScoredLabels(
|
|||||||
|
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
label := strings.ToLower(strings.TrimSpace(item.Label))
|
label := strings.ToLower(strings.TrimSpace(item.Label))
|
||||||
if label == "" || label == "unknown" {
|
if isNoSexPositionLabel(label) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1302,7 +1302,7 @@ func highlightComboPartOrder(label string) int {
|
|||||||
|
|
||||||
func predictionHasAnyAnalyzeSignal(pred TrainingPrediction) bool {
|
func predictionHasAnyAnalyzeSignal(pred TrainingPrediction) bool {
|
||||||
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
|
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
|
||||||
if sexPosition != "" && sexPosition != "unknown" {
|
if !isNoSexPositionLabel(sexPosition) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1342,7 +1342,7 @@ func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64)
|
|||||||
|
|
||||||
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
|
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
|
||||||
|
|
||||||
if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) {
|
if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) {
|
||||||
positionScore := pred.SexPositionScore
|
positionScore := pred.SexPositionScore
|
||||||
if positionScore <= 0 {
|
if positionScore <= 0 {
|
||||||
positionScore = 0.35
|
positionScore = 0.35
|
||||||
@ -1364,7 +1364,7 @@ func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64)
|
|||||||
|
|
||||||
for _, box := range pred.Boxes {
|
for _, box := range pred.Boxes {
|
||||||
label := strings.ToLower(strings.TrimSpace(box.Label))
|
label := strings.ToLower(strings.TrimSpace(box.Label))
|
||||||
if label == "" || label == "unknown" {
|
if isNoSexPositionLabel(label) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if isIgnoredNSFWLabel(label) {
|
if isIgnoredNSFWLabel(label) {
|
||||||
@ -1540,7 +1540,7 @@ func buildHighlightHitsFromPrediction(pred TrainingPrediction, t float64) []anal
|
|||||||
best := map[string]highlightSignal{}
|
best := map[string]highlightSignal{}
|
||||||
|
|
||||||
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
|
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
|
||||||
if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) {
|
if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) {
|
||||||
addHighlightSignal(best, "position:"+sexPosition, pred.SexPositionScore)
|
addHighlightSignal(best, "position:"+sexPosition, pred.SexPositionScore)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1550,7 +1550,7 @@ func buildHighlightHitsFromPrediction(pred TrainingPrediction, t float64) []anal
|
|||||||
|
|
||||||
for _, box := range pred.Boxes {
|
for _, box := range pred.Boxes {
|
||||||
label := strings.ToLower(strings.TrimSpace(box.Label))
|
label := strings.ToLower(strings.TrimSpace(box.Label))
|
||||||
if label == "" || label == "unknown" {
|
if isNoSexPositionLabel(label) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if isIgnoredNSFWLabel(label) {
|
if isIgnoredNSFWLabel(label) {
|
||||||
@ -2322,7 +2322,7 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit {
|
|||||||
|
|
||||||
for _, h := range in {
|
for _, h := range in {
|
||||||
label := strings.ToLower(strings.TrimSpace(h.Label))
|
label := strings.ToLower(strings.TrimSpace(h.Label))
|
||||||
if label == "" || label == "unknown" {
|
if isNoSexPositionLabel(label) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) {
|
if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) {
|
||||||
@ -2542,7 +2542,7 @@ type analyzeLabelSegmentPoint struct {
|
|||||||
|
|
||||||
func isAllowedAnalyzeSegmentLabel(label string) bool {
|
func isAllowedAnalyzeSegmentLabel(label string) bool {
|
||||||
label = strings.ToLower(strings.TrimSpace(label))
|
label = strings.ToLower(strings.TrimSpace(label))
|
||||||
if label == "" || label == "unknown" {
|
if isNoSexPositionLabel(label) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) {
|
if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) {
|
||||||
@ -2559,7 +2559,7 @@ func isAllowedAnalyzeSegmentLabel(label string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
raw := normalizeSegmentLabel(label)
|
raw := normalizeSegmentLabel(label)
|
||||||
if raw == "" || raw == "unknown" {
|
if isNoSexPositionLabel(raw) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -76,20 +76,29 @@ func stopJobsInternal(list []*RecordJob) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pl := make([]payload, 0, len(list))
|
pl := make([]payload, 0, len(list))
|
||||||
|
ids := make([]string, 0, len(list))
|
||||||
|
nowMs := time.Now().UnixMilli()
|
||||||
|
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
for _, job := range list {
|
for _, job := range list {
|
||||||
if job == nil {
|
if job == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if job.EndedAt != nil || isTerminalJobStatus(job.Status) || isPostworkJob(job) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
job.Phase = "stopping"
|
job.Phase = "stopping"
|
||||||
job.Progress = 10
|
job.Progress = 10
|
||||||
|
job.StopRequestedAtMs = nowMs
|
||||||
|
job.StopAttempts++
|
||||||
|
|
||||||
pl = append(pl, payload{
|
pl = append(pl, payload{
|
||||||
cmd: job.previewCmd,
|
cmd: job.previewCmd,
|
||||||
cancel: job.cancel,
|
cancel: job.cancel,
|
||||||
previewCancel: job.previewCancel,
|
previewCancel: job.previewCancel,
|
||||||
})
|
})
|
||||||
|
ids = append(ids, job.ID)
|
||||||
|
|
||||||
job.previewCmd = nil
|
job.previewCmd = nil
|
||||||
job.previewCancel = nil
|
job.previewCancel = nil
|
||||||
@ -98,6 +107,11 @@ func stopJobsInternal(list []*RecordJob) {
|
|||||||
}
|
}
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
|
|
||||||
|
for _, id := range ids {
|
||||||
|
_ = publishJobSnapshot(id)
|
||||||
|
_ = publishModelJobUpsert(id)
|
||||||
|
}
|
||||||
|
|
||||||
for _, p := range pl {
|
for _, p := range pl {
|
||||||
if p.previewCancel != nil {
|
if p.previewCancel != nil {
|
||||||
p.previewCancel()
|
p.previewCancel()
|
||||||
@ -109,6 +123,11 @@ func stopJobsInternal(list []*RecordJob) {
|
|||||||
p.cancel()
|
p.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, id := range ids {
|
||||||
|
_ = publishJobSnapshot(id)
|
||||||
|
_ = publishModelJobUpsert(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopAllStoppableJobs() int {
|
func stopAllStoppableJobs() int {
|
||||||
|
|||||||
@ -8,9 +8,17 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed ml/*.py ai_server.py .env recorder-cert.pem recorder-key.pem generated/training/detection_labels.json
|
//go:embed ml/*.py ml/detection_labels.json ai_server.py .env recorder-cert.pem recorder-key.pem yolo26n-pose.pt
|
||||||
var embeddedMLFiles embed.FS
|
var embeddedMLFiles embed.FS
|
||||||
|
|
||||||
|
func embeddedWriteFileIfNeeded(dstPath string, b []byte, perm os.FileMode) error {
|
||||||
|
if fi, err := os.Stat(dstPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() == int64(len(b)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(dstPath, b, perm)
|
||||||
|
}
|
||||||
|
|
||||||
func trainingEmbeddedMLDir() (string, error) {
|
func trainingEmbeddedMLDir() (string, error) {
|
||||||
dir := filepath.Join(os.TempDir(), "nsfwapp-ml")
|
dir := filepath.Join(os.TempDir(), "nsfwapp-ml")
|
||||||
|
|
||||||
@ -22,6 +30,8 @@ func trainingEmbeddedMLDir() (string, error) {
|
|||||||
"predict_detector_model.py",
|
"predict_detector_model.py",
|
||||||
"train_detector_model.py",
|
"train_detector_model.py",
|
||||||
"detection_labels.json",
|
"detection_labels.json",
|
||||||
|
"predict_pose_model.py",
|
||||||
|
"train_pose_model.py",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Falls du die alten Scene-Skripte noch embedded hast, kannst du sie optional mitkopieren.
|
// Falls du die alten Scene-Skripte noch embedded hast, kannst du sie optional mitkopieren.
|
||||||
@ -33,10 +43,6 @@ func trainingEmbeddedMLDir() (string, error) {
|
|||||||
for _, name := range append(files, optionalFiles...) {
|
for _, name := range append(files, optionalFiles...) {
|
||||||
srcPath := filepath.ToSlash(filepath.Join("ml", name))
|
srcPath := filepath.ToSlash(filepath.Join("ml", name))
|
||||||
|
|
||||||
if name == "detection_labels.json" {
|
|
||||||
srcPath = "generated/training/detection_labels.json"
|
|
||||||
}
|
|
||||||
|
|
||||||
b, err := embeddedMLFiles.ReadFile(srcPath)
|
b, err := embeddedMLFiles.ReadFile(srcPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Pflichtdateien müssen vorhanden sein.
|
// Pflichtdateien müssen vorhanden sein.
|
||||||
@ -51,14 +57,24 @@ func trainingEmbeddedMLDir() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dstPath := filepath.Join(dir, name)
|
dstPath := filepath.Join(dir, name)
|
||||||
if err := os.WriteFile(dstPath, b, 0644); err != nil {
|
if err := embeddedWriteFileIfNeeded(dstPath, b, 0644); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, err := embeddedPoseModelPath(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
return dir, nil
|
return dir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func embeddedMLScriptExists(name string) bool {
|
||||||
|
srcPath := filepath.ToSlash(filepath.Join("ml", name))
|
||||||
|
_, err := embeddedMLFiles.ReadFile(srcPath)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
func embeddedAIServerDir() (string, error) {
|
func embeddedAIServerDir() (string, error) {
|
||||||
dir := filepath.Join(os.TempDir(), "nsfwapp-ai-server")
|
dir := filepath.Join(os.TempDir(), "nsfwapp-ai-server")
|
||||||
|
|
||||||
@ -72,13 +88,33 @@ func embeddedAIServerDir() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dstPath := filepath.Join(dir, "ai_server.py")
|
dstPath := filepath.Join(dir, "ai_server.py")
|
||||||
if err := os.WriteFile(dstPath, b, 0644); err != nil {
|
if err := embeddedWriteFileIfNeeded(dstPath, b, 0644); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return dir, nil
|
return dir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func embeddedPoseModelPath() (string, error) {
|
||||||
|
dir := filepath.Join(os.TempDir(), "nsfwapp-ml")
|
||||||
|
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := embeddedMLFiles.ReadFile("yolo26n-pose.pt")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
dstPath := filepath.Join(dir, "yolo26n-pose.pt")
|
||||||
|
if err := embeddedWriteFileIfNeeded(dstPath, b, 0644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dstPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
func embeddedDotEnvBytes() ([]byte, error) {
|
func embeddedDotEnvBytes() ([]byte, error) {
|
||||||
return embeddedMLFiles.ReadFile(".env")
|
return embeddedMLFiles.ReadFile(".env")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -103,6 +103,9 @@ func appLogWriter() io.Writer {
|
|||||||
|
|
||||||
func appLogWriteLine(line string) {
|
func appLogWriteLine(line string) {
|
||||||
line = strings.TrimRight(line, "\r\n")
|
line = strings.TrimRight(line, "\r\n")
|
||||||
|
if appLogLineSuppressed(line) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ts := time.Now().Format("2006-01-02 15:04:05")
|
ts := time.Now().Format("2006-01-02 15:04:05")
|
||||||
out := fmt.Sprintf("[%s] %s\n", ts, line)
|
out := fmt.Sprintf("[%s] %s\n", ts, line)
|
||||||
@ -130,11 +133,71 @@ func appLogf(format string, args ...any) {
|
|||||||
// erwartbare und sehr häufige Fehler (z.B. abgelaufene HLS-Edge-URLs während
|
// erwartbare und sehr häufige Fehler (z.B. abgelaufene HLS-Edge-URLs während
|
||||||
// eines Stream-Reconnects). Der Fehler wird weiterhin zurückgegeben, damit die
|
// eines Stream-Reconnects). Der Fehler wird weiterhin zurückgegeben, damit die
|
||||||
// Retry-/Reconnect-Logik unverändert funktioniert – nur die Log-Zeile entfällt.
|
// Retry-/Reconnect-Logik unverändert funktioniert – nur die Log-Zeile entfällt.
|
||||||
|
func appLogLineSuppressed(line string) bool {
|
||||||
|
m := strings.ToLower(strings.TrimSpace(line))
|
||||||
|
if m == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(m, "get playlist: context canceled") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(m, "usernamelookup ohne user.id") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(m, "mfc status failed") &&
|
||||||
|
(strings.Contains(m, "user not found") || strings.Contains(m, "usernamelookup")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(m, "mfc playlist candidate failed") &&
|
||||||
|
(strings.Contains(m, "http 403") || strings.Contains(m, "http 404")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(m, "mfc lookup:") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
isHLS := strings.Contains(m, ".m3u8") ||
|
||||||
|
strings.Contains(m, "abruf der m3u8") ||
|
||||||
|
strings.Contains(m, "variant-playlist")
|
||||||
|
|
||||||
|
if isHLS && strings.Contains(m, "http 403") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if isHLS && (strings.Contains(m, "http 500") ||
|
||||||
|
strings.Contains(m, "http 502") ||
|
||||||
|
strings.Contains(m, "http 503") ||
|
||||||
|
strings.Contains(m, "http 504")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func appLogFilterText(text string) string {
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
out := make([]string, 0, len(lines))
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
if appLogLineSuppressed(line) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(out, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
func appErrorLogSuppressed(msg string) bool {
|
func appErrorLogSuppressed(msg string) bool {
|
||||||
m := strings.ToLower(msg)
|
m := strings.ToLower(msg)
|
||||||
|
|
||||||
return strings.Contains(m, "leere hls url") ||
|
return appLogLineSuppressed(msg) ||
|
||||||
strings.Contains(m, "http 403")
|
strings.Contains(m, "leere hls url")
|
||||||
}
|
}
|
||||||
|
|
||||||
func appErrorf(format string, args ...any) error {
|
func appErrorf(format string, args ...any) error {
|
||||||
@ -243,7 +306,7 @@ func appLogHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logText := string(buf)
|
logText := appLogFilterText(string(buf))
|
||||||
if offset > 0 {
|
if offset > 0 {
|
||||||
logText = "… Log gekürzt, zeige die letzten " + fmt.Sprint(maxBytes/1024) + " KB …\n\n" + logText
|
logText = "… Log gekürzt, zeige die letzten " + fmt.Sprint(maxBytes/1024) + " KB …\n\n" + logText
|
||||||
}
|
}
|
||||||
|
|||||||
@ -97,6 +97,8 @@ type RecordJob struct {
|
|||||||
// ✅ Frontend Progress beim Stop/Finalize
|
// ✅ Frontend Progress beim Stop/Finalize
|
||||||
Phase string `json:"phase,omitempty"` // stopping | remuxing | moving
|
Phase string `json:"phase,omitempty"` // stopping | remuxing | moving
|
||||||
Progress int `json:"progress,omitempty"` // 0..100
|
Progress int `json:"progress,omitempty"` // 0..100
|
||||||
|
StopRequestedAtMs int64 `json:"stopRequestedAtMs,omitempty"` // ms since epoch
|
||||||
|
StopAttempts int `json:"stopAttempts,omitempty"`
|
||||||
|
|
||||||
PostWorkKey string `json:"postWorkKey,omitempty"`
|
PostWorkKey string `json:"postWorkKey,omitempty"`
|
||||||
PostWork *PostWorkKeyStatus `json:"postWork,omitempty"`
|
PostWork *PostWorkKeyStatus `json:"postWork,omitempty"`
|
||||||
@ -116,6 +118,8 @@ type jobEventSnapshot struct {
|
|||||||
Error string
|
Error string
|
||||||
Phase string
|
Phase string
|
||||||
Progress int
|
Progress int
|
||||||
|
StopRequestedAtMs int64
|
||||||
|
StopAttempts int
|
||||||
SizeBytes int64
|
SizeBytes int64
|
||||||
DurationSeconds float64
|
DurationSeconds float64
|
||||||
PreviewState string
|
PreviewState string
|
||||||
@ -177,6 +181,8 @@ func publishJobUpsertSnapshot(s jobEventSnapshot) {
|
|||||||
"error": s.Error,
|
"error": s.Error,
|
||||||
"phase": s.Phase,
|
"phase": s.Phase,
|
||||||
"progress": s.Progress,
|
"progress": s.Progress,
|
||||||
|
"stopRequestedAtMs": s.StopRequestedAtMs,
|
||||||
|
"stopAttempts": s.StopAttempts,
|
||||||
"sizeBytes": s.SizeBytes,
|
"sizeBytes": s.SizeBytes,
|
||||||
"durationSeconds": s.DurationSeconds,
|
"durationSeconds": s.DurationSeconds,
|
||||||
"previewState": s.PreviewState,
|
"previewState": s.PreviewState,
|
||||||
@ -233,6 +239,8 @@ func publishJobSnapshot(jobID string) bool {
|
|||||||
Error: job.Error,
|
Error: job.Error,
|
||||||
Phase: job.Phase,
|
Phase: job.Phase,
|
||||||
Progress: job.Progress,
|
Progress: job.Progress,
|
||||||
|
StopRequestedAtMs: job.StopRequestedAtMs,
|
||||||
|
StopAttempts: job.StopAttempts,
|
||||||
SizeBytes: job.SizeBytes,
|
SizeBytes: job.SizeBytes,
|
||||||
DurationSeconds: job.DurationSeconds,
|
DurationSeconds: job.DurationSeconds,
|
||||||
PreviewState: job.PreviewState,
|
PreviewState: job.PreviewState,
|
||||||
|
|||||||
56
backend/ml/detection_labels.json
Normal file
56
backend/ml/detection_labels.json
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"people": [
|
||||||
|
"person_female",
|
||||||
|
"person_male"
|
||||||
|
],
|
||||||
|
"sexPositions": [
|
||||||
|
"keine",
|
||||||
|
"missionary",
|
||||||
|
"doggy",
|
||||||
|
"cowgirl",
|
||||||
|
"reverse_cowgirl",
|
||||||
|
"cunnilingus",
|
||||||
|
"prone_bone",
|
||||||
|
"standing",
|
||||||
|
"standing_doggy",
|
||||||
|
"spooning",
|
||||||
|
"facesitting",
|
||||||
|
"handjob",
|
||||||
|
"blowjob",
|
||||||
|
"boobjob",
|
||||||
|
"toy_play",
|
||||||
|
"fingering",
|
||||||
|
"69"
|
||||||
|
],
|
||||||
|
"bodyParts": [
|
||||||
|
"anus",
|
||||||
|
"ass",
|
||||||
|
"breasts",
|
||||||
|
"penis",
|
||||||
|
"tongue",
|
||||||
|
"pussy"
|
||||||
|
],
|
||||||
|
"objects": [
|
||||||
|
"blindfold",
|
||||||
|
"buttplug",
|
||||||
|
"collar",
|
||||||
|
"dildo",
|
||||||
|
"handcuffs",
|
||||||
|
"shower",
|
||||||
|
"strapon",
|
||||||
|
"towel",
|
||||||
|
"vibrator"
|
||||||
|
],
|
||||||
|
"clothing": [
|
||||||
|
"bikini",
|
||||||
|
"bra",
|
||||||
|
"dress",
|
||||||
|
"heels",
|
||||||
|
"hotpants",
|
||||||
|
"lingerie",
|
||||||
|
"panties",
|
||||||
|
"skirt",
|
||||||
|
"stockings",
|
||||||
|
"croptop"
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -13,10 +13,36 @@ def clamp01(v):
|
|||||||
return max(0.0, min(1.0, float(v)))
|
return max(0.0, min(1.0, float(v)))
|
||||||
|
|
||||||
|
|
||||||
|
def existing_file(path):
|
||||||
|
try:
|
||||||
|
p = Path(path).expanduser().resolve()
|
||||||
|
if p.exists() and p.is_file() and p.stat().st_size > 0:
|
||||||
|
return p
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_model_path(root, requested):
|
||||||
|
if requested:
|
||||||
|
p = existing_file(requested)
|
||||||
|
if p:
|
||||||
|
return p, "yolo26_model"
|
||||||
|
return Path(requested).expanduser(), "detector_missing"
|
||||||
|
|
||||||
|
trained = root / "detector" / "model" / "best.pt"
|
||||||
|
p = existing_file(trained)
|
||||||
|
if p:
|
||||||
|
return p, "yolo26_detector"
|
||||||
|
|
||||||
|
return trained, "detector_missing"
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--root", required=True)
|
parser.add_argument("--root", required=True)
|
||||||
parser.add_argument("--image", required=True)
|
parser.add_argument("--image", required=True)
|
||||||
|
parser.add_argument("--model", default="")
|
||||||
parser.add_argument("--conf", type=float, default=0.30)
|
parser.add_argument("--conf", type=float, default=0.30)
|
||||||
parser.add_argument("--imgsz", type=int, default=640)
|
parser.add_argument("--imgsz", type=int, default=640)
|
||||||
parser.add_argument("--debug-image", action="store_true")
|
parser.add_argument("--debug-image", action="store_true")
|
||||||
@ -25,11 +51,11 @@ def main():
|
|||||||
root = Path(args.root)
|
root = Path(args.root)
|
||||||
image_path = Path(args.image)
|
image_path = Path(args.image)
|
||||||
|
|
||||||
model_path = root / "detector" / "model" / "best.pt"
|
model_path, model_source = resolve_model_path(root, args.model)
|
||||||
if not model_path.exists():
|
if not model_path.exists():
|
||||||
print(json.dumps({
|
print(json.dumps({
|
||||||
"available": False,
|
"available": False,
|
||||||
"source": "detector_missing",
|
"source": model_source,
|
||||||
"modelPath": str(model_path),
|
"modelPath": str(model_path),
|
||||||
"boxes": [],
|
"boxes": [],
|
||||||
}, ensure_ascii=False))
|
}, ensure_ascii=False))
|
||||||
@ -124,7 +150,7 @@ def main():
|
|||||||
|
|
||||||
print(json.dumps({
|
print(json.dumps({
|
||||||
"available": True,
|
"available": True,
|
||||||
"source": "yolo26_detector",
|
"source": model_source,
|
||||||
"modelPath": str(model_path),
|
"modelPath": str(model_path),
|
||||||
"image": str(image_path),
|
"image": str(image_path),
|
||||||
"conf": float(args.conf),
|
"conf": float(args.conf),
|
||||||
|
|||||||
210
backend/ml/predict_pose_model.py
Normal file
210
backend/ml/predict_pose_model.py
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
# backend/ml/predict_pose_model.py
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
from ultralytics import YOLO
|
||||||
|
import torch
|
||||||
|
|
||||||
|
KEYPOINT_NAMES = [
|
||||||
|
"nose",
|
||||||
|
"left_eye", "right_eye",
|
||||||
|
"left_ear", "right_ear",
|
||||||
|
"left_shoulder", "right_shoulder",
|
||||||
|
"left_elbow", "right_elbow",
|
||||||
|
"left_wrist", "right_wrist",
|
||||||
|
"left_hip", "right_hip",
|
||||||
|
"left_knee", "right_knee",
|
||||||
|
"left_ankle", "right_ankle",
|
||||||
|
]
|
||||||
|
|
||||||
|
BASE_MODEL_NAME = "yolo26n-pose.pt"
|
||||||
|
|
||||||
|
|
||||||
|
def existing_file(path):
|
||||||
|
try:
|
||||||
|
p = Path(path).expanduser().resolve()
|
||||||
|
if p.exists() and p.is_file() and p.stat().st_size > 0:
|
||||||
|
return p
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def base_model_candidates(root):
|
||||||
|
script_dir = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
return [
|
||||||
|
script_dir / BASE_MODEL_NAME,
|
||||||
|
Path.cwd() / BASE_MODEL_NAME,
|
||||||
|
root / BASE_MODEL_NAME,
|
||||||
|
root.parent / BASE_MODEL_NAME,
|
||||||
|
root.parent.parent / BASE_MODEL_NAME,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_model_path(root, requested):
|
||||||
|
if requested:
|
||||||
|
p = existing_file(requested)
|
||||||
|
if p:
|
||||||
|
return p, "yolo_pose_model"
|
||||||
|
return Path(requested).expanduser(), "pose_missing"
|
||||||
|
|
||||||
|
trained = root / "pose" / "model" / "best.pt"
|
||||||
|
p = existing_file(trained)
|
||||||
|
if p:
|
||||||
|
return p, "yolo_pose"
|
||||||
|
|
||||||
|
for candidate in base_model_candidates(root):
|
||||||
|
p = existing_file(candidate)
|
||||||
|
if p:
|
||||||
|
return p, "yolo26_pose_base"
|
||||||
|
|
||||||
|
return trained, "pose_missing"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--root", required=True)
|
||||||
|
parser.add_argument("--image", required=True)
|
||||||
|
parser.add_argument("--model", default="")
|
||||||
|
parser.add_argument("--conf", type=float, default=0.30)
|
||||||
|
parser.add_argument("--imgsz", type=int, default=640)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
root = Path(args.root)
|
||||||
|
image_path = Path(args.image)
|
||||||
|
|
||||||
|
model_path, model_source = resolve_model_path(root, args.model)
|
||||||
|
if not model_path.exists():
|
||||||
|
print(json.dumps({
|
||||||
|
"available": False,
|
||||||
|
"source": model_source,
|
||||||
|
"modelPath": str(model_path),
|
||||||
|
"persons": [],
|
||||||
|
}, ensure_ascii=False))
|
||||||
|
return
|
||||||
|
|
||||||
|
img = Image.open(image_path).convert("RGB")
|
||||||
|
img_w, img_h = img.size
|
||||||
|
|
||||||
|
try:
|
||||||
|
model = YOLO(str(model_path))
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({
|
||||||
|
"available": False,
|
||||||
|
"source": "pose_load_failed",
|
||||||
|
"modelPath": str(model_path),
|
||||||
|
"error": repr(e),
|
||||||
|
"persons": [],
|
||||||
|
}, ensure_ascii=False))
|
||||||
|
return
|
||||||
|
|
||||||
|
device = 0 if torch.cuda.is_available() else "cpu"
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = model.predict(
|
||||||
|
source=str(image_path),
|
||||||
|
conf=float(args.conf),
|
||||||
|
imgsz=int(args.imgsz),
|
||||||
|
verbose=False,
|
||||||
|
device=device,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({
|
||||||
|
"available": False,
|
||||||
|
"source": "pose_predict_failed",
|
||||||
|
"modelPath": str(model_path),
|
||||||
|
"image": str(image_path),
|
||||||
|
"error": repr(e),
|
||||||
|
"persons": [],
|
||||||
|
}, ensure_ascii=False))
|
||||||
|
return
|
||||||
|
|
||||||
|
persons = []
|
||||||
|
|
||||||
|
if results:
|
||||||
|
r = results[0]
|
||||||
|
names = r.names or {}
|
||||||
|
|
||||||
|
kpts_xyn = None
|
||||||
|
kpts_conf = None
|
||||||
|
|
||||||
|
if r.keypoints is not None:
|
||||||
|
try:
|
||||||
|
kpts_xyn = r.keypoints.xyn.cpu().numpy()
|
||||||
|
except Exception:
|
||||||
|
kpts_xyn = None
|
||||||
|
try:
|
||||||
|
kpts_conf = r.keypoints.conf.cpu().numpy()
|
||||||
|
except Exception:
|
||||||
|
kpts_conf = None
|
||||||
|
|
||||||
|
if r.boxes is not None:
|
||||||
|
for i, b in enumerate(r.boxes):
|
||||||
|
score = float(b.conf[0].item())
|
||||||
|
label = ""
|
||||||
|
try:
|
||||||
|
cls_id = int(b.cls[0].item())
|
||||||
|
label = str(names.get(cls_id, cls_id)).strip().lower()
|
||||||
|
except Exception:
|
||||||
|
label = ""
|
||||||
|
|
||||||
|
x1, y1, x2, y2 = [float(v) for v in b.xyxy[0].tolist()]
|
||||||
|
x1 = max(0.0, min(float(img_w), x1))
|
||||||
|
y1 = max(0.0, min(float(img_h), y1))
|
||||||
|
x2 = max(0.0, min(float(img_w), x2))
|
||||||
|
y2 = max(0.0, min(float(img_h), y2))
|
||||||
|
|
||||||
|
if x2 <= x1 or y2 <= y1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
box = {
|
||||||
|
"x": x1 / img_w,
|
||||||
|
"y": y1 / img_h,
|
||||||
|
"w": (x2 - x1) / img_w,
|
||||||
|
"h": (y2 - y1) / img_h,
|
||||||
|
}
|
||||||
|
|
||||||
|
keypoints = []
|
||||||
|
if kpts_xyn is not None and i < len(kpts_xyn):
|
||||||
|
person_kpts = kpts_xyn[i]
|
||||||
|
for ki, (kx, ky) in enumerate(person_kpts):
|
||||||
|
kconf = 0.0
|
||||||
|
if kpts_conf is not None and i < len(kpts_conf) and ki < len(kpts_conf[i]):
|
||||||
|
kconf = float(kpts_conf[i][ki])
|
||||||
|
|
||||||
|
name = KEYPOINT_NAMES[ki] if ki < len(KEYPOINT_NAMES) else str(ki)
|
||||||
|
keypoints.append({
|
||||||
|
"name": name,
|
||||||
|
"x": float(kx),
|
||||||
|
"y": float(ky),
|
||||||
|
"conf": kconf,
|
||||||
|
})
|
||||||
|
|
||||||
|
persons.append({
|
||||||
|
"label": label,
|
||||||
|
"score": score,
|
||||||
|
"box": box,
|
||||||
|
"keypoints": keypoints,
|
||||||
|
})
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"available": True,
|
||||||
|
"source": model_source,
|
||||||
|
"modelPath": str(model_path),
|
||||||
|
"image": str(image_path),
|
||||||
|
"conf": float(args.conf),
|
||||||
|
"imgsz": int(args.imgsz),
|
||||||
|
"device": str(device),
|
||||||
|
"imageWidth": img_w,
|
||||||
|
"imageHeight": img_h,
|
||||||
|
"personCount": len(persons),
|
||||||
|
"persons": persons,
|
||||||
|
}, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
385
backend/ml/train_pose_model.py
Normal file
385
backend/ml/train_pose_model.py
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
# backend/ml/train_pose_model.py
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ultralytics import YOLO
|
||||||
|
from ultralytics.models.yolo.pose.train import PoseTrainer
|
||||||
|
|
||||||
|
BASE_MODEL_NAME = "yolo26n-pose.pt"
|
||||||
|
|
||||||
|
|
||||||
|
def emit_progress(stage, progress, message="", **extra):
|
||||||
|
out = {
|
||||||
|
"type": "progress",
|
||||||
|
"stage": stage,
|
||||||
|
"progress": max(0.0, min(1.0, float(progress))),
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
out.update(extra)
|
||||||
|
print(json.dumps(out, ensure_ascii=False), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def count_yolo_samples(dataset_root: Path, split: str) -> int:
|
||||||
|
images_dir = dataset_root / "images" / split
|
||||||
|
labels_dir = dataset_root / "labels" / split
|
||||||
|
|
||||||
|
if not images_dir.exists() or not labels_dir.exists():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
image_exts = {".jpg", ".jpeg", ".png", ".webp"}
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
for image_path in images_dir.iterdir():
|
||||||
|
if not image_path.is_file():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if image_path.suffix.lower() not in image_exts:
|
||||||
|
continue
|
||||||
|
|
||||||
|
label_path = labels_dir / f"{image_path.stem}.txt"
|
||||||
|
if label_path.exists() and label_path.is_file():
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def safe_int(value, fallback):
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except Exception:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def existing_file(path):
|
||||||
|
try:
|
||||||
|
p = Path(path).expanduser().resolve()
|
||||||
|
if p.exists() and p.is_file() and p.stat().st_size > 0:
|
||||||
|
return p
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def base_model_candidates(root):
|
||||||
|
script_dir = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
return [
|
||||||
|
script_dir / BASE_MODEL_NAME,
|
||||||
|
Path.cwd() / BASE_MODEL_NAME,
|
||||||
|
root / BASE_MODEL_NAME,
|
||||||
|
root.parent / BASE_MODEL_NAME,
|
||||||
|
root.parent.parent / BASE_MODEL_NAME,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_base_model(root, requested):
|
||||||
|
requested = str(requested or "").strip()
|
||||||
|
|
||||||
|
if requested and requested != BASE_MODEL_NAME:
|
||||||
|
p = existing_file(requested)
|
||||||
|
if p:
|
||||||
|
return str(p)
|
||||||
|
return requested
|
||||||
|
|
||||||
|
for candidate in base_model_candidates(root):
|
||||||
|
p = existing_file(candidate)
|
||||||
|
if p:
|
||||||
|
return str(p)
|
||||||
|
|
||||||
|
return requested or BASE_MODEL_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def batch_sample_ids(batch):
|
||||||
|
if not isinstance(batch, dict):
|
||||||
|
return []
|
||||||
|
|
||||||
|
image_paths = batch.get("im_file")
|
||||||
|
if not image_paths:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if isinstance(image_paths, (str, Path)):
|
||||||
|
image_paths = [image_paths]
|
||||||
|
|
||||||
|
out = []
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
for image_path in image_paths:
|
||||||
|
stem = Path(str(image_path)).stem.strip()
|
||||||
|
if stem and stem not in seen:
|
||||||
|
seen.add(stem)
|
||||||
|
out.append(stem)
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def progress_pose_trainer(train_count, val_count, train_device, fallback_epochs):
|
||||||
|
class ProgressPoseTrainer(PoseTrainer):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._preview_epoch = 0
|
||||||
|
self._preview_batch = 0
|
||||||
|
|
||||||
|
def preprocess_batch(self, batch):
|
||||||
|
epoch = int(getattr(self, "epoch", 0)) + 1
|
||||||
|
total_epochs = int(getattr(self, "epochs", fallback_epochs) or fallback_epochs)
|
||||||
|
|
||||||
|
if epoch != self._preview_epoch:
|
||||||
|
self._preview_epoch = epoch
|
||||||
|
self._preview_batch = 0
|
||||||
|
|
||||||
|
self._preview_batch += 1
|
||||||
|
|
||||||
|
sample_ids = batch_sample_ids(batch)
|
||||||
|
if sample_ids:
|
||||||
|
total_batches = max(1, len(self.train_loader))
|
||||||
|
completed = (epoch - 1) + min(1.0, self._preview_batch / total_batches)
|
||||||
|
progress = 0.04 + 0.90 * (completed / max(1, total_epochs))
|
||||||
|
|
||||||
|
emit_progress(
|
||||||
|
"pose",
|
||||||
|
progress,
|
||||||
|
"Pose Detector trainiert…",
|
||||||
|
epoch=epoch,
|
||||||
|
epochs=total_epochs,
|
||||||
|
sampleId=sample_ids[0],
|
||||||
|
trainSamples=train_count,
|
||||||
|
valSamples=val_count,
|
||||||
|
device=str(train_device),
|
||||||
|
)
|
||||||
|
|
||||||
|
return super().preprocess_batch(batch)
|
||||||
|
|
||||||
|
return ProgressPoseTrainer
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--root", required=True)
|
||||||
|
parser.add_argument("--base", default="yolo26n-pose.pt")
|
||||||
|
parser.add_argument("--epochs", default="80")
|
||||||
|
parser.add_argument("--imgsz", default="640")
|
||||||
|
parser.add_argument("--device", default="auto")
|
||||||
|
parser.add_argument("--workers", default="2")
|
||||||
|
parser.add_argument("--patience", default="20")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
import torch
|
||||||
|
|
||||||
|
root = Path(args.root).resolve()
|
||||||
|
dataset_root = root / "pose" / "dataset"
|
||||||
|
yaml_path = dataset_root / "dataset.yaml"
|
||||||
|
runs_dir = root / "pose" / "runs"
|
||||||
|
out_dir = root / "pose" / "model"
|
||||||
|
|
||||||
|
epochs = max(1, safe_int(args.epochs, 80))
|
||||||
|
imgsz = max(64, safe_int(args.imgsz, 640))
|
||||||
|
workers = max(0, safe_int(args.workers, 2))
|
||||||
|
patience = max(0, safe_int(args.patience, 20))
|
||||||
|
|
||||||
|
if str(args.device).lower() == "auto":
|
||||||
|
train_device = 0 if torch.cuda.is_available() else "cpu"
|
||||||
|
else:
|
||||||
|
train_device = args.device
|
||||||
|
|
||||||
|
if not yaml_path.exists():
|
||||||
|
raise SystemExit(f"dataset.yaml not found: {yaml_path}")
|
||||||
|
|
||||||
|
train_count = count_yolo_samples(dataset_root, "train")
|
||||||
|
val_count = count_yolo_samples(dataset_root, "val")
|
||||||
|
|
||||||
|
emit_progress(
|
||||||
|
"pose",
|
||||||
|
0.01,
|
||||||
|
"YOLO-Pose-Dataset wird geprüft…",
|
||||||
|
trainSamples=train_count,
|
||||||
|
valSamples=val_count,
|
||||||
|
epochs=epochs,
|
||||||
|
imgsz=imgsz,
|
||||||
|
device=str(train_device),
|
||||||
|
)
|
||||||
|
|
||||||
|
if train_count <= 0:
|
||||||
|
raise SystemExit("no YOLO pose train samples found")
|
||||||
|
|
||||||
|
if val_count <= 0:
|
||||||
|
raise SystemExit("no YOLO pose val samples found")
|
||||||
|
|
||||||
|
emit_progress(
|
||||||
|
"pose",
|
||||||
|
0.03,
|
||||||
|
"YOLO-Pose-Basismodell wird geladen…",
|
||||||
|
base=resolve_base_model(root, args.base),
|
||||||
|
device=str(train_device),
|
||||||
|
)
|
||||||
|
|
||||||
|
base_model = resolve_base_model(root, args.base)
|
||||||
|
model = YOLO(base_model)
|
||||||
|
|
||||||
|
best_epoch = 0
|
||||||
|
last_epoch = 0
|
||||||
|
best_map50 = 0.0
|
||||||
|
best_map5095 = 0.0
|
||||||
|
|
||||||
|
def on_train_epoch_start(trainer):
|
||||||
|
epoch = int(getattr(trainer, "epoch", 0)) + 1
|
||||||
|
total = int(getattr(trainer, "epochs", epochs) or epochs)
|
||||||
|
|
||||||
|
emit_progress(
|
||||||
|
"pose",
|
||||||
|
0.04 + 0.90 * ((epoch - 1) / max(1, total)),
|
||||||
|
"Pose Detector trainiert…",
|
||||||
|
epoch=epoch,
|
||||||
|
epochs=total,
|
||||||
|
trainSamples=train_count,
|
||||||
|
valSamples=val_count,
|
||||||
|
device=str(train_device),
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_train_epoch_end(trainer):
|
||||||
|
nonlocal last_epoch
|
||||||
|
|
||||||
|
epoch = int(getattr(trainer, "epoch", 0)) + 1
|
||||||
|
total = int(getattr(trainer, "epochs", epochs) or epochs)
|
||||||
|
last_epoch = max(last_epoch, epoch)
|
||||||
|
|
||||||
|
emit_progress(
|
||||||
|
"pose",
|
||||||
|
0.04 + 0.90 * (epoch / max(1, total)),
|
||||||
|
"Pose Detector trainiert…",
|
||||||
|
epoch=epoch,
|
||||||
|
epochs=total,
|
||||||
|
trainSamples=train_count,
|
||||||
|
valSamples=val_count,
|
||||||
|
device=str(train_device),
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_fit_epoch_end(trainer):
|
||||||
|
nonlocal best_epoch, best_map50, best_map5095
|
||||||
|
|
||||||
|
epoch = int(getattr(trainer, "epoch", 0)) + 1
|
||||||
|
total = int(getattr(trainer, "epochs", epochs) or epochs)
|
||||||
|
metrics = getattr(trainer, "metrics", None) or {}
|
||||||
|
|
||||||
|
map50 = (
|
||||||
|
metrics.get("metrics/mAP50(B)")
|
||||||
|
or metrics.get("metrics/mAP50")
|
||||||
|
or metrics.get("mAP50")
|
||||||
|
)
|
||||||
|
map5095 = (
|
||||||
|
metrics.get("metrics/mAP50-95(B)")
|
||||||
|
or metrics.get("metrics/mAP50-95")
|
||||||
|
or metrics.get("mAP50-95")
|
||||||
|
)
|
||||||
|
|
||||||
|
if map50 is not None or map5095 is not None:
|
||||||
|
best_epoch = epoch
|
||||||
|
|
||||||
|
m50 = float(map50) if map50 is not None else 0.0
|
||||||
|
if m50 >= best_map50:
|
||||||
|
best_map50 = m50
|
||||||
|
if map5095 is not None:
|
||||||
|
best_map5095 = float(map5095)
|
||||||
|
|
||||||
|
emit_progress(
|
||||||
|
"pose",
|
||||||
|
0.04 + 0.90 * (epoch / max(1, total)),
|
||||||
|
"Pose Detector validiert…",
|
||||||
|
epoch=epoch,
|
||||||
|
epochs=total,
|
||||||
|
mAP50=map50,
|
||||||
|
mAP5095=map5095,
|
||||||
|
device=str(train_device),
|
||||||
|
)
|
||||||
|
|
||||||
|
model.add_callback("on_train_epoch_start", on_train_epoch_start)
|
||||||
|
model.add_callback("on_train_epoch_end", on_train_epoch_end)
|
||||||
|
model.add_callback("on_fit_epoch_end", on_fit_epoch_end)
|
||||||
|
|
||||||
|
emit_progress(
|
||||||
|
"pose",
|
||||||
|
0.05,
|
||||||
|
"Pose Detector Training startet…",
|
||||||
|
trainSamples=train_count,
|
||||||
|
valSamples=val_count,
|
||||||
|
epochs=epochs,
|
||||||
|
imgsz=imgsz,
|
||||||
|
device=str(train_device),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = model.train(
|
||||||
|
trainer=progress_pose_trainer(
|
||||||
|
train_count,
|
||||||
|
val_count,
|
||||||
|
train_device,
|
||||||
|
epochs,
|
||||||
|
),
|
||||||
|
data=str(yaml_path),
|
||||||
|
epochs=epochs,
|
||||||
|
imgsz=imgsz,
|
||||||
|
project=str(runs_dir),
|
||||||
|
name="pose",
|
||||||
|
exist_ok=True,
|
||||||
|
device=train_device,
|
||||||
|
workers=workers,
|
||||||
|
patience=patience,
|
||||||
|
)
|
||||||
|
|
||||||
|
emit_progress(
|
||||||
|
"pose",
|
||||||
|
0.96,
|
||||||
|
"Bestes YOLO-Pose-Modell wird übernommen…",
|
||||||
|
lastEpoch=last_epoch,
|
||||||
|
bestEpoch=best_epoch,
|
||||||
|
device=str(train_device),
|
||||||
|
)
|
||||||
|
|
||||||
|
best = runs_dir / "pose" / "weights" / "best.pt"
|
||||||
|
last = runs_dir / "pose" / "weights" / "last.pt"
|
||||||
|
|
||||||
|
if not best.exists():
|
||||||
|
if last.exists():
|
||||||
|
best = last
|
||||||
|
else:
|
||||||
|
raise SystemExit(f"best.pt not found after training: {best}")
|
||||||
|
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
final_model = out_dir / "best.pt"
|
||||||
|
shutil.copy2(best, final_model)
|
||||||
|
|
||||||
|
result_path = runs_dir / "pose"
|
||||||
|
status = {
|
||||||
|
"ok": True,
|
||||||
|
"model": str(final_model),
|
||||||
|
"sourceModel": str(best),
|
||||||
|
"runs": str(result_path),
|
||||||
|
"trainedAt": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"trainSamples": train_count,
|
||||||
|
"valSamples": val_count,
|
||||||
|
"epochs": epochs,
|
||||||
|
"imgsz": imgsz,
|
||||||
|
"device": str(train_device),
|
||||||
|
"mAP50": round(best_map50, 4),
|
||||||
|
"mAP5095": round(best_map5095, 4),
|
||||||
|
"bestEpoch": best_epoch,
|
||||||
|
}
|
||||||
|
|
||||||
|
with (out_dir / "status.json").open("w", encoding="utf-8") as f:
|
||||||
|
json.dump(status, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
emit_progress(
|
||||||
|
"pose",
|
||||||
|
1.0,
|
||||||
|
"Pose Detector fertig.",
|
||||||
|
**status,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(json.dumps(status, ensure_ascii=False), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -121,10 +121,12 @@ func isPersonSegmentLabel(label string) bool {
|
|||||||
|
|
||||||
func isKnownPositionLabel(label string) bool {
|
func isKnownPositionLabel(label string) bool {
|
||||||
label = strings.ToLower(strings.TrimSpace(label))
|
label = strings.ToLower(strings.TrimSpace(label))
|
||||||
|
if isNoSexPositionLabel(label) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
switch label {
|
switch label {
|
||||||
case "unknown",
|
case "missionary",
|
||||||
"missionary",
|
|
||||||
"doggy",
|
"doggy",
|
||||||
"doggystyle",
|
"doggystyle",
|
||||||
"cowgirl",
|
"cowgirl",
|
||||||
@ -306,7 +308,7 @@ type ratingSignalSet struct {
|
|||||||
|
|
||||||
func normalizeRatingSignalLabel(label string) string {
|
func normalizeRatingSignalLabel(label string) string {
|
||||||
label = strings.ToLower(strings.TrimSpace(label))
|
label = strings.ToLower(strings.TrimSpace(label))
|
||||||
if label == "" || label == "unknown" {
|
if isNoSexPositionLabel(label) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -359,7 +361,7 @@ func normalizeRatingSignalLabel(label string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
raw := normalizeSegmentLabel(label)
|
raw := normalizeSegmentLabel(label)
|
||||||
if raw == "" || raw == "unknown" {
|
if isNoSexPositionLabel(raw) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1540,13 +1540,21 @@ func recordStop(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
jobsMu.RLock()
|
jobsMu.RLock()
|
||||||
job, ok := jobs[id]
|
job, ok := jobs[id]
|
||||||
|
shouldStop := ok &&
|
||||||
|
job != nil &&
|
||||||
|
job.EndedAt == nil &&
|
||||||
|
!isTerminalJobStatus(job.Status) &&
|
||||||
|
!isPostworkJob(job)
|
||||||
jobsMu.RUnlock()
|
jobsMu.RUnlock()
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "job nicht gefunden", http.StatusNotFound)
|
http.Error(w, "job nicht gefunden", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if shouldStop {
|
||||||
stopJobsInternal([]*RecordJob{job})
|
stopJobsInternal([]*RecordJob{job})
|
||||||
|
}
|
||||||
|
|
||||||
respondJSON(w, job)
|
respondJSON(w, job)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1206,6 +1206,13 @@ func recordHLSPlaylistSegments(
|
|||||||
|
|
||||||
shouldFinalize := parentCanceled || endOfStream || finalErr == nil
|
shouldFinalize := parentCanceled || endOfStream || finalErr == nil
|
||||||
|
|
||||||
|
if parentCanceled {
|
||||||
|
if verboseLogs() {
|
||||||
|
appLogln("⏹️", logPrefix, "stop requested; deferring audio/video mux to postwork")
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
if shouldFinalize {
|
if shouldFinalize {
|
||||||
if !fileExistsNonEmpty(outFile) {
|
if !fileExistsNonEmpty(outFile) {
|
||||||
if finalErr != nil {
|
if finalErr != nil {
|
||||||
|
|||||||
@ -63,7 +63,11 @@ func RecordStreamMFC(
|
|||||||
return appErrorf("mfc status blieb %s für %s", lastStatus, username)
|
return appErrorf("mfc status blieb %s für %s", lastStatus, username)
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(5 * time.Second)
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if job != nil {
|
if job != nil {
|
||||||
|
|||||||
@ -354,8 +354,6 @@ func waitForUsableOutput(path string, timeout time.Duration) (os.FileInfo, error
|
|||||||
lastErr = appErrorf("stat returned nil")
|
lastErr = appErrorf("stat returned nil")
|
||||||
} else if fi.IsDir() {
|
} else if fi.IsDir() {
|
||||||
lastErr = appErrorf("path is dir")
|
lastErr = appErrorf("path is dir")
|
||||||
} else {
|
|
||||||
lastErr = appErrorf("file size is 0")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if time.Now().After(deadline) {
|
if time.Now().After(deadline) {
|
||||||
@ -411,8 +409,6 @@ func waitForStableUsableOutput(path string, timeout time.Duration, stableFor tim
|
|||||||
lastErr = appErrorf("stat returned nil")
|
lastErr = appErrorf("stat returned nil")
|
||||||
} else if fi.IsDir() {
|
} else if fi.IsDir() {
|
||||||
lastErr = appErrorf("path is dir")
|
lastErr = appErrorf("path is dir")
|
||||||
} else {
|
|
||||||
lastErr = appErrorf("file size is 0")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -112,6 +112,7 @@ var (
|
|||||||
aiServerMu sync.Mutex
|
aiServerMu sync.Mutex
|
||||||
aiServerCurrent *aiServerProcess
|
aiServerCurrent *aiServerProcess
|
||||||
aiServerCtx context.Context
|
aiServerCtx context.Context
|
||||||
|
aiServerRoot string
|
||||||
)
|
)
|
||||||
|
|
||||||
func aiServerAutostartEnabled() bool {
|
func aiServerAutostartEnabled() bool {
|
||||||
@ -349,13 +350,17 @@ func findTrainingRootDir(scriptDir string) (trainingRoot string, appBaseDir stri
|
|||||||
|
|
||||||
// Wichtig für Entwicklung:
|
// Wichtig für Entwicklung:
|
||||||
// findAIServerScriptDir() findet meistens dein backend-Verzeichnis.
|
// findAIServerScriptDir() findet meistens dein backend-Verzeichnis.
|
||||||
|
if scriptDir != "" && !isTempBuildDir(scriptDir) {
|
||||||
add(scriptDir)
|
add(scriptDir)
|
||||||
|
}
|
||||||
|
|
||||||
// Falls du aus repo-root oder backend startest.
|
// Falls du aus repo-root oder backend startest.
|
||||||
add(cwd)
|
add(cwd)
|
||||||
|
|
||||||
// Wichtig für gebaute EXE.
|
// Wichtig für gebaute EXE.
|
||||||
|
if exeDir != "" && !isTempBuildDir(exeDir) {
|
||||||
add(exeDir)
|
add(exeDir)
|
||||||
|
}
|
||||||
|
|
||||||
// Bevorzugt den Pfad, wo detection_labels.json liegt.
|
// Bevorzugt den Pfad, wo detection_labels.json liegt.
|
||||||
for _, c := range candidates {
|
for _, c := range candidates {
|
||||||
@ -472,6 +477,7 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
trainingRoot, appBaseDir := findTrainingRootDir(scriptDir)
|
trainingRoot, appBaseDir := findTrainingRootDir(scriptDir)
|
||||||
|
aiServerRoot = trainingRoot
|
||||||
|
|
||||||
mlDir, mlErr := trainingEmbeddedMLDir()
|
mlDir, mlErr := trainingEmbeddedMLDir()
|
||||||
if mlErr != nil {
|
if mlErr != nil {
|
||||||
@ -493,7 +499,8 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
|
|||||||
detectionLabelsPath = filepath.Clean(detectionLabelsPath)
|
detectionLabelsPath = filepath.Clean(detectionLabelsPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultModelPath := filepath.Join(trainingRoot, "detector", "model", "best.pt")
|
defaultModel := trainingResolveDetectorModel(trainingRoot)
|
||||||
|
defaultModelPath := defaultModel.EffectivePath
|
||||||
|
|
||||||
pythonPath := aiServerPythonPath()
|
pythonPath := aiServerPythonPath()
|
||||||
port := aiServerPortFromURL()
|
port := aiServerPortFromURL()
|
||||||
@ -550,7 +557,7 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
|
|||||||
if fi, err := os.Stat(defaultModelPath); err != nil || fi == nil || fi.IsDir() {
|
if fi, err := os.Stat(defaultModelPath); err != nil || fi == nil || fi.IsDir() {
|
||||||
appLogln("⚠️ YOLO Modell nicht gefunden:", defaultModelPath)
|
appLogln("⚠️ YOLO Modell nicht gefunden:", defaultModelPath)
|
||||||
} else {
|
} else {
|
||||||
appLogln("✅ YOLO Modell:", defaultModelPath)
|
appLogln("✅ YOLO Modell:", defaultModelPath, "Quelle:", defaultModel.Source)
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(os.Getenv("YOLO_IMGSZ")) == "" {
|
if strings.TrimSpace(os.Getenv("YOLO_IMGSZ")) == "" {
|
||||||
@ -647,8 +654,10 @@ func restartAIServer() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if aiServerCurrent != nil {
|
|
||||||
appLogln("🔄 AI Server wird neu gestartet…")
|
appLogln("🔄 AI Server wird neu gestartet…")
|
||||||
|
publishAppNotification("info", "AI Server", "AI Server wird neu gestartet…", 2200)
|
||||||
|
|
||||||
|
if aiServerCurrent != nil {
|
||||||
aiServerCurrent.Stop()
|
aiServerCurrent.Stop()
|
||||||
aiServerCurrent = nil
|
aiServerCurrent = nil
|
||||||
}
|
}
|
||||||
@ -656,6 +665,7 @@ func restartAIServer() {
|
|||||||
proc, err := startAIServer(ctx)
|
proc, err := startAIServer(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
appLogln("⚠️ AI Server Neustart fehlgeschlagen:", err)
|
appLogln("⚠️ AI Server Neustart fehlgeschlagen:", err)
|
||||||
|
publishAppNotification("warning", "AI Server", "Neustart fehlgeschlagen: "+err.Error(), 5000)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -663,6 +673,97 @@ func restartAIServer() {
|
|||||||
|
|
||||||
if proc != nil {
|
if proc != nil {
|
||||||
appLogln("✅ AI Server neu gestartet.")
|
appLogln("✅ AI Server neu gestartet.")
|
||||||
|
publishAppNotification("success", "AI Server", "AI Server wurde neu gestartet.", 2600)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type trainingBestModelSnapshot struct {
|
||||||
|
exists bool
|
||||||
|
size int64
|
||||||
|
modTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotTrainingBestModel(path string) trainingBestModelSnapshot {
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||||
|
return trainingBestModelSnapshot{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return trainingBestModelSnapshot{
|
||||||
|
exists: true,
|
||||||
|
size: fi.Size(),
|
||||||
|
modTime: fi.ModTime(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s trainingBestModelSnapshot) changed(next trainingBestModelSnapshot) bool {
|
||||||
|
if s.exists != next.exists {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.size != next.size || !s.modTime.Equal(next.modTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startTrainingBestModelWatcher(ctx context.Context, trainingRoot string) {
|
||||||
|
trainingRoot = filepath.Clean(strings.TrimSpace(trainingRoot))
|
||||||
|
if trainingRoot == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
paths := []string{
|
||||||
|
filepath.Join(trainingRoot, "detector", "model", "best.pt"),
|
||||||
|
filepath.Join(trainingRoot, "pose", "model", "best.pt"),
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshots := make(map[string]trainingBestModelSnapshot, len(paths))
|
||||||
|
for _, path := range paths {
|
||||||
|
snapshots[path] = snapshotTrainingBestModel(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
appLogln("👁️ Training-Model-Watcher aktiv:", strings.Join(paths, " | "))
|
||||||
|
|
||||||
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
var lastRestart time.Time
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
changedPaths := []string{}
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
|
previous := snapshots[path]
|
||||||
|
next := snapshotTrainingBestModel(path)
|
||||||
|
|
||||||
|
if previous.changed(next) {
|
||||||
|
snapshots[path] = next
|
||||||
|
|
||||||
|
if next.exists {
|
||||||
|
changedPaths = append(changedPaths, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(changedPaths) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(lastRestart) < 5*time.Second {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
lastRestart = time.Now()
|
||||||
|
appLogln("🔄 Training-Modell geändert:", strings.Join(changedPaths, " | "))
|
||||||
|
go restartAIServer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -864,8 +965,11 @@ func main() {
|
|||||||
aiServerMu.Lock()
|
aiServerMu.Lock()
|
||||||
aiServerCtx = appCtx
|
aiServerCtx = appCtx
|
||||||
aiServerCurrent = aiProc
|
aiServerCurrent = aiProc
|
||||||
|
watchedTrainingRoot := aiServerRoot
|
||||||
aiServerMu.Unlock()
|
aiServerMu.Unlock()
|
||||||
|
|
||||||
|
go startTrainingBestModelWatcher(appCtx, watchedTrainingRoot)
|
||||||
|
|
||||||
// ✅ Hier: alte manuelle Autostart-Pause beim echten App-Start zurücksetzen.
|
// ✅ Hier: alte manuelle Autostart-Pause beim echten App-Start zurücksetzen.
|
||||||
resetAutostartPauseOnStartup()
|
resetAutostartPauseOnStartup()
|
||||||
|
|
||||||
|
|||||||
103
backend/sse.go
103
backend/sse.go
@ -29,6 +29,8 @@ type jobEvent struct {
|
|||||||
Status JobStatus `json:"status"`
|
Status JobStatus `json:"status"`
|
||||||
Phase string `json:"phase,omitempty"`
|
Phase string `json:"phase,omitempty"`
|
||||||
Progress int `json:"progress,omitempty"`
|
Progress int `json:"progress,omitempty"`
|
||||||
|
StopRequestedAtMs int64 `json:"stopRequestedAtMs,omitempty"`
|
||||||
|
StopAttempts int `json:"stopAttempts,omitempty"`
|
||||||
SourceURL string `json:"sourceUrl,omitempty"`
|
SourceURL string `json:"sourceUrl,omitempty"`
|
||||||
Output string `json:"output,omitempty"`
|
Output string `json:"output,omitempty"`
|
||||||
StartedAt string `json:"startedAt,omitempty"`
|
StartedAt string `json:"startedAt,omitempty"`
|
||||||
@ -82,6 +84,42 @@ type taskStateEvent struct {
|
|||||||
TS int64 `json:"ts"`
|
TS int64 `json:"ts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type appNotificationEvent struct {
|
||||||
|
Type string `json:"type"` // "notification"
|
||||||
|
Level string `json:"level"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
DurationMs int `json:"durationMs,omitempty"`
|
||||||
|
TS int64 `json:"ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishAppNotification(level string, title string, message string, durationMs int) {
|
||||||
|
level = strings.TrimSpace(strings.ToLower(level))
|
||||||
|
if level == "" {
|
||||||
|
level = "info"
|
||||||
|
}
|
||||||
|
|
||||||
|
ev := appNotificationEvent{
|
||||||
|
Type: "notification",
|
||||||
|
Level: level,
|
||||||
|
Title: strings.TrimSpace(title),
|
||||||
|
Message: strings.TrimSpace(message),
|
||||||
|
DurationMs: durationMs,
|
||||||
|
TS: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if ev.Title == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(ev)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
publishSSE("notification", b)
|
||||||
|
}
|
||||||
|
|
||||||
type analysisProgressEvent struct {
|
type analysisProgressEvent struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Scope string `json:"scope,omitempty"`
|
Scope string `json:"scope,omitempty"`
|
||||||
@ -419,6 +457,8 @@ func visibleJobEventsJSON() []ssePublishItem {
|
|||||||
Status: j.Status,
|
Status: j.Status,
|
||||||
Phase: j.Phase,
|
Phase: j.Phase,
|
||||||
Progress: j.Progress,
|
Progress: j.Progress,
|
||||||
|
StopRequestedAtMs: j.StopRequestedAtMs,
|
||||||
|
StopAttempts: j.StopAttempts,
|
||||||
SourceURL: j.SourceURL,
|
SourceURL: j.SourceURL,
|
||||||
Output: j.Output,
|
Output: j.Output,
|
||||||
StartedAt: j.StartedAt.Format(time.RFC3339Nano),
|
StartedAt: j.StartedAt.Format(time.RFC3339Nano),
|
||||||
@ -478,6 +518,69 @@ func publishJobRemove(j *RecordJob) {
|
|||||||
publishSSE(eventName, b)
|
publishSSE(eventName, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func publishModelJobUpsert(jobID string) bool {
|
||||||
|
jobsMu.RLock()
|
||||||
|
j := jobs[jobID]
|
||||||
|
if j == nil || j.Hidden {
|
||||||
|
jobsMu.RUnlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
eventName := sseModelEventNameForJob(j)
|
||||||
|
if eventName == "" {
|
||||||
|
jobsMu.RUnlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var postWork any
|
||||||
|
if j.PostWork != nil {
|
||||||
|
tmp := *j.PostWork
|
||||||
|
postWork = &tmp
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := jobEvent{
|
||||||
|
Type: "job_upsert",
|
||||||
|
Model: eventName,
|
||||||
|
JobID: j.ID,
|
||||||
|
Status: j.Status,
|
||||||
|
Phase: j.Phase,
|
||||||
|
Progress: j.Progress,
|
||||||
|
StopRequestedAtMs: j.StopRequestedAtMs,
|
||||||
|
StopAttempts: j.StopAttempts,
|
||||||
|
SourceURL: j.SourceURL,
|
||||||
|
Output: j.Output,
|
||||||
|
StartedAt: j.StartedAt.Format(time.RFC3339Nano),
|
||||||
|
StartedAtMs: j.StartedAtMs,
|
||||||
|
SizeBytes: j.SizeBytes,
|
||||||
|
DurationSeconds: j.DurationSeconds,
|
||||||
|
PreviewState: j.PreviewState,
|
||||||
|
PostWorkKey: strings.TrimSpace(j.PostWorkKey),
|
||||||
|
PostWork: postWork,
|
||||||
|
TS: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if sm := sseStoredModelForJob(j); sm != nil {
|
||||||
|
payload.RoomStatus = strings.ToLower(strings.TrimSpace(sm.RoomStatus))
|
||||||
|
payload.IsOnline = sm.IsOnline
|
||||||
|
payload.ModelImageURL = strings.TrimSpace(sm.ImageURL)
|
||||||
|
payload.ModelChatRoomURL = strings.TrimSpace(sm.ChatRoomURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if j.EndedAt != nil {
|
||||||
|
payload.EndedAt = j.EndedAt.Format(time.RFC3339Nano)
|
||||||
|
payload.EndedAtMs = j.EndedAtMs
|
||||||
|
}
|
||||||
|
jobsMu.RUnlock()
|
||||||
|
|
||||||
|
b, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
publishSSE(eventName, b)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func sseModelEventNameForJob(j *RecordJob) string {
|
func sseModelEventNameForJob(j *RecordJob) string {
|
||||||
if j == nil {
|
if j == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -18,6 +18,56 @@ type TrainingGroupedLabels struct {
|
|||||||
Clothing []string `json:"clothing"`
|
Clothing []string `json:"clothing"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trainingNoSexPositionLabel = "keine"
|
||||||
|
|
||||||
|
func isNoSexPositionLabel(label string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(label)) {
|
||||||
|
case "", trainingNoSexPositionLabel:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSexPositionLabel(label string) string {
|
||||||
|
clean := strings.TrimSpace(label)
|
||||||
|
if isNoSexPositionLabel(clean) {
|
||||||
|
return trainingNoSexPositionLabel
|
||||||
|
}
|
||||||
|
return clean
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSexPositionLabels(values []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := []string{}
|
||||||
|
hasNoPosition := false
|
||||||
|
|
||||||
|
for _, value := range values {
|
||||||
|
label := strings.TrimSpace(value)
|
||||||
|
if label == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if isNoSexPositionLabel(label) {
|
||||||
|
hasNoPosition = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if seen[label] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
seen[label] = true
|
||||||
|
out = append(out, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasNoPosition {
|
||||||
|
out = append([]string{trainingNoSexPositionLabel}, out...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func trainingDetectionLabelsPath() string {
|
func trainingDetectionLabelsPath() string {
|
||||||
if p, err := ensureTrainingDetectionLabelsFile(); err == nil {
|
if p, err := ensureTrainingDetectionLabelsFile(); err == nil {
|
||||||
return p
|
return p
|
||||||
@ -114,13 +164,13 @@ func trainingGroupedLabels() (TrainingGroupedLabels, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
grouped.People = uniqueNonEmptyLabels(grouped.People)
|
grouped.People = uniqueNonEmptyLabels(grouped.People)
|
||||||
grouped.SexPositions = uniqueNonEmptyLabels(grouped.SexPositions)
|
grouped.SexPositions = normalizeSexPositionLabels(grouped.SexPositions)
|
||||||
grouped.BodyParts = uniqueNonEmptyLabels(grouped.BodyParts)
|
grouped.BodyParts = uniqueNonEmptyLabels(grouped.BodyParts)
|
||||||
grouped.Objects = uniqueNonEmptyLabels(grouped.Objects)
|
grouped.Objects = uniqueNonEmptyLabels(grouped.Objects)
|
||||||
grouped.Clothing = uniqueNonEmptyLabels(grouped.Clothing)
|
grouped.Clothing = uniqueNonEmptyLabels(grouped.Clothing)
|
||||||
|
|
||||||
if len(grouped.SexPositions) == 0 {
|
if len(grouped.SexPositions) == 0 {
|
||||||
grouped.SexPositions = []string{"unknown"}
|
grouped.SexPositions = []string{trainingNoSexPositionLabel}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(grouped.People)+len(grouped.SexPositions)+len(grouped.BodyParts)+len(grouped.Objects)+len(grouped.Clothing) == 0 {
|
if len(grouped.People)+len(grouped.SexPositions)+len(grouped.BodyParts)+len(grouped.Objects)+len(grouped.Clothing) == 0 {
|
||||||
@ -146,7 +196,7 @@ func trainingDetectorLabels() ([]string, error) {
|
|||||||
|
|
||||||
for _, label := range grouped.SexPositions {
|
for _, label := range grouped.SexPositions {
|
||||||
clean := strings.TrimSpace(label)
|
clean := strings.TrimSpace(label)
|
||||||
if clean == "" || clean == "unknown" {
|
if isNoSexPositionLabel(clean) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -205,11 +255,65 @@ func trainingDetectorDatasetNamesYAML() (string, error) {
|
|||||||
return b.String(), nil
|
return b.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func trainingPoseLabels() ([]string, error) {
|
||||||
|
grouped, err := trainingGroupedLabels()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
labels := []string{}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
|
||||||
|
for _, value := range grouped.SexPositions {
|
||||||
|
label := strings.TrimSpace(value)
|
||||||
|
if isNoSexPositionLabel(label) || seen[label] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
seen[label] = true
|
||||||
|
labels = append(labels, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(labels) == 0 {
|
||||||
|
return nil, fmt.Errorf("no pose sex position labels configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func trainingPoseClassMap() (map[string]int, error) {
|
||||||
|
labels, err := trainingPoseLabels()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := map[string]int{}
|
||||||
|
for i, label := range labels {
|
||||||
|
out[label] = i
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func trainingPoseDatasetNamesYAML() (string, error) {
|
||||||
|
labels, err := trainingPoseLabels()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for i, label := range labels {
|
||||||
|
b.WriteString(fmt.Sprintf(" %d: %s\n", i, label))
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func defaultTrainingLabelsFromJSON() TrainingLabels {
|
func defaultTrainingLabelsFromJSON() TrainingLabels {
|
||||||
grouped, err := trainingGroupedLabels()
|
grouped, err := trainingGroupedLabels()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
grouped = TrainingGroupedLabels{
|
grouped = TrainingGroupedLabels{
|
||||||
SexPositions: []string{"unknown"},
|
SexPositions: []string{trainingNoSexPositionLabel},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -124,8 +124,8 @@ func TestTrainingEffectiveCorrectionClearsNegativeAnnotation(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if effective.SexPosition != "unknown" {
|
if effective.SexPosition != trainingNoSexPositionLabel {
|
||||||
t.Fatalf("sex position = %q, want unknown", effective.SexPosition)
|
t.Fatalf("sex position = %q, want %s", effective.SexPosition, trainingNoSexPositionLabel)
|
||||||
}
|
}
|
||||||
if len(effective.Boxes) != 0 {
|
if len(effective.Boxes) != 0 {
|
||||||
t.Fatalf("negative annotation has %d boxes, want 0", len(effective.Boxes))
|
t.Fatalf("negative annotation has %d boxes, want 0", len(effective.Boxes))
|
||||||
|
|||||||
BIN
backend/yolo26n-pose.pt
Normal file
BIN
backend/yolo26n-pose.pt
Normal file
Binary file not shown.
Binary file not shown.
@ -2956,6 +2956,40 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onNotification = (ev: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(String(ev.data ?? 'null'))
|
||||||
|
|
||||||
|
if (data?.type !== 'notification') return
|
||||||
|
|
||||||
|
const title = String(data?.title ?? '').trim()
|
||||||
|
if (!title) return
|
||||||
|
|
||||||
|
const message = String(data?.message ?? '').trim() || undefined
|
||||||
|
const durationRaw = Number(data?.durationMs ?? 0)
|
||||||
|
const opts = Number.isFinite(durationRaw) && durationRaw > 0
|
||||||
|
? { durationMs: durationRaw }
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
switch (String(data?.level ?? 'info').trim().toLowerCase()) {
|
||||||
|
case 'success':
|
||||||
|
notifyRef.current?.success(title, message, opts)
|
||||||
|
break
|
||||||
|
case 'warning':
|
||||||
|
notifyRef.current?.warning(title, message, opts)
|
||||||
|
break
|
||||||
|
case 'error':
|
||||||
|
notifyRef.current?.error(title, message, opts)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
notifyRef.current?.info(title, message, opts)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void loadJobs()
|
void loadJobs()
|
||||||
void loadDoneCount()
|
void loadDoneCount()
|
||||||
void loadTaskStateOnce()
|
void loadTaskStateOnce()
|
||||||
@ -2981,6 +3015,7 @@ export default function App() {
|
|||||||
es.addEventListener('taskState', onTaskState as any)
|
es.addEventListener('taskState', onTaskState as any)
|
||||||
es.addEventListener('training', onTraining as any)
|
es.addEventListener('training', onTraining as any)
|
||||||
es.addEventListener('analysisProgress', onAnalysisProgress as any)
|
es.addEventListener('analysisProgress', onAnalysisProgress as any)
|
||||||
|
es.addEventListener('notification', onNotification as any)
|
||||||
|
|
||||||
const onVis = () => {
|
const onVis = () => {
|
||||||
if (document.hidden) return
|
if (document.hidden) return
|
||||||
@ -3008,6 +3043,7 @@ export default function App() {
|
|||||||
es.removeEventListener('taskState', onTaskState as any)
|
es.removeEventListener('taskState', onTaskState as any)
|
||||||
es.removeEventListener('training', onTraining as any)
|
es.removeEventListener('training', onTraining as any)
|
||||||
es.removeEventListener('analysisProgress', onAnalysisProgress as any)
|
es.removeEventListener('analysisProgress', onAnalysisProgress as any)
|
||||||
|
es.removeEventListener('notification', onNotification as any)
|
||||||
|
|
||||||
const handler = onModelJobEventRef.current
|
const handler = onModelJobEventRef.current
|
||||||
if (handler) {
|
if (handler) {
|
||||||
@ -3110,6 +3146,7 @@ export default function App() {
|
|||||||
async function stopJob(id: string) {
|
async function stopJob(id: string) {
|
||||||
try {
|
try {
|
||||||
await apiJSON(`/api/record/stop?id=${encodeURIComponent(id)}`, { method: 'POST' })
|
await apiJSON(`/api/record/stop?id=${encodeURIComponent(id)}`, { method: 'POST' })
|
||||||
|
void loadJobs()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
notify.error('Stop fehlgeschlagen', e?.message ?? String(e))
|
notify.error('Stop fehlgeschlagen', e?.message ?? String(e))
|
||||||
}
|
}
|
||||||
@ -3118,6 +3155,7 @@ export default function App() {
|
|||||||
async function stopAllJobs() {
|
async function stopAllJobs() {
|
||||||
try {
|
try {
|
||||||
await apiJSON('/api/record/stop-all', { method: 'POST' })
|
await apiJSON('/api/record/stop-all', { method: 'POST' })
|
||||||
|
void loadJobs()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
notify.error('Alle stoppen fehlgeschlagen', e?.message ?? String(e))
|
notify.error('Alle stoppen fehlgeschlagen', e?.message ?? String(e))
|
||||||
}
|
}
|
||||||
@ -3887,6 +3925,7 @@ export default function App() {
|
|||||||
}, [autoAddEnabled, autoStartEnabled, startUrl])
|
}, [autoAddEnabled, autoStartEnabled, startUrl])
|
||||||
|
|
||||||
const isTrainingTab = selectedTab === 'training'
|
const isTrainingTab = selectedTab === 'training'
|
||||||
|
const [trainingTabMounted, setTrainingTabMounted] = useState(() => isTrainingTab)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTrainingTab) {
|
if (!isTrainingTab) {
|
||||||
@ -3894,6 +3933,12 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [isTrainingTab])
|
}, [isTrainingTab])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isTrainingTab) {
|
||||||
|
setTrainingTabMounted(true)
|
||||||
|
}
|
||||||
|
}, [isTrainingTab])
|
||||||
|
|
||||||
if (!authChecked) {
|
if (!authChecked) {
|
||||||
return <div className="min-h-[100dvh] grid place-items-center">Lade…</div>
|
return <div className="min-h-[100dvh] grid place-items-center">Lade…</div>
|
||||||
}
|
}
|
||||||
@ -4236,11 +4281,17 @@ export default function App() {
|
|||||||
<ModelsTab onAddToDownloads={handleAddToDownloads} />
|
<ModelsTab onAddToDownloads={handleAddToDownloads} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{selectedTab === 'training' ? (
|
{trainingTabMounted ? (
|
||||||
|
<div
|
||||||
|
className={isTrainingTab ? undefined : 'hidden'}
|
||||||
|
aria-hidden={isTrainingTab ? undefined : true}
|
||||||
|
>
|
||||||
<TrainingTab
|
<TrainingTab
|
||||||
|
active={isTrainingTab}
|
||||||
onTrainingRunningChange={setTrainingTabRunning}
|
onTrainingRunningChange={setTrainingTabRunning}
|
||||||
onImageExpandedChange={setTrainingImageExpanded}
|
onImageExpandedChange={setTrainingImageExpanded}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{selectedTab === 'categories' ? <CategoriesTab /> : null}
|
{selectedTab === 'categories' ? <CategoriesTab /> : null}
|
||||||
|
|||||||
@ -8,8 +8,10 @@ export type AiLabelGroup =
|
|||||||
| 'clothing'
|
| 'clothing'
|
||||||
| 'unknown'
|
| 'unknown'
|
||||||
|
|
||||||
|
const NO_POSITION_LABELS = new Set(['', 'keine'])
|
||||||
|
|
||||||
export const AI_POSITION_LABELS = [
|
export const AI_POSITION_LABELS = [
|
||||||
'unknown',
|
'keine',
|
||||||
'missionary',
|
'missionary',
|
||||||
'doggy',
|
'doggy',
|
||||||
'doggystyle',
|
'doggystyle',
|
||||||
@ -109,7 +111,7 @@ export const AI_LABEL_FALLBACKS: Record<string, string> = {
|
|||||||
stockings: 'Strümpfe',
|
stockings: 'Strümpfe',
|
||||||
croptop: 'Crop Top',
|
croptop: 'Crop Top',
|
||||||
|
|
||||||
unknown: 'Unbekannt',
|
keine: 'Keine',
|
||||||
missionary: 'Missionary',
|
missionary: 'Missionary',
|
||||||
doggy: 'Doggy',
|
doggy: 'Doggy',
|
||||||
doggystyle: 'Doggy',
|
doggystyle: 'Doggy',
|
||||||
@ -237,7 +239,7 @@ export function prettyAiLabel(value: unknown): string {
|
|||||||
export function aiLabelGroup(value: unknown): AiLabelGroup {
|
export function aiLabelGroup(value: unknown): AiLabelGroup {
|
||||||
const label = normalizeAiLabel(value)
|
const label = normalizeAiLabel(value)
|
||||||
|
|
||||||
if (!label || label === 'unknown') return 'unknown'
|
if (NO_POSITION_LABELS.has(label)) return 'unknown'
|
||||||
if (PERSON_SET.has(label)) return 'people'
|
if (PERSON_SET.has(label)) return 'people'
|
||||||
if (POSITION_SET.has(label)) return 'position'
|
if (POSITION_SET.has(label)) return 'position'
|
||||||
if (BODY_SET.has(label)) return 'body'
|
if (BODY_SET.has(label)) return 'body'
|
||||||
@ -248,7 +250,8 @@ export function aiLabelGroup(value: unknown): AiLabelGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isKnownFrontendPositionLabel(value: unknown): boolean {
|
export function isKnownFrontendPositionLabel(value: unknown): boolean {
|
||||||
return POSITION_SET.has(normalizeAiLabel(value))
|
const label = normalizeAiLabel(value)
|
||||||
|
return !NO_POSITION_LABELS.has(label) && POSITION_SET.has(label)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isKnownAiContentLabel(value: unknown): boolean {
|
export function isKnownAiContentLabel(value: unknown): boolean {
|
||||||
|
|||||||
@ -308,7 +308,7 @@ const addedAtMsOf = (r: DownloadRow): number => {
|
|||||||
const phaseLabel = (p?: string) => {
|
const phaseLabel = (p?: string) => {
|
||||||
switch ((p ?? '').toLowerCase()) {
|
switch ((p ?? '').toLowerCase()) {
|
||||||
case 'stopping':
|
case 'stopping':
|
||||||
return 'Stop wird angefordert…'
|
return 'Stoppe Aufnahme…'
|
||||||
case 'probe':
|
case 'probe':
|
||||||
return 'Analysiere Datei (Dauer/Streams)…'
|
return 'Analysiere Datei (Dauer/Streams)…'
|
||||||
case 'remuxing':
|
case 'remuxing':
|
||||||
@ -326,6 +326,45 @@ const phaseLabel = (p?: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STOP_RETRY_AFTER_MS = 15_000
|
||||||
|
|
||||||
|
const stopRequestedAtMsOf = (job: RecordJob): number => {
|
||||||
|
return toMs((job as any)?.stopRequestedAtMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFreshStoppingPhase = (job: RecordJob, nowMs: number): boolean => {
|
||||||
|
const phase = String((job as any)?.phase ?? '').trim().toLowerCase()
|
||||||
|
if (phase !== 'stopping') return false
|
||||||
|
|
||||||
|
const startedAt = stopRequestedAtMsOf(job)
|
||||||
|
return startedAt > 0 && Math.max(0, nowMs - startedAt) < STOP_RETRY_AFTER_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
const canRetryStoppingJob = (job: RecordJob, nowMs: number): boolean => {
|
||||||
|
const phase = String((job as any)?.phase ?? '').trim().toLowerCase()
|
||||||
|
const status = String((job as any)?.status ?? '').trim().toLowerCase()
|
||||||
|
return phase === 'stopping' && status === 'running' && !isFreshStoppingPhase(job, nowMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStopWaitingForJob = (
|
||||||
|
job: RecordJob,
|
||||||
|
localStopRequested: boolean,
|
||||||
|
nowMs: number
|
||||||
|
): boolean => {
|
||||||
|
return localStopRequested || isFreshStoppingPhase(job, nowMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStoppingForUi = (
|
||||||
|
job: RecordJob,
|
||||||
|
localStopRequested: boolean,
|
||||||
|
nowMs: number
|
||||||
|
): boolean => {
|
||||||
|
const phase = String((job as any)?.phase ?? '').trim().toLowerCase()
|
||||||
|
const status = String((job as any)?.status ?? '').trim().toLowerCase()
|
||||||
|
const isBusyPhase = phase !== '' && phase !== 'recording' && phase !== 'stopping'
|
||||||
|
return isBusyPhase || status !== 'running' || isStopWaitingForJob(job, localStopRequested, nowMs)
|
||||||
|
}
|
||||||
|
|
||||||
function postWorkLabel(
|
function postWorkLabel(
|
||||||
job: RecordJob,
|
job: RecordJob,
|
||||||
override?: { pos?: number; total?: number }
|
override?: { pos?: number; total?: number }
|
||||||
@ -370,9 +409,11 @@ function postWorkLabel(
|
|||||||
function StatusCell({
|
function StatusCell({
|
||||||
job,
|
job,
|
||||||
postworkInfo,
|
postworkInfo,
|
||||||
|
nowMs,
|
||||||
}: {
|
}: {
|
||||||
job: RecordJob
|
job: RecordJob
|
||||||
postworkInfo?: { pos?: number; total?: number }
|
postworkInfo?: { pos?: number; total?: number }
|
||||||
|
nowMs: number
|
||||||
}) {
|
}) {
|
||||||
const anyJ = job as any
|
const anyJ = job as any
|
||||||
const phaseRaw = String(anyJ?.phase ?? '').trim()
|
const phaseRaw = String(anyJ?.phase ?? '').trim()
|
||||||
@ -398,8 +439,12 @@ function StatusCell({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
|
if (phase === 'stopping' && canRetryStoppingJob(job, nowMs)) {
|
||||||
|
text = 'Stop dauert zu lange - erneut stoppen'
|
||||||
|
} else {
|
||||||
text = phaseRaw ? (phaseLabel(phaseRaw) || phaseRaw) : String(anyJ?.status ?? '').trim().toLowerCase()
|
text = phaseRaw ? (phaseLabel(phaseRaw) || phaseRaw) : String(anyJ?.status ?? '').trim().toLowerCase()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const showBar =
|
const showBar =
|
||||||
!isRecording &&
|
!isRecording &&
|
||||||
@ -720,30 +765,59 @@ export default function Downloads({
|
|||||||
|
|
||||||
const [stopRequestedIds, setStopRequestedIds] = useState<Record<string, true>>({})
|
const [stopRequestedIds, setStopRequestedIds] = useState<Record<string, true>>({})
|
||||||
const [stopInitiatedIds, setStopInitiatedIds] = useState<Record<string, true>>({})
|
const [stopInitiatedIds, setStopInitiatedIds] = useState<Record<string, true>>({})
|
||||||
|
const stopRequestClearTimersRef = useRef<Record<string, number>>({})
|
||||||
|
|
||||||
const [removePendingRequestedKeys, setRemovePendingRequestedKeys] = useState<Record<string, true>>({})
|
const [removePendingRequestedKeys, setRemovePendingRequestedKeys] = useState<Record<string, true>>({})
|
||||||
const [removeQueuedPostworkRequestedIds, setRemoveQueuedPostworkRequestedIds] = useState<Record<string, true>>({})
|
const [removeQueuedPostworkRequestedIds, setRemoveQueuedPostworkRequestedIds] = useState<Record<string, true>>({})
|
||||||
|
|
||||||
const markStopRequested = useCallback((ids: string | string[]) => {
|
const markStopRequested = useCallback((ids: string | string[]) => {
|
||||||
const arr = Array.isArray(ids) ? ids : [ids]
|
const arr = Array.isArray(ids) ? ids : [ids]
|
||||||
|
const cleanIds = arr.map((id) => String(id || '').trim()).filter(Boolean)
|
||||||
|
|
||||||
|
for (const id of cleanIds) {
|
||||||
|
const oldTimer = stopRequestClearTimersRef.current[id]
|
||||||
|
if (oldTimer) {
|
||||||
|
window.clearTimeout(oldTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopRequestClearTimersRef.current[id] = window.setTimeout(() => {
|
||||||
|
delete stopRequestClearTimersRef.current[id]
|
||||||
|
|
||||||
|
setStopRequestedIds((prev) => {
|
||||||
|
if (!prev[id]) return prev
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[id]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, 12_000)
|
||||||
|
}
|
||||||
|
|
||||||
setStopRequestedIds((prev) => {
|
setStopRequestedIds((prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
for (const id of arr) {
|
for (const id of cleanIds) {
|
||||||
if (id) next[id] = true
|
next[id] = true
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
setStopInitiatedIds((prev) => {
|
setStopInitiatedIds((prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
for (const id of arr) {
|
for (const id of cleanIds) {
|
||||||
if (id) next[id] = true
|
next[id] = true
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
for (const timer of Object.values(stopRequestClearTimersRef.current)) {
|
||||||
|
window.clearTimeout(timer)
|
||||||
|
}
|
||||||
|
stopRequestClearTimersRef.current = {}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const markRemovePendingRequested = useCallback((key: string) => {
|
const markRemovePendingRequested = useCallback((key: string) => {
|
||||||
if (!key) return
|
if (!key) return
|
||||||
setRemovePendingRequestedKeys((prev) => ({ ...prev, [key]: true }))
|
setRemovePendingRequestedKeys((prev) => ({ ...prev, [key]: true }))
|
||||||
@ -791,7 +865,10 @@ export default function Downloads({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const phaseLower = String((j as any).phase ?? '').trim().toLowerCase()
|
const phaseLower = String((j as any).phase ?? '').trim().toLowerCase()
|
||||||
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
|
const isBusyPhase =
|
||||||
|
phaseLower !== '' &&
|
||||||
|
phaseLower !== 'recording' &&
|
||||||
|
phaseLower !== 'stopping'
|
||||||
const isStopping = isBusyPhase || j.status !== 'running'
|
const isStopping = isBusyPhase || j.status !== 'running'
|
||||||
|
|
||||||
if (isStopping) {
|
if (isStopping) {
|
||||||
@ -994,15 +1071,12 @@ export default function Downloads({
|
|||||||
if (isPostworkJob(j)) return false
|
if (isPostworkJob(j)) return false
|
||||||
if ((j as any).endedAt) return false
|
if ((j as any).endedAt) return false
|
||||||
|
|
||||||
const phase = String((j as any).phase ?? '').trim()
|
|
||||||
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
||||||
const phaseLower = phase.trim().toLowerCase()
|
const isStopping = isStoppingForUi(j, isStopRequested, nowMs)
|
||||||
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
|
|
||||||
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
|
|
||||||
return !isStopping
|
return !isStopping
|
||||||
})
|
})
|
||||||
.map((j) => j.id)
|
.map((j) => j.id)
|
||||||
}, [jobs, stopRequestedIds])
|
}, [jobs, stopRequestedIds, nowMs])
|
||||||
|
|
||||||
const columns = useMemo<Column<DownloadRow>[]>(() => {
|
const columns = useMemo<Column<DownloadRow>[]>(() => {
|
||||||
return [
|
return [
|
||||||
@ -1187,7 +1261,7 @@ export default function Downloads({
|
|||||||
cell: (r) => {
|
cell: (r) => {
|
||||||
if (r.kind === 'job') {
|
if (r.kind === 'job') {
|
||||||
const j = r.job
|
const j = r.job
|
||||||
return <StatusCell job={j} postworkInfo={postworkInfoOf(j)} />
|
return <StatusCell job={j} postworkInfo={postworkInfoOf(j)} nowMs={nowMs} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const p = r.pending
|
const p = r.pending
|
||||||
@ -1308,16 +1382,14 @@ export default function Downloads({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const j = r.job
|
const j = r.job
|
||||||
const phase = String((j as any).phase ?? '').trim()
|
|
||||||
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
||||||
const phaseLower = phase.trim().toLowerCase()
|
|
||||||
|
|
||||||
const postworkState = getEffectivePostworkState(j)
|
const postworkState = getEffectivePostworkState(j)
|
||||||
const isQueuedPostwork = postworkState === 'queued'
|
const isQueuedPostwork = postworkState === 'queued'
|
||||||
const isRemovingQueuedPostwork = Boolean(removeQueuedPostworkRequestedIds[j.id])
|
const isRemovingQueuedPostwork = Boolean(removeQueuedPostworkRequestedIds[j.id])
|
||||||
|
|
||||||
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
|
const canRetryStop = canRetryStoppingJob(j, nowMs) && !isStopRequested
|
||||||
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
|
const isStopping = isStoppingForUi(j, isStopRequested, nowMs)
|
||||||
|
|
||||||
const buttonBusy = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping
|
const buttonBusy = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping
|
||||||
const disableStopButton = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping
|
const disableStopButton = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping
|
||||||
@ -1375,6 +1447,8 @@ export default function Downloads({
|
|||||||
>
|
>
|
||||||
{isQueuedPostwork
|
{isQueuedPostwork
|
||||||
? (isRemovingQueuedPostwork ? 'Entferne…' : 'Entfernen')
|
? (isRemovingQueuedPostwork ? 'Entferne…' : 'Entfernen')
|
||||||
|
: canRetryStop
|
||||||
|
? 'Stop erneut'
|
||||||
: (isStopping ? 'Stoppe…' : 'Stoppen')}
|
: (isStopping ? 'Stoppe…' : 'Stoppen')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -1519,15 +1593,16 @@ export default function Downloads({
|
|||||||
const stopRequested = Boolean(stopRequestedIds[j.id])
|
const stopRequested = Boolean(stopRequestedIds[j.id])
|
||||||
const removingQueued = Boolean(removeQueuedPostworkRequestedIds[j.id])
|
const removingQueued = Boolean(removeQueuedPostworkRequestedIds[j.id])
|
||||||
const isQueuedPostwork = getEffectivePostworkState(j) === 'queued'
|
const isQueuedPostwork = getEffectivePostworkState(j) === 'queued'
|
||||||
|
const stopWaiting = isStopWaitingForJob(j, stopRequested, nowMs)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'transition-all duration-300',
|
'transition-all duration-300',
|
||||||
stopRequested && !isQueuedPostwork && 'bg-indigo-50/70 dark:bg-indigo-500/10 pointer-events-none animate-pulse',
|
stopWaiting && !isQueuedPostwork && 'bg-indigo-50/70 dark:bg-indigo-500/10 pointer-events-none animate-pulse',
|
||||||
removingQueued && isQueuedPostwork && 'bg-red-50/70 dark:bg-red-500/10 pointer-events-none animate-pulse',
|
removingQueued && isQueuedPostwork && 'bg-red-50/70 dark:bg-red-500/10 pointer-events-none animate-pulse',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
}, [removePendingRequestedKeys, stopRequestedIds, removeQueuedPostworkRequestedIds])
|
}, [removePendingRequestedKeys, stopRequestedIds, removeQueuedPostworkRequestedIds, nowMs])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@ -1707,17 +1782,16 @@ export default function Downloads({
|
|||||||
if (r.kind !== 'job') return null
|
if (r.kind !== 'job') return null
|
||||||
|
|
||||||
const j = r.job
|
const j = r.job
|
||||||
const phase = String((j as any).phase ?? '').trim().toLowerCase()
|
|
||||||
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
||||||
const postworkState = getEffectivePostworkState(j)
|
const postworkState = getEffectivePostworkState(j)
|
||||||
const isQueuedPostwork = postworkState === 'queued'
|
const isQueuedPostwork = postworkState === 'queued'
|
||||||
const isBusyPhase = phase !== '' && phase !== 'recording'
|
const canRetryStop = canRetryStoppingJob(j, nowMs) && !isStopRequested
|
||||||
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
|
const isStopping = isStoppingForUi(j, isStopRequested, nowMs)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SwipeActionRow
|
<SwipeActionRow
|
||||||
key={`dl:${j.id}`}
|
key={`dl:${j.id}`}
|
||||||
actionLabel={isStopping ? 'Stoppe…' : 'Stoppen'}
|
actionLabel={canRetryStop ? 'Stop erneut' : isStopping ? 'Stoppe…' : 'Stoppen'}
|
||||||
actionColor="indigo"
|
actionColor="indigo"
|
||||||
disabled={isStopping || isQueuedPostwork}
|
disabled={isStopping || isQueuedPostwork}
|
||||||
onAction={async () => {
|
onAction={async () => {
|
||||||
@ -1822,19 +1896,20 @@ export default function Downloads({
|
|||||||
if (r.kind !== 'job') return null
|
if (r.kind !== 'job') return null
|
||||||
|
|
||||||
const j = r.job
|
const j = r.job
|
||||||
const phase = String((j as any).phase ?? '').trim().toLowerCase()
|
|
||||||
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
||||||
|
|
||||||
const postworkState = getEffectivePostworkState(j)
|
const postworkState = getEffectivePostworkState(j)
|
||||||
const isQueuedPostwork = postworkState === 'queued'
|
const isQueuedPostwork = postworkState === 'queued'
|
||||||
|
|
||||||
const isBusyPhase = phase !== '' && phase !== 'recording'
|
const canRetryStop = canRetryStoppingJob(j, nowMs) && !isStopRequested
|
||||||
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
|
const isStopping = isStoppingForUi(j, isStopRequested, nowMs)
|
||||||
|
|
||||||
const isRemovingQueuedPostwork = Boolean(removeQueuedPostworkRequestedIds[j.id])
|
const isRemovingQueuedPostwork = Boolean(removeQueuedPostworkRequestedIds[j.id])
|
||||||
|
|
||||||
const label = isQueuedPostwork
|
const label = isQueuedPostwork
|
||||||
? (isRemovingQueuedPostwork ? 'Entferne…' : 'Entfernen')
|
? (isRemovingQueuedPostwork ? 'Entferne…' : 'Entfernen')
|
||||||
|
: canRetryStop
|
||||||
|
? 'Stop erneut'
|
||||||
: (isStopping ? 'Stoppe…' : 'Stoppen')
|
: (isStopping ? 'Stoppe…' : 'Stoppen')
|
||||||
|
|
||||||
const color = isQueuedPostwork ? 'red' : 'indigo'
|
const color = isQueuedPostwork ? 'red' : 'indigo'
|
||||||
|
|||||||
@ -320,7 +320,7 @@ const formatBytes = (bytes: number | null): string => {
|
|||||||
const phaseLabel = (p?: string) => {
|
const phaseLabel = (p?: string) => {
|
||||||
switch ((p ?? '').toLowerCase()) {
|
switch ((p ?? '').toLowerCase()) {
|
||||||
case 'stopping':
|
case 'stopping':
|
||||||
return 'Stop wird angefordert…'
|
return 'Stoppe Aufnahme…'
|
||||||
case 'probe':
|
case 'probe':
|
||||||
return 'Analysiere Datei (Dauer/Streams)…'
|
return 'Analysiere Datei (Dauer/Streams)…'
|
||||||
case 'remuxing':
|
case 'remuxing':
|
||||||
@ -338,6 +338,45 @@ const phaseLabel = (p?: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STOP_RETRY_AFTER_MS = 15_000
|
||||||
|
|
||||||
|
const stopRequestedAtMsOf = (job: RecordJob): number => {
|
||||||
|
return toMs((job as any)?.stopRequestedAtMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFreshStoppingPhase = (job: RecordJob, nowMs: number): boolean => {
|
||||||
|
const phase = String((job as any)?.phase ?? '').trim().toLowerCase()
|
||||||
|
if (phase !== 'stopping') return false
|
||||||
|
|
||||||
|
const startedAt = stopRequestedAtMsOf(job)
|
||||||
|
return startedAt > 0 && Math.max(0, nowMs - startedAt) < STOP_RETRY_AFTER_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
const canRetryStoppingJob = (job: RecordJob, nowMs: number): boolean => {
|
||||||
|
const phase = String((job as any)?.phase ?? '').trim().toLowerCase()
|
||||||
|
const status = String((job as any)?.status ?? '').trim().toLowerCase()
|
||||||
|
return phase === 'stopping' && status === 'running' && !isFreshStoppingPhase(job, nowMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStopWaitingForJob = (
|
||||||
|
job: RecordJob,
|
||||||
|
localStopRequested: boolean,
|
||||||
|
nowMs: number
|
||||||
|
): boolean => {
|
||||||
|
return localStopRequested || isFreshStoppingPhase(job, nowMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStoppingForUi = (
|
||||||
|
job: RecordJob,
|
||||||
|
localStopRequested: boolean,
|
||||||
|
nowMs: number
|
||||||
|
): boolean => {
|
||||||
|
const phase = String((job as any)?.phase ?? '').trim().toLowerCase()
|
||||||
|
const status = String((job as any)?.status ?? '').trim().toLowerCase()
|
||||||
|
const isBusyPhase = phase !== '' && phase !== 'recording' && phase !== 'stopping'
|
||||||
|
return isBusyPhase || status !== 'running' || isStopWaitingForJob(job, localStopRequested, nowMs)
|
||||||
|
}
|
||||||
|
|
||||||
const isTerminalStatus = (status?: unknown) => {
|
const isTerminalStatus = (status?: unknown) => {
|
||||||
const s = String(status ?? '').trim().toLowerCase()
|
const s = String(status ?? '').trim().toLowerCase()
|
||||||
return (
|
return (
|
||||||
@ -734,13 +773,12 @@ export default function DownloadsCardRow({
|
|||||||
const isRecording = phaseLower === 'recording'
|
const isRecording = phaseLower === 'recording'
|
||||||
|
|
||||||
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
||||||
const rawStatus = String(j.status ?? '').toLowerCase()
|
|
||||||
|
|
||||||
const postworkState = getEffectivePostworkState(j)
|
const postworkState = getEffectivePostworkState(j)
|
||||||
const isQueuedPostwork = postworkState === 'queued'
|
const isQueuedPostwork = postworkState === 'queued'
|
||||||
|
|
||||||
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
|
const canRetryStop = canRetryStoppingJob(j, nowMs) && !isStopRequested
|
||||||
const isStopping = isBusyPhase || rawStatus !== 'running' || isStopRequested
|
const isStopping = isStoppingForUi(j, isStopRequested, nowMs)
|
||||||
|
|
||||||
const showRemoveQueuedButton = isQueuedPostwork
|
const showRemoveQueuedButton = isQueuedPostwork
|
||||||
const disableStopButton = showRemoveQueuedButton ? false : isStopping
|
const disableStopButton = showRemoveQueuedButton ? false : isStopping
|
||||||
@ -767,9 +805,13 @@ export default function DownloadsCardRow({
|
|||||||
} else if (pwState === 'running') {
|
} else if (pwState === 'running') {
|
||||||
phaseText = phaseLabel(phase) || postWorkLabel(j, postworkInfoOf(j))
|
phaseText = phaseLabel(phase) || postWorkLabel(j, postworkInfoOf(j))
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (phaseLower === 'stopping' && canRetryStop) {
|
||||||
|
phaseText = 'Stop dauert zu lange - erneut stoppen'
|
||||||
} else {
|
} else {
|
||||||
phaseText = phase ? (phaseLabel(phase) || phase) : ''
|
phaseText = phase ? (phaseLabel(phase) || phase) : ''
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const progressLabel = phaseText || roomStatus
|
const progressLabel = phaseText || roomStatus
|
||||||
|
|
||||||
@ -979,7 +1021,11 @@ export default function DownloadsCardRow({
|
|||||||
await onStopJob(j.id)
|
await onStopJob(j.id)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{showRemoveQueuedButton ? 'Entfernen' : (isStopping ? 'Stoppe…' : 'Stop')}
|
{showRemoveQueuedButton
|
||||||
|
? 'Entfernen'
|
||||||
|
: canRetryStop
|
||||||
|
? 'Stop erneut'
|
||||||
|
: (isStopping ? 'Stoppe…' : 'Stop')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -120,6 +120,24 @@ const baseName = (p: string) => {
|
|||||||
return parts[parts.length - 1] || ''
|
return parts[parts.length - 1] || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRenamedFile(file: string, renamedFiles: Record<string, string>) {
|
||||||
|
let current = String(file || '').trim()
|
||||||
|
if (!current) return current
|
||||||
|
|
||||||
|
const seen = new Set<string>()
|
||||||
|
|
||||||
|
while (current && !seen.has(current)) {
|
||||||
|
seen.add(current)
|
||||||
|
|
||||||
|
const next = String(renamedFiles[current] || '').trim()
|
||||||
|
if (!next || next === current) break
|
||||||
|
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
const stemWithoutExt = (p: string) => {
|
const stemWithoutExt = (p: string) => {
|
||||||
const f = baseName(p || '')
|
const f = baseName(p || '')
|
||||||
return stripHotPrefix(f.replace(/\.[^.]+$/, ''))
|
return stripHotPrefix(f.replace(/\.[^.]+$/, ''))
|
||||||
@ -2585,8 +2603,8 @@ export default function FinishedDownloads({
|
|||||||
(job: RecordJob): RecordJob => {
|
(job: RecordJob): RecordJob => {
|
||||||
const out = norm(job.output || '')
|
const out = norm(job.output || '')
|
||||||
const file = baseName(out)
|
const file = baseName(out)
|
||||||
const override = renamedFiles[file]
|
const override = resolveRenamedFile(file, renamedFiles)
|
||||||
if (!override) return job
|
if (!override || override === file) return job
|
||||||
|
|
||||||
const idx = out.lastIndexOf('/')
|
const idx = out.lastIndexOf('/')
|
||||||
const dir = idx >= 0 ? out.slice(0, idx + 1) : ''
|
const dir = idx >= 0 ? out.slice(0, idx + 1) : ''
|
||||||
@ -2897,8 +2915,11 @@ export default function FinishedDownloads({
|
|||||||
}, [activeVisibleRows, keyFor, selectionStore, selectionVersion])
|
}, [activeVisibleRows, keyFor, selectionStore, selectionVersion])
|
||||||
|
|
||||||
const hasSearchQuery = (searchQuery || '').trim() !== ''
|
const hasSearchQuery = (searchQuery || '').trim() !== ''
|
||||||
const selectionActionsVisible = selectedCount > 0 && selectionActionsOpen
|
const selectionActionsVisible = selectionActionsOpen && activeVisibleRows.length > 0
|
||||||
const selectionModeActive = selectedCount > 0
|
const selectionModeActive = selectionActionsOpen || selectedCount > 0
|
||||||
|
const selectionButtonDisabled = activeVisibleRows.length === 0
|
||||||
|
const selectionButtonLabel = selectedCount > 0 ? `${selectedCount} ausgewählt` : 'Auswählen'
|
||||||
|
const selectedBulkActionsDisabled = bulkBusy || selectedCount === 0
|
||||||
|
|
||||||
const selectedKeys = useMemo(() => {
|
const selectedKeys = useMemo(() => {
|
||||||
const next = new Set<string>()
|
const next = new Set<string>()
|
||||||
@ -3241,6 +3262,28 @@ export default function FinishedDownloads({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const removeRowNow = useCallback(
|
||||||
|
(key: string, file?: string) => {
|
||||||
|
cancelRemoveTimer(key)
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
setHiddenFiles((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.add(file)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
markDeleted(key)
|
||||||
|
markRemoving(key, false)
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
refreshHoverPreviewFromPointer()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[cancelRemoveTimer, markDeleted, markRemoving, refreshHoverPreviewFromPointer]
|
||||||
|
)
|
||||||
|
|
||||||
const restoreRow = useCallback(
|
const restoreRow = useCallback(
|
||||||
(key: string, file?: string) => {
|
(key: string, file?: string) => {
|
||||||
cancelRemoveTimer(key)
|
cancelRemoveTimer(key)
|
||||||
@ -3470,7 +3513,7 @@ export default function FinishedDownloads({
|
|||||||
rowKey: key,
|
rowKey: key,
|
||||||
setBusy: (v) => markDeleting(key, v),
|
setBusy: (v) => markDeleting(key, v),
|
||||||
isBusyNow: () => deletingKeys.has(key),
|
isBusyNow: () => deletingKeys.has(key),
|
||||||
optimisticRemove: true,
|
optimisticRemove: false,
|
||||||
alreadyRemoved: opts?.alreadyRemoved,
|
alreadyRemoved: opts?.alreadyRemoved,
|
||||||
labels: {
|
labels: {
|
||||||
invalidTitle: 'Löschen nicht möglich',
|
invalidTitle: 'Löschen nicht möglich',
|
||||||
@ -3501,6 +3544,7 @@ export default function FinishedDownloads({
|
|||||||
},
|
},
|
||||||
onSuccess: async (result: any) => {
|
onSuccess: async (result: any) => {
|
||||||
selectionStore.deselect(key)
|
selectionStore.deselect(key)
|
||||||
|
removeRowNow(key, file)
|
||||||
|
|
||||||
const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
|
const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
|
||||||
const from = typeof result?.from === 'string' ? result.from : undefined
|
const from = typeof result?.from === 'string' ? result.from : undefined
|
||||||
@ -3525,6 +3569,7 @@ export default function FinishedDownloads({
|
|||||||
runFileMutation,
|
runFileMutation,
|
||||||
emitCountHint,
|
emitCountHint,
|
||||||
selectionStore,
|
selectionStore,
|
||||||
|
removeRowNow,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -3600,10 +3645,28 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
setRenamedFiles((prev) => {
|
setRenamedFiles((prev) => {
|
||||||
const next: Record<string, string> = { ...prev }
|
const next: Record<string, string> = { ...prev }
|
||||||
|
|
||||||
|
const sources = new Set<string>([oldFile])
|
||||||
|
|
||||||
for (const [k, v] of Object.entries(next)) {
|
for (const [k, v] of Object.entries(next)) {
|
||||||
if (k === oldFile || k === newFile || v === oldFile || v === newFile) delete next[k]
|
const final = resolveRenamedFile(k, prev)
|
||||||
|
|
||||||
|
if (final === oldFile || v === oldFile) {
|
||||||
|
sources.add(k)
|
||||||
}
|
}
|
||||||
next[oldFile] = newFile
|
|
||||||
|
if (k === newFile || v === newFile) {
|
||||||
|
delete next[k]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
if (!source || source === newFile) continue
|
||||||
|
next[source] = newFile
|
||||||
|
}
|
||||||
|
|
||||||
|
delete next[newFile]
|
||||||
|
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -3614,9 +3677,33 @@ export default function FinishedDownloads({
|
|||||||
if (!a && !b) return
|
if (!a && !b) return
|
||||||
setRenamedFiles((prev) => {
|
setRenamedFiles((prev) => {
|
||||||
const next: Record<string, string> = { ...prev }
|
const next: Record<string, string> = { ...prev }
|
||||||
|
|
||||||
for (const [k, v] of Object.entries(next)) {
|
for (const [k, v] of Object.entries(next)) {
|
||||||
if (k === a || k === b || v === a || v === b) delete next[k]
|
const final = resolveRenamedFile(k, prev)
|
||||||
|
|
||||||
|
if (k === b) {
|
||||||
|
delete next[k]
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (final === b || v === b) {
|
||||||
|
if (k && k !== a) {
|
||||||
|
next[k] = a
|
||||||
|
} else {
|
||||||
|
delete next[k]
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (k === a || v === a) {
|
||||||
|
delete next[k]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a && next[a] === a) {
|
||||||
|
delete next[a]
|
||||||
|
}
|
||||||
|
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
@ -4025,7 +4112,7 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
if (item?.ok) {
|
if (item?.ok) {
|
||||||
deletedKeys.add(key)
|
deletedKeys.add(key)
|
||||||
animateRemove(key, file)
|
removeRowNow(key, file)
|
||||||
} else {
|
} else {
|
||||||
failedKeys.add(key)
|
failedKeys.add(key)
|
||||||
}
|
}
|
||||||
@ -4089,9 +4176,9 @@ export default function FinishedDownloads({
|
|||||||
}, [
|
}, [
|
||||||
bulkBusy,
|
bulkBusy,
|
||||||
selectedFiles,
|
selectedFiles,
|
||||||
animateRemove,
|
|
||||||
clearSelection,
|
clearSelection,
|
||||||
emitCountHint,
|
emitCountHint,
|
||||||
|
removeRowNow,
|
||||||
notify,
|
notify,
|
||||||
])
|
])
|
||||||
|
|
||||||
@ -5031,10 +5118,10 @@ export default function FinishedDownloads({
|
|||||||
}, [isSmall])
|
}, [isSmall])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedCount === 0 && selectionActionsOpen) {
|
if (activeVisibleRows.length === 0 && selectionActionsOpen) {
|
||||||
setSelectionActionsOpen(false)
|
setSelectionActionsOpen(false)
|
||||||
}
|
}
|
||||||
}, [selectedCount, selectionActionsOpen])
|
}, [activeVisibleRows.length, selectionActionsOpen])
|
||||||
|
|
||||||
const desktopToolbarControlsClass = [
|
const desktopToolbarControlsClass = [
|
||||||
'flex flex-col gap-3',
|
'flex flex-col gap-3',
|
||||||
@ -5278,24 +5365,23 @@ export default function FinishedDownloads({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedCount > 0 ? (
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="soft"
|
variant="soft"
|
||||||
|
disabled={selectionButtonDisabled}
|
||||||
onClick={() => setSelectionActionsOpen((v) => !v)}
|
onClick={() => setSelectionActionsOpen((v) => !v)}
|
||||||
title={
|
title={
|
||||||
selectionActionsOpen
|
selectionActionsOpen
|
||||||
? 'Auswahl-Aktionen ausblenden'
|
? 'Auswahl-Aktionen ausblenden'
|
||||||
: 'Auswahl-Aktionen anzeigen'
|
: 'Auswahlmodus öffnen'
|
||||||
}
|
}
|
||||||
className={[
|
className={[
|
||||||
'shrink-0 whitespace-nowrap font-semibold',
|
'shrink-0 whitespace-nowrap font-semibold',
|
||||||
selectionActionsOpen ? 'ring-2 ring-indigo-500/60' : '',
|
selectionActionsOpen ? 'ring-2 ring-indigo-500/60' : '',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{selectedCount} ausgewählt
|
{selectionButtonLabel}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -5340,7 +5426,7 @@ export default function FinishedDownloads({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
leadingIcon={<XMarkIcon className="size-4 shrink-0" />}
|
leadingIcon={<XMarkIcon className="size-4 shrink-0" />}
|
||||||
disabled={bulkBusy}
|
disabled={selectedBulkActionsDisabled}
|
||||||
onClick={clearSelection}
|
onClick={clearSelection}
|
||||||
className="w-full justify-center"
|
className="w-full justify-center"
|
||||||
>
|
>
|
||||||
@ -5352,7 +5438,7 @@ export default function FinishedDownloads({
|
|||||||
variant="soft"
|
variant="soft"
|
||||||
color="emerald"
|
color="emerald"
|
||||||
leadingIcon={<ArchiveBoxIcon className="size-4 shrink-0" />}
|
leadingIcon={<ArchiveBoxIcon className="size-4 shrink-0" />}
|
||||||
disabled={bulkBusy}
|
disabled={selectedBulkActionsDisabled}
|
||||||
onClick={() => void bulkKeepSelected()}
|
onClick={() => void bulkKeepSelected()}
|
||||||
className="w-full justify-center"
|
className="w-full justify-center"
|
||||||
>
|
>
|
||||||
@ -5364,7 +5450,7 @@ export default function FinishedDownloads({
|
|||||||
variant="soft"
|
variant="soft"
|
||||||
color="red"
|
color="red"
|
||||||
leadingIcon={<TrashIcon className="size-4 shrink-0" />}
|
leadingIcon={<TrashIcon className="size-4 shrink-0" />}
|
||||||
disabled={bulkBusy}
|
disabled={selectedBulkActionsDisabled}
|
||||||
onClick={() => void bulkDeleteSelected()}
|
onClick={() => void bulkDeleteSelected()}
|
||||||
className="w-full justify-center"
|
className="w-full justify-center"
|
||||||
>
|
>
|
||||||
@ -5396,7 +5482,7 @@ export default function FinishedDownloads({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
leadingIcon={<XMarkIcon className="size-4 shrink-0" />}
|
leadingIcon={<XMarkIcon className="size-4 shrink-0" />}
|
||||||
disabled={bulkBusy}
|
disabled={selectedBulkActionsDisabled}
|
||||||
onClick={clearSelection}
|
onClick={clearSelection}
|
||||||
>
|
>
|
||||||
Auswahl aufheben
|
Auswahl aufheben
|
||||||
@ -5409,7 +5495,7 @@ export default function FinishedDownloads({
|
|||||||
variant="soft"
|
variant="soft"
|
||||||
color="emerald"
|
color="emerald"
|
||||||
leadingIcon={<ArchiveBoxIcon className="size-4 shrink-0" />}
|
leadingIcon={<ArchiveBoxIcon className="size-4 shrink-0" />}
|
||||||
disabled={bulkBusy}
|
disabled={selectedBulkActionsDisabled}
|
||||||
onClick={() => void bulkKeepSelected()}
|
onClick={() => void bulkKeepSelected()}
|
||||||
>
|
>
|
||||||
Behalten
|
Behalten
|
||||||
@ -5420,7 +5506,7 @@ export default function FinishedDownloads({
|
|||||||
variant="soft"
|
variant="soft"
|
||||||
color="red"
|
color="red"
|
||||||
leadingIcon={<TrashIcon className="size-4 shrink-0" />}
|
leadingIcon={<TrashIcon className="size-4 shrink-0" />}
|
||||||
disabled={bulkBusy}
|
disabled={selectedBulkActionsDisabled}
|
||||||
onClick={() => void bulkDeleteSelected()}
|
onClick={() => void bulkDeleteSelected()}
|
||||||
>
|
>
|
||||||
Löschen
|
Löschen
|
||||||
@ -5610,11 +5696,28 @@ export default function FinishedDownloads({
|
|||||||
<div className="lg:hidden">
|
<div className="lg:hidden">
|
||||||
<div className="flex items-center gap-2 px-3 pt-2.5 pb-1.5">
|
<div className="flex items-center gap-2 px-3 pt-2.5 pb-1.5">
|
||||||
<div className="min-w-0 flex flex-1 items-center gap-2">
|
<div className="min-w-0 flex flex-1 items-center gap-2">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
|
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
Abgeschlossene Downloads
|
Abgeschlossene Downloads
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={lastAction ? 'soft' : 'secondary'}
|
||||||
|
color="emerald"
|
||||||
|
className="h-7 shrink-0 px-2 text-xs"
|
||||||
|
disabled={!lastAction || undoing}
|
||||||
|
onClick={undoLastAction}
|
||||||
|
leadingIcon={<ArrowUturnLeftIcon className="size-3.5 shrink-0" />}
|
||||||
|
title={
|
||||||
|
!lastAction
|
||||||
|
? 'Keine Aktion fuer Undo'
|
||||||
|
: `Letzte Aktion rueckgaengig machen (${lastAction.kind})`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Undo
|
||||||
|
</Button>
|
||||||
|
|
||||||
{bulkBusy ? (
|
{bulkBusy ? (
|
||||||
<LoadingSpinner
|
<LoadingSpinner
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -5646,7 +5749,7 @@ export default function FinishedDownloads({
|
|||||||
size="md"
|
size="md"
|
||||||
variant={lastAction ? 'soft' : 'secondary'}
|
variant={lastAction ? 'soft' : 'secondary'}
|
||||||
color="emerald"
|
color="emerald"
|
||||||
className="flex-1"
|
className="!hidden"
|
||||||
disabled={!lastAction || undoing}
|
disabled={!lastAction || undoing}
|
||||||
onClick={undoLastAction}
|
onClick={undoLastAction}
|
||||||
leadingIcon={<ArrowUturnLeftIcon className="size-4 shrink-0" />}
|
leadingIcon={<ArrowUturnLeftIcon className="size-4 shrink-0" />}
|
||||||
@ -5699,23 +5802,22 @@ export default function FinishedDownloads({
|
|||||||
<span className="sr-only">Report</span>
|
<span className="sr-only">Report</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{selectedCount > 0 ? (
|
|
||||||
<Button
|
<Button
|
||||||
size="md"
|
size="md"
|
||||||
variant="soft"
|
variant="soft"
|
||||||
|
disabled={selectionButtonDisabled}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectionActionsOpen((v) => !v)
|
setSelectionActionsOpen((v) => !v)
|
||||||
setMobileOptionsOpen(false)
|
setMobileOptionsOpen(false)
|
||||||
}}
|
}}
|
||||||
title={selectionActionsOpen ? 'Auswahl-Aktionen ausblenden' : 'Auswahl-Aktionen anzeigen'}
|
title={selectionActionsOpen ? 'Auswahl-Aktionen ausblenden' : 'Auswahlmodus öffnen'}
|
||||||
className={[
|
className={[
|
||||||
'shrink-0 whitespace-nowrap px-3 font-semibold',
|
'shrink-0 whitespace-nowrap px-3 font-semibold',
|
||||||
selectionActionsOpen ? 'ring-2 ring-indigo-500/60' : '',
|
selectionActionsOpen ? 'ring-2 ring-indigo-500/60' : '',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{selectedCount} ausgewählt
|
{selectionButtonLabel}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -5779,7 +5881,7 @@ export default function FinishedDownloads({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
leadingIcon={<XMarkIcon className="size-4 shrink-0" />}
|
leadingIcon={<XMarkIcon className="size-4 shrink-0" />}
|
||||||
disabled={bulkBusy}
|
disabled={selectedBulkActionsDisabled}
|
||||||
onClick={clearSelection}
|
onClick={clearSelection}
|
||||||
className="w-full justify-center"
|
className="w-full justify-center"
|
||||||
>
|
>
|
||||||
@ -5791,7 +5893,7 @@ export default function FinishedDownloads({
|
|||||||
variant="soft"
|
variant="soft"
|
||||||
color="emerald"
|
color="emerald"
|
||||||
leadingIcon={<ArchiveBoxIcon className="size-4 shrink-0" />}
|
leadingIcon={<ArchiveBoxIcon className="size-4 shrink-0" />}
|
||||||
disabled={bulkBusy}
|
disabled={selectedBulkActionsDisabled}
|
||||||
onClick={() => void bulkKeepSelected()}
|
onClick={() => void bulkKeepSelected()}
|
||||||
className="w-full justify-center"
|
className="w-full justify-center"
|
||||||
>
|
>
|
||||||
@ -5803,7 +5905,7 @@ export default function FinishedDownloads({
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
color="red"
|
color="red"
|
||||||
leadingIcon={<TrashIcon className="size-4 shrink-0" />}
|
leadingIcon={<TrashIcon className="size-4 shrink-0" />}
|
||||||
disabled={bulkBusy}
|
disabled={selectedBulkActionsDisabled}
|
||||||
onClick={() => void bulkDeleteSelected()}
|
onClick={() => void bulkDeleteSelected()}
|
||||||
className="w-full justify-center"
|
className="w-full justify-center"
|
||||||
>
|
>
|
||||||
@ -6069,6 +6171,7 @@ export default function FinishedDownloads({
|
|||||||
<FinishedDownloadsCardsView
|
<FinishedDownloadsCardsView
|
||||||
rows={cardRows}
|
rows={cardRows}
|
||||||
selectionStore={selectionStore}
|
selectionStore={selectionStore}
|
||||||
|
selectionModeActive={selectionModeActive}
|
||||||
onToggleSelected={toggleSelected}
|
onToggleSelected={toggleSelected}
|
||||||
onToggleSelectAllPage={handleToggleSelectAllPage}
|
onToggleSelectAllPage={handleToggleSelectAllPage}
|
||||||
onSelectOnly={selectOnly}
|
onSelectOnly={selectOnly}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import Card from './Card'
|
import Card from './Card'
|
||||||
@ -63,6 +64,7 @@ type Props = {
|
|||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
isSmall: boolean
|
isSmall: boolean
|
||||||
selectionStore: FinishedSelectionStore
|
selectionStore: FinishedSelectionStore
|
||||||
|
selectionModeActive: boolean
|
||||||
onToggleSelected: (job: RecordJob) => void
|
onToggleSelected: (job: RecordJob) => void
|
||||||
onToggleSelectAllPage: (checked: boolean) => void
|
onToggleSelectAllPage: (checked: boolean) => void
|
||||||
onSelectOnly: (job: RecordJob) => void
|
onSelectOnly: (job: RecordJob) => void
|
||||||
@ -320,6 +322,7 @@ function FinishedDownloadsCardsView({
|
|||||||
rows,
|
rows,
|
||||||
isSmall,
|
isSmall,
|
||||||
selectionStore,
|
selectionStore,
|
||||||
|
selectionModeActive,
|
||||||
onToggleSelected,
|
onToggleSelected,
|
||||||
isLoading,
|
isLoading,
|
||||||
blurPreviews,
|
blurPreviews,
|
||||||
@ -450,7 +453,7 @@ function FinishedDownloadsCardsView({
|
|||||||
const k = keyFor(j)
|
const k = keyFor(j)
|
||||||
|
|
||||||
const handleSelectOrOpen = () => {
|
const handleSelectOrOpen = () => {
|
||||||
if (selectionStore.hasAnySelection()) {
|
if (selectionModeActive) {
|
||||||
onToggleSelected(j)
|
onToggleSelected(j)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -869,7 +872,7 @@ function FinishedDownloadsCardsView({
|
|||||||
|
|
||||||
if (isSmall) return
|
if (isSmall) return
|
||||||
|
|
||||||
if (selectionStore.hasAnySelection()) {
|
if (selectionModeActive) {
|
||||||
onToggleSelected(j)
|
onToggleSelected(j)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -982,6 +985,36 @@ function FinishedDownloadsCardsView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [skippedMobileKeys, setSkippedMobileKeys] = useState<string[]>([])
|
const [skippedMobileKeys, setSkippedMobileKeys] = useState<string[]>([])
|
||||||
|
const mobileStackFrameRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const mobileStackHeightTimerRef = useRef<number | null>(null)
|
||||||
|
const [mobileStackMinHeight, setMobileStackMinHeight] = useState(0)
|
||||||
|
|
||||||
|
const lockMobileStackHeight = useCallback((durationMs = 520) => {
|
||||||
|
if (!isSmall) return
|
||||||
|
|
||||||
|
const height = mobileStackFrameRef.current?.getBoundingClientRect().height ?? 0
|
||||||
|
if (height > 0) {
|
||||||
|
setMobileStackMinHeight(Math.ceil(height))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mobileStackHeightTimerRef.current != null) {
|
||||||
|
window.clearTimeout(mobileStackHeightTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
mobileStackHeightTimerRef.current = window.setTimeout(() => {
|
||||||
|
setMobileStackMinHeight(0)
|
||||||
|
mobileStackHeightTimerRef.current = null
|
||||||
|
}, durationMs)
|
||||||
|
}, [isSmall])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (mobileStackHeightTimerRef.current != null) {
|
||||||
|
window.clearTimeout(mobileStackHeightTimerRef.current)
|
||||||
|
mobileStackHeightTimerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const mobileOrderedRows = useMemo(() => {
|
const mobileOrderedRows = useMemo(() => {
|
||||||
if (!isSmall || skippedMobileKeys.length === 0) return rows
|
if (!isSmall || skippedMobileKeys.length === 0) return rows
|
||||||
@ -1007,13 +1040,14 @@ function FinishedDownloadsCardsView({
|
|||||||
|
|
||||||
const topKey = keyFor(topRow)
|
const topKey = keyFor(topRow)
|
||||||
|
|
||||||
|
lockMobileStackHeight()
|
||||||
setSpeedHoldKey((prev) => (prev === topKey ? null : prev))
|
setSpeedHoldKey((prev) => (prev === topKey ? null : prev))
|
||||||
|
|
||||||
setSkippedMobileKeys((prev) => [
|
setSkippedMobileKeys((prev) => [
|
||||||
...prev.filter((key) => key !== topKey),
|
...prev.filter((key) => key !== topKey),
|
||||||
topKey,
|
topKey,
|
||||||
])
|
])
|
||||||
}, [isSmall, mobileOrderedRows, keyFor])
|
}, [isSmall, mobileOrderedRows, keyFor, lockMobileStackHeight])
|
||||||
|
|
||||||
const mobileStackDepth = 3
|
const mobileStackDepth = 3
|
||||||
|
|
||||||
@ -1125,6 +1159,11 @@ function FinishedDownloadsCardsView({
|
|||||||
|
|
||||||
// weil wir nach OBEN stacken, brauchen wir oben Platz
|
// weil wir nach OBEN stacken, brauchen wir oben Platz
|
||||||
const stackExtraTopPx = Math.max(0, Math.min(mobileVisibleStackRows.length, mobileStackDepth) - 1)
|
const stackExtraTopPx = Math.max(0, Math.min(mobileVisibleStackRows.length, mobileStackDepth) - 1)
|
||||||
|
const mobileStackShellStyle = useMemo(() => ({
|
||||||
|
overflowX: 'clip',
|
||||||
|
overflowAnchor: 'none',
|
||||||
|
minHeight: mobileStackMinHeight > 0 ? `${mobileStackMinHeight}px` : undefined,
|
||||||
|
}) as CSSProperties, [mobileStackMinHeight])
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -1139,6 +1178,7 @@ function FinishedDownloadsCardsView({
|
|||||||
key={`${k}__desktop`}
|
key={`${k}__desktop`}
|
||||||
job={j}
|
job={j}
|
||||||
selectionStore={selectionStore}
|
selectionStore={selectionStore}
|
||||||
|
selectionModeActive={selectionModeActive}
|
||||||
isDeleting={deletingKeys.has(k)}
|
isDeleting={deletingKeys.has(k)}
|
||||||
isKeeping={keepingKeys.has(k)}
|
isKeeping={keepingKeys.has(k)}
|
||||||
isRemoving={removingKeys.has(k)}
|
isRemoving={removingKeys.has(k)}
|
||||||
@ -1196,8 +1236,9 @@ function FinishedDownloadsCardsView({
|
|||||||
) : null
|
) : null
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
|
ref={mobileStackFrameRef}
|
||||||
className="relative mx-auto w-full max-w-[560px] overflow-y-visible"
|
className="relative mx-auto w-full max-w-[560px] overflow-y-visible"
|
||||||
style={{ overflowX: 'clip' }}
|
style={mobileStackShellStyle}
|
||||||
>
|
>
|
||||||
{/* feste Höhe für den Stapel (damit die unteren Karten sichtbar “rausgucken”) */}
|
{/* feste Höhe für den Stapel (damit die unteren Karten sichtbar “rausgucken”) */}
|
||||||
<div
|
<div
|
||||||
@ -1295,14 +1336,14 @@ function FinishedDownloadsCardsView({
|
|||||||
renderRole: 'top',
|
renderRole: 'top',
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectionModeActive = selectionStore.hasAnySelection()
|
const topCardSelectionModeActive = selectionModeActive
|
||||||
|
|
||||||
const canSpeedHold =
|
const canSpeedHold =
|
||||||
!inlineActive &&
|
!inlineActive &&
|
||||||
!busy &&
|
!busy &&
|
||||||
(
|
(
|
||||||
allowTeaserAnimation ||
|
allowTeaserAnimation ||
|
||||||
(selectionModeActive && teaserState.mode !== 'still')
|
(topCardSelectionModeActive && teaserState.mode !== 'still')
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -1331,7 +1372,7 @@ function FinishedDownloadsCardsView({
|
|||||||
onTap={() => {
|
onTap={() => {
|
||||||
if (!mobileTopTapEnabled) return
|
if (!mobileTopTapEnabled) return
|
||||||
|
|
||||||
if (selectionStore.hasAnySelection()) {
|
if (topCardSelectionModeActive) {
|
||||||
onToggleSelected(j)
|
onToggleSelected(j)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -1350,10 +1391,12 @@ function FinishedDownloadsCardsView({
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
onSwipeLeft={async () => {
|
onSwipeLeft={async () => {
|
||||||
|
lockMobileStackHeight()
|
||||||
setMobileTopTeaserEnabled(false)
|
setMobileTopTeaserEnabled(false)
|
||||||
return await deleteVideo(j)
|
return await deleteVideo(j)
|
||||||
}}
|
}}
|
||||||
onSwipeRight={async () => {
|
onSwipeRight={async () => {
|
||||||
|
lockMobileStackHeight()
|
||||||
setMobileTopTeaserEnabled(false)
|
setMobileTopTeaserEnabled(false)
|
||||||
return await keepVideo(j)
|
return await keepVideo(j)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -46,6 +46,7 @@ import { autoTagsForJob, mergeTags } from './autoTags'
|
|||||||
export type FinishedDownloadsDesktopCardProps = {
|
export type FinishedDownloadsDesktopCardProps = {
|
||||||
job: RecordJob
|
job: RecordJob
|
||||||
selectionStore: FinishedSelectionStore
|
selectionStore: FinishedSelectionStore
|
||||||
|
selectionModeActive: boolean
|
||||||
isDeleting: boolean
|
isDeleting: boolean
|
||||||
isKeeping: boolean
|
isKeeping: boolean
|
||||||
isRemoving: boolean
|
isRemoving: boolean
|
||||||
@ -137,6 +138,7 @@ const FinishedDownloadsDesktopCard = memo(
|
|||||||
function FinishedDownloadsDesktopCard({
|
function FinishedDownloadsDesktopCard({
|
||||||
job: j,
|
job: j,
|
||||||
selectionStore,
|
selectionStore,
|
||||||
|
selectionModeActive,
|
||||||
isDeleting,
|
isDeleting,
|
||||||
isKeeping,
|
isKeeping,
|
||||||
isRemoving,
|
isRemoving,
|
||||||
@ -199,12 +201,12 @@ const FinishedDownloadsDesktopCard = memo(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const handleSelectOrOpen = useCallback(() => {
|
const handleSelectOrOpen = useCallback(() => {
|
||||||
if (selectionStore.hasAnySelection()) {
|
if (selectionModeActive) {
|
||||||
onToggleSelected(j)
|
onToggleSelected(j)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}, [selectionStore, onToggleSelected, j])
|
}, [selectionModeActive, onToggleSelected, j])
|
||||||
|
|
||||||
const clearLongPressTimer = useCallback(() => {
|
const clearLongPressTimer = useCallback(() => {
|
||||||
if (longPressTimerRef.current !== null) {
|
if (longPressTimerRef.current !== null) {
|
||||||
@ -216,14 +218,14 @@ const FinishedDownloadsDesktopCard = memo(
|
|||||||
const startLongPressSelect = useCallback(() => {
|
const startLongPressSelect = useCallback(() => {
|
||||||
clearLongPressTimer()
|
clearLongPressTimer()
|
||||||
|
|
||||||
if (selectionStore.hasAnySelection()) return
|
if (selectionModeActive) return
|
||||||
|
|
||||||
longPressTimerRef.current = window.setTimeout(() => {
|
longPressTimerRef.current = window.setTimeout(() => {
|
||||||
longPressTimerRef.current = null
|
longPressTimerRef.current = null
|
||||||
longPressTriggeredRef.current = true
|
longPressTriggeredRef.current = true
|
||||||
onToggleSelected(j)
|
onToggleSelected(j)
|
||||||
}, 450)
|
}, 450)
|
||||||
}, [clearLongPressTimer, selectionStore, onToggleSelected, j])
|
}, [clearLongPressTimer, selectionModeActive, onToggleSelected, j])
|
||||||
|
|
||||||
const previewMuted = forcePreviewMuted ? true : !allowSound
|
const previewMuted = forcePreviewMuted ? true : !allowSound
|
||||||
|
|
||||||
@ -378,7 +380,7 @@ const FinishedDownloadsDesktopCard = memo(
|
|||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'absolute left-3 top-3 z-40 hidden transition-opacity duration-150 lg:block',
|
'absolute left-3 top-3 z-40 hidden transition-opacity duration-150 lg:block',
|
||||||
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
|
checked || selectionModeActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
|||||||
@ -90,6 +90,60 @@ function baseName(path: string) {
|
|||||||
return (path || '').split(/[\\/]/).pop() || ''
|
return (path || '').split(/[\\/]/).pop() || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMobilePreviewConstrained() {
|
||||||
|
if (typeof window === 'undefined') return false
|
||||||
|
|
||||||
|
const coarsePointer =
|
||||||
|
typeof window.matchMedia === 'function' &&
|
||||||
|
(
|
||||||
|
window.matchMedia('(pointer: coarse)').matches ||
|
||||||
|
window.matchMedia('(hover: none)').matches
|
||||||
|
)
|
||||||
|
|
||||||
|
return coarsePointer
|
||||||
|
}
|
||||||
|
|
||||||
|
function useMobilePreviewConstrained() {
|
||||||
|
const [constrained, setConstrained] = useState(isMobilePreviewConstrained)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
if (typeof window.matchMedia !== 'function') return
|
||||||
|
|
||||||
|
const queries = [
|
||||||
|
window.matchMedia('(pointer: coarse)'),
|
||||||
|
window.matchMedia('(hover: none)'),
|
||||||
|
]
|
||||||
|
|
||||||
|
const update = () => setConstrained(isMobilePreviewConstrained())
|
||||||
|
|
||||||
|
update()
|
||||||
|
|
||||||
|
const cleanups: Array<() => void> = []
|
||||||
|
for (const query of queries) {
|
||||||
|
if (typeof query.addEventListener === 'function') {
|
||||||
|
query.addEventListener('change', update)
|
||||||
|
cleanups.push(() => query.removeEventListener('change', update))
|
||||||
|
} else {
|
||||||
|
query.addListener?.(update)
|
||||||
|
cleanups.push(() => query.removeListener?.(update))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', update)
|
||||||
|
window.addEventListener('orientationchange', update)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const cleanup of cleanups) cleanup()
|
||||||
|
|
||||||
|
window.removeEventListener('resize', update)
|
||||||
|
window.removeEventListener('orientationchange', update)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return constrained
|
||||||
|
}
|
||||||
|
|
||||||
export default function FinishedVideoPreview({
|
export default function FinishedVideoPreview({
|
||||||
job,
|
job,
|
||||||
getFileName,
|
getFileName,
|
||||||
@ -319,8 +373,14 @@ export default function FinishedVideoPreview({
|
|||||||
return !document.hidden
|
return !document.hidden
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const mobilePreviewConstrained = useMobilePreviewConstrained()
|
||||||
const effectiveInView = forceActive || inView
|
const effectiveInView = forceActive || inView
|
||||||
const effectiveNearView = forceActive || nearView
|
const effectiveNearView = forceActive || nearView
|
||||||
|
const metadataLoadRangeActive = mobilePreviewConstrained
|
||||||
|
? inView
|
||||||
|
: forceActive || effectiveNearView || effectiveInView
|
||||||
|
const teaserRetryDelayMs = mobilePreviewConstrained ? 450 : 120
|
||||||
|
const teaserWatchdogMs = mobilePreviewConstrained ? 700 : 180
|
||||||
|
|
||||||
const inlineMode: 'never' | 'always' | 'hover' =
|
const inlineMode: 'never' | 'always' | 'hover' =
|
||||||
inlineVideo === true || inlineVideo === 'always' ? 'always' : inlineVideo === 'hover' ? 'hover' : 'never'
|
inlineVideo === true || inlineVideo === 'always' ? 'always' : inlineVideo === 'hover' ? 'hover' : 'never'
|
||||||
@ -517,7 +577,7 @@ export default function FinishedVideoPreview({
|
|||||||
return { ratio, globalSec: Math.max(0, globalSec), vvDur }
|
return { ratio, globalSec: Math.max(0, globalSec), vvDur }
|
||||||
}
|
}
|
||||||
|
|
||||||
const hardStop = (v: HTMLVideoElement | null) => {
|
const hardStop = useCallback((v: HTMLVideoElement | null) => {
|
||||||
if (!v) return
|
if (!v) return
|
||||||
try {
|
try {
|
||||||
v.pause()
|
v.pause()
|
||||||
@ -528,7 +588,7 @@ export default function FinishedVideoPreview({
|
|||||||
v.src = ''
|
v.src = ''
|
||||||
v.load()
|
v.load()
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@ -581,7 +641,7 @@ export default function FinishedVideoPreview({
|
|||||||
window.removeEventListener('player:release', onRelease as EventListener)
|
window.removeEventListener('player:release', onRelease as EventListener)
|
||||||
window.removeEventListener('player:close', onRelease as EventListener)
|
window.removeEventListener('player:close', onRelease as EventListener)
|
||||||
}
|
}
|
||||||
}, [file])
|
}, [file, hardStop])
|
||||||
|
|
||||||
// --- IntersectionObserver: echtes inView (für Playback)
|
// --- IntersectionObserver: echtes inView (für Playback)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -716,7 +776,8 @@ export default function FinishedVideoPreview({
|
|||||||
|
|
||||||
// ✅ Teaser/Clips "vorwärmen": schon in nearView erlauben
|
// ✅ Teaser/Clips "vorwärmen": schon in nearView erlauben
|
||||||
const shouldPreloadAnimatedAssets =
|
const shouldPreloadAnimatedAssets =
|
||||||
forceActive || effectiveNearView || effectiveInView || (wantsHover && hovered)
|
!mobilePreviewConstrained &&
|
||||||
|
(forceActive || effectiveNearView || effectiveInView || (wantsHover && hovered))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (animatedMode !== 'teaser' || showingInlineVideo || !teaserActive) {
|
if (animatedMode !== 'teaser' || showingInlineVideo || !teaserActive) {
|
||||||
@ -808,10 +869,12 @@ export default function FinishedVideoPreview({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ✅ aktive Asset-Nutzung (z.B. poster etc.)
|
// ✅ aktive Asset-Nutzung (z.B. poster etc.)
|
||||||
const shouldLoadAssets = shouldPreloadAnimatedAssets
|
const shouldLoadAssets =
|
||||||
|
shouldPreloadAnimatedAssets || effectiveInView || showingInlineVideo || teaserActive
|
||||||
|
|
||||||
const teaserCanPrewarm =
|
const teaserCanPrewarm =
|
||||||
animated &&
|
animated &&
|
||||||
|
!mobilePreviewConstrained &&
|
||||||
animatedMode === 'teaser' &&
|
animatedMode === 'teaser' &&
|
||||||
Boolean(teaserSrc) &&
|
Boolean(teaserSrc) &&
|
||||||
!showingInlineVideo &&
|
!showingInlineVideo &&
|
||||||
@ -1037,12 +1100,12 @@ export default function FinishedVideoPreview({
|
|||||||
const onLoadedData = () => {
|
const onLoadedData = () => {
|
||||||
setTeaserReady(true)
|
setTeaserReady(true)
|
||||||
tryPlay()
|
tryPlay()
|
||||||
scheduleRetry(120)
|
scheduleRetry(teaserRetryDelayMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onCanPlay = () => {
|
const onCanPlay = () => {
|
||||||
tryPlay()
|
tryPlay()
|
||||||
scheduleRetry(120)
|
scheduleRetry(teaserRetryDelayMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPause = () => {
|
const onPause = () => {
|
||||||
@ -1053,12 +1116,12 @@ export default function FinishedVideoPreview({
|
|||||||
if (el.ended) return
|
if (el.ended) return
|
||||||
|
|
||||||
// iOS-Fall: erster Frame sichtbar, aber Playback stoppt direkt wieder
|
// iOS-Fall: erster Frame sichtbar, aber Playback stoppt direkt wieder
|
||||||
scheduleRetry(120)
|
scheduleRetry(teaserRetryDelayMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWaiting = () => {
|
const onWaiting = () => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
scheduleRetry(120)
|
scheduleRetry(teaserRetryDelayMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
vv.addEventListener('playing', onPlaying)
|
vv.addEventListener('playing', onPlaying)
|
||||||
@ -1090,15 +1153,15 @@ export default function FinishedVideoPreview({
|
|||||||
const stalledFor = now - teaserLastProgressTimeRef.current
|
const stalledFor = now - teaserLastProgressTimeRef.current
|
||||||
const ct = Number(el.currentTime)
|
const ct = Number(el.currentTime)
|
||||||
|
|
||||||
if ((!Number.isFinite(ct) || ct <= 0.05) && stalledFor > 180) {
|
if ((!Number.isFinite(ct) || ct <= 0.05) && stalledFor > teaserWatchdogMs) {
|
||||||
tryPlay({ resetToStart: true })
|
tryPlay({ resetToStart: true })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (el.paused && !el.ended && stalledFor > 180) {
|
if (el.paused && !el.ended && stalledFor > teaserWatchdogMs) {
|
||||||
tryPlay()
|
tryPlay()
|
||||||
}
|
}
|
||||||
}, 180)
|
}, teaserWatchdogMs)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
@ -1114,7 +1177,15 @@ export default function FinishedVideoPreview({
|
|||||||
if (typeof raf2 === 'number') cancelAnimationFrame(raf2)
|
if (typeof raf2 === 'number') cancelAnimationFrame(raf2)
|
||||||
window.clearInterval(watchdog)
|
window.clearInterval(watchdog)
|
||||||
}
|
}
|
||||||
}, [teaserActive, animatedMode, teaserSrc, pageVisible])
|
}, [
|
||||||
|
teaserActive,
|
||||||
|
animatedMode,
|
||||||
|
teaserSrc,
|
||||||
|
pageVisible,
|
||||||
|
teaserRetryDelayMs,
|
||||||
|
teaserWatchdogMs,
|
||||||
|
teaserSpeedHold,
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activationNonce) return
|
if (!activationNonce) return
|
||||||
@ -1213,7 +1284,7 @@ export default function FinishedVideoPreview({
|
|||||||
sync()
|
sync()
|
||||||
|
|
||||||
// ✅ Sekundentakt (robust, unabhängig von raf/play-events)
|
// ✅ Sekundentakt (robust, unabhängig von raf/play-events)
|
||||||
timer = window.setInterval(sync, 100)
|
timer = window.setInterval(sync, mobilePreviewConstrained ? 250 : 100)
|
||||||
|
|
||||||
// optional: bei metadata/timeupdate sofort einmal syncen
|
// optional: bei metadata/timeupdate sofort einmal syncen
|
||||||
const onLoaded = () => sync()
|
const onLoaded = () => sync()
|
||||||
@ -1230,7 +1301,7 @@ export default function FinishedVideoPreview({
|
|||||||
vv.removeEventListener('durationchange', onLoaded)
|
vv.removeEventListener('durationchange', onLoaded)
|
||||||
vv.removeEventListener('timeupdate', onTime)
|
vv.removeEventListener('timeupdate', onTime)
|
||||||
}
|
}
|
||||||
}, [showProgressBar, progressVideoRef, progressTotalSeconds, previewClipMapKey, progressKind, previewClipMap, progressMountTick])
|
}, [showProgressBar, progressVideoRef, progressTotalSeconds, previewClipMapKey, progressKind, previewClipMap, progressMountTick, mobilePreviewConstrained])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showingInlineVideo) return
|
if (!showingInlineVideo) return
|
||||||
@ -1287,7 +1358,7 @@ export default function FinishedVideoPreview({
|
|||||||
|
|
||||||
// ✅ brauchen wir noch hidden-metadata-load?
|
// ✅ brauchen wir noch hidden-metadata-load?
|
||||||
const needHiddenMeta =
|
const needHiddenMeta =
|
||||||
(forceActive || effectiveNearView || effectiveInView) &&
|
metadataLoadRangeActive &&
|
||||||
(onDuration || onResolution) &&
|
(onDuration || onResolution) &&
|
||||||
!metaLoaded &&
|
!metaLoaded &&
|
||||||
!showingInlineVideo &&
|
!showingInlineVideo &&
|
||||||
@ -1437,7 +1508,7 @@ export default function FinishedVideoPreview({
|
|||||||
playsInline
|
playsInline
|
||||||
autoPlay
|
autoPlay
|
||||||
loop
|
loop
|
||||||
preload="auto"
|
preload={mobilePreviewConstrained ? 'metadata' : 'auto'}
|
||||||
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
|
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
|
||||||
onLoadedData={() => {
|
onLoadedData={() => {
|
||||||
setTeaserOk(true)
|
setTeaserOk(true)
|
||||||
|
|||||||
@ -1100,8 +1100,8 @@ const SEGMENT_LABEL_META: SegmentLabelMeta[] = [
|
|||||||
icon: DoggyIcon,
|
icon: DoggyIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
match: ['unknown'],
|
match: ['keine'],
|
||||||
text: 'Unbekannt',
|
text: 'Keine',
|
||||||
icon: UnknownContentIcon,
|
icon: UnknownContentIcon,
|
||||||
},
|
},
|
||||||
// People
|
// People
|
||||||
|
|||||||
@ -451,8 +451,6 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
|||||||
thresholdRef.current ||
|
thresholdRef.current ||
|
||||||
Math.min(thresholdPx, (cardRef.current?.offsetWidth || 360) * thresholdRatio)
|
Math.min(thresholdPx, (cardRef.current?.offsetWidth || 360) * thresholdRatio)
|
||||||
|
|
||||||
const reveal = clamp(absDx / Math.max(1, activeThreshold), 0, 1)
|
|
||||||
|
|
||||||
const revealSoft = clamp(absDx / Math.max(1, activeThreshold * 1.35), 0, 1)
|
const revealSoft = clamp(absDx / Math.max(1, activeThreshold * 1.35), 0, 1)
|
||||||
|
|
||||||
const tiltDeg = clamp(dx / 34, -ROTATE_MAX_DEG, ROTATE_MAX_DEG)
|
const tiltDeg = clamp(dx / 34, -ROTATE_MAX_DEG, ROTATE_MAX_DEG)
|
||||||
@ -478,17 +476,14 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
|||||||
transform:
|
transform:
|
||||||
dx !== 0
|
dx !== 0
|
||||||
? `translate3d(${dx}px,0,0) rotate(${tiltDeg}deg) scale(${dragScale})`
|
? `translate3d(${dx}px,0,0) rotate(${tiltDeg}deg) scale(${dragScale})`
|
||||||
: undefined,
|
: 'translate3d(0,0,0)',
|
||||||
transition: animMs
|
transition: animMs
|
||||||
? `transform ${animMs}ms cubic-bezier(0.22, 1, 0.36, 1)`
|
? `transform ${animMs}ms cubic-bezier(0.22, 1, 0.36, 1)`
|
||||||
: undefined,
|
: undefined,
|
||||||
touchAction: baseTouchAction,
|
touchAction: baseTouchAction,
|
||||||
willChange: dx !== 0 ? 'transform' : undefined,
|
willChange: 'transform',
|
||||||
|
backfaceVisibility: 'hidden',
|
||||||
borderRadius: dx !== 0 ? '12px' : undefined,
|
borderRadius: dx !== 0 ? '12px' : undefined,
|
||||||
filter:
|
|
||||||
dx !== 0
|
|
||||||
? `saturate(${1 + reveal * 0.06}) brightness(${1 + reveal * 0.015})`
|
|
||||||
: undefined,
|
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
if (!enabled || disabled) return
|
if (!enabled || disabled) return
|
||||||
|
|||||||
@ -84,6 +84,19 @@ function clampPercent(value: number) {
|
|||||||
return Math.max(0, Math.min(100, value))
|
return Math.max(0, Math.min(100, value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NO_SEX_POSITION_LABEL = 'keine'
|
||||||
|
const NO_SEX_POSITION_ALIASES = new Set([
|
||||||
|
'',
|
||||||
|
NO_SEX_POSITION_LABEL,
|
||||||
|
])
|
||||||
|
|
||||||
|
function normalizeSexPositionValue(value?: string | null) {
|
||||||
|
const clean = String(value ?? '').trim()
|
||||||
|
return NO_SEX_POSITION_ALIASES.has(clean.toLowerCase())
|
||||||
|
? NO_SEX_POSITION_LABEL
|
||||||
|
: clean
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeBox(box: TrainingFeedbackBox): TrainingFeedbackBox {
|
function normalizeBox(box: TrainingFeedbackBox): TrainingFeedbackBox {
|
||||||
const x = clamp01(Number(box.x))
|
const x = clamp01(Number(box.x))
|
||||||
const y = clamp01(Number(box.y))
|
const y = clamp01(Number(box.y))
|
||||||
@ -104,7 +117,7 @@ function annotationEffectiveCorrection(
|
|||||||
): TrainingFeedbackCorrection {
|
): TrainingFeedbackCorrection {
|
||||||
if (annotation.negative) {
|
if (annotation.negative) {
|
||||||
return {
|
return {
|
||||||
sexPosition: 'unknown',
|
sexPosition: NO_SEX_POSITION_LABEL,
|
||||||
peoplePresent: [],
|
peoplePresent: [],
|
||||||
bodyPartsPresent: [],
|
bodyPartsPresent: [],
|
||||||
objectsPresent: [],
|
objectsPresent: [],
|
||||||
@ -114,11 +127,14 @@ function annotationEffectiveCorrection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (annotation.correction) {
|
if (annotation.correction) {
|
||||||
return annotation.correction
|
return {
|
||||||
|
...annotation.correction,
|
||||||
|
sexPosition: normalizeSexPositionValue(annotation.correction.sexPosition),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sexPosition: annotation.prediction.sexPosition || 'unknown',
|
sexPosition: normalizeSexPositionValue(annotation.prediction.sexPosition),
|
||||||
peoplePresent: (annotation.prediction.peoplePresent ?? []).map((item) => item.label),
|
peoplePresent: (annotation.prediction.peoplePresent ?? []).map((item) => item.label),
|
||||||
bodyPartsPresent: (annotation.prediction.bodyPartsPresent ?? []).map((item) => item.label),
|
bodyPartsPresent: (annotation.prediction.bodyPartsPresent ?? []).map((item) => item.label),
|
||||||
objectsPresent: (annotation.prediction.objectsPresent ?? []).map((item) => item.label),
|
objectsPresent: (annotation.prediction.objectsPresent ?? []).map((item) => item.label),
|
||||||
@ -685,7 +701,7 @@ function FeedbackListItem(props: {
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="max-w-full truncate rounded-full bg-gray-50 px-2 py-0.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
<span className="max-w-full truncate rounded-full bg-gray-50 px-2 py-0.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||||||
{effective.sexPosition || 'unknown'}
|
{normalizeSexPositionValue(effective.sexPosition)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -850,7 +866,7 @@ function ResultLabelsCard(props: {
|
|||||||
const groups = [
|
const groups = [
|
||||||
{
|
{
|
||||||
title: 'Position',
|
title: 'Position',
|
||||||
values: [props.effective.sexPosition || 'unknown'],
|
values: [normalizeSexPositionValue(props.effective.sexPosition)],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Personen',
|
title: 'Personen',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -190,6 +190,8 @@ export type RecordJob = {
|
|||||||
|
|
||||||
phase?: string
|
phase?: string
|
||||||
progress?: number
|
progress?: number
|
||||||
|
stopRequestedAtMs?: number
|
||||||
|
stopAttempts?: number
|
||||||
|
|
||||||
postWorkKey?: string
|
postWorkKey?: string
|
||||||
postWork?: PostWorkKeyStatus
|
postWork?: PostWorkKeyStatus
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user