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
|
||||
Binary file not shown.
@ -12,6 +12,52 @@ from ultralytics import YOLO
|
||||
|
||||
|
||||
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]:
|
||||
@ -60,6 +106,7 @@ def resolve_training_root() -> Path:
|
||||
|
||||
TRAINING_ROOT = resolve_training_root()
|
||||
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:
|
||||
@ -91,12 +138,26 @@ def resolve_model_path() -> str:
|
||||
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.
|
||||
DETECTION_LABELS_PATH: Optional[Path] = None
|
||||
|
||||
LABEL_GROUPS = {
|
||||
"people": set(),
|
||||
"sexPositions": {"unknown"},
|
||||
"sexPositions": {NO_SEX_POSITION_LABEL},
|
||||
"bodyParts": set(),
|
||||
"objects": set(),
|
||||
"clothing": set(),
|
||||
@ -114,15 +175,19 @@ PERSON_LABELS = {
|
||||
|
||||
_MODEL_PATH = ""
|
||||
_MODEL_ERROR = ""
|
||||
_POSE_MODEL_PATH = ""
|
||||
_POSE_MODEL_ERROR = ""
|
||||
_LABEL_ERROR = ""
|
||||
|
||||
_DEVICE = os.environ.get("YOLO_DEVICE", "")
|
||||
_CONF = float(os.environ.get("YOLO_CONF", "0.25"))
|
||||
_POSE_CONF = float(os.environ.get("YOLO_POSE_CONF", "0.30"))
|
||||
_BATCH = int(os.environ.get("YOLO_BATCH", "16"))
|
||||
_IMGSZ = int(os.environ.get("YOLO_IMGSZ", "640"))
|
||||
_HALF = os.environ.get("YOLO_HALF", "0").lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
model = None
|
||||
pose_model = None
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@ -174,13 +239,14 @@ def empty_prediction(source: str = "model_missing") -> dict:
|
||||
return {
|
||||
"modelAvailable": False,
|
||||
"source": source,
|
||||
"sexPosition": "unknown",
|
||||
"sexPosition": NO_SEX_POSITION_LABEL,
|
||||
"sexPositionScore": 0.0,
|
||||
"peoplePresent": [],
|
||||
"bodyPartsPresent": [],
|
||||
"objectsPresent": [],
|
||||
"clothingPresent": [],
|
||||
"boxes": [],
|
||||
"persons": [],
|
||||
}
|
||||
|
||||
|
||||
@ -207,11 +273,7 @@ def load_label_groups_safe() -> None:
|
||||
for x in data.get("people", [])
|
||||
if str(x).strip()
|
||||
),
|
||||
"sexPositions": set(
|
||||
str(x).strip().lower()
|
||||
for x in data.get("sexPositions", [])
|
||||
if str(x).strip()
|
||||
),
|
||||
"sexPositions": normalize_sex_position_labels(data.get("sexPositions", [])),
|
||||
"bodyParts": set(
|
||||
str(x).strip().lower()
|
||||
for x in data.get("bodyParts", [])
|
||||
@ -230,7 +292,7 @@ def load_label_groups_safe() -> None:
|
||||
}
|
||||
|
||||
if not LABEL_GROUPS["sexPositions"]:
|
||||
LABEL_GROUPS["sexPositions"] = {"unknown"}
|
||||
LABEL_GROUPS["sexPositions"] = {NO_SEX_POSITION_LABEL}
|
||||
|
||||
_LABEL_ERROR = ""
|
||||
|
||||
@ -240,7 +302,7 @@ def load_label_groups_safe() -> None:
|
||||
|
||||
LABEL_GROUPS = {
|
||||
"people": set(),
|
||||
"sexPositions": {"unknown"},
|
||||
"sexPositions": {NO_SEX_POSITION_LABEL},
|
||||
"bodyParts": set(),
|
||||
"objects": set(),
|
||||
"clothing": set(),
|
||||
@ -252,7 +314,7 @@ def load_label_groups_safe() -> None:
|
||||
|
||||
POSITION_LABELS = {
|
||||
label for label in LABEL_GROUPS["sexPositions"]
|
||||
if label and label != "unknown"
|
||||
if label and not is_no_sex_position_label(label)
|
||||
}
|
||||
|
||||
PERSON_LABELS = {
|
||||
@ -289,6 +351,37 @@ def get_model():
|
||||
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:
|
||||
return {
|
||||
"label": label,
|
||||
@ -315,9 +408,6 @@ def prediction_from_result(result) -> dict:
|
||||
objects = []
|
||||
clothing = []
|
||||
|
||||
sex_position = "unknown"
|
||||
sex_position_score = 0.0
|
||||
|
||||
if result.boxes is not None:
|
||||
xywhn = result.boxes.xywhn.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_object = label in OBJECT_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):
|
||||
continue
|
||||
@ -376,13 +459,165 @@ def prediction_from_result(result) -> dict:
|
||||
return {
|
||||
"modelAvailable": True,
|
||||
"source": f"yolo-server:{Path(_MODEL_PATH).name}",
|
||||
"sexPosition": sex_position,
|
||||
"sexPositionScore": sex_position_score,
|
||||
"sexPosition": NO_SEX_POSITION_LABEL,
|
||||
"sexPositionScore": 0.0,
|
||||
"peoplePresent": people_present,
|
||||
"bodyPartsPresent": body_parts,
|
||||
"objectsPresent": objects,
|
||||
"clothingPresent": clothing,
|
||||
"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]
|
||||
|
||||
if not req.detectorOnly:
|
||||
apply_pose_batch_to_predictions(paths, predictions, imgsz)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"predictions": predictions,
|
||||
@ -444,7 +682,7 @@ def health():
|
||||
current_model = get_model()
|
||||
names = getattr(current_model, "names", {}) or {} if current_model is not None else {}
|
||||
|
||||
return {
|
||||
status_payload = {
|
||||
"ok": True,
|
||||
"ready": current_model is not None,
|
||||
"modelAvailable": current_model is not None,
|
||||
@ -458,22 +696,31 @@ def health():
|
||||
"labelError": _LABEL_ERROR,
|
||||
}
|
||||
|
||||
status_payload.update(pose_model_status())
|
||||
return status_payload
|
||||
|
||||
@app.post("/reload", dependencies=[Depends(require_ai_server_auth)])
|
||||
def reload_model():
|
||||
global model
|
||||
global pose_model
|
||||
global _MODEL_PATH
|
||||
global _MODEL_ERROR
|
||||
global _POSE_MODEL_PATH
|
||||
global _POSE_MODEL_ERROR
|
||||
global DETECTION_LABELS_PATH
|
||||
|
||||
model = None
|
||||
pose_model = None
|
||||
_MODEL_PATH = ""
|
||||
_MODEL_ERROR = ""
|
||||
_POSE_MODEL_PATH = ""
|
||||
_POSE_MODEL_ERROR = ""
|
||||
DETECTION_LABELS_PATH = None
|
||||
|
||||
current_model = get_model()
|
||||
names = getattr(current_model, "names", {}) or {} if current_model is not None else {}
|
||||
|
||||
return {
|
||||
status_payload = {
|
||||
"ok": current_model is not None,
|
||||
"ready": current_model is not None,
|
||||
"modelAvailable": current_model is not None,
|
||||
@ -485,4 +732,7 @@ def reload_model():
|
||||
"classes": list(names.values())[:80] if isinstance(names, dict) else names,
|
||||
"labelConfig": str(DETECTION_LABELS_PATH) if DETECTION_LABELS_PATH else "",
|
||||
"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) {
|
||||
for _, value := range values {
|
||||
label := strings.ToLower(strings.TrimSpace(value))
|
||||
if label == "" || label == "unknown" {
|
||||
if isNoSexPositionLabel(label) {
|
||||
continue
|
||||
}
|
||||
out[label] = struct{}{}
|
||||
@ -155,7 +155,7 @@ var autoSelectedAILabelsCache map[string]struct{}
|
||||
|
||||
func shouldAutoSelectAnalyzeHit(label string) bool {
|
||||
label = strings.ToLower(strings.TrimSpace(label))
|
||||
if label == "" || label == "unknown" {
|
||||
if isNoSexPositionLabel(label) {
|
||||
return false
|
||||
}
|
||||
|
||||
@ -202,7 +202,7 @@ func isIgnoredNSFWLabel(label string) bool {
|
||||
|
||||
func addHighlightResult(best map[string]float64, label string, score float64) {
|
||||
label = strings.ToLower(strings.TrimSpace(label))
|
||||
if label == "" || label == "unknown" {
|
||||
if isNoSexPositionLabel(label) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -223,7 +223,7 @@ func addScoredHighlightLabels(best map[string]float64, prefix string, items []Tr
|
||||
|
||||
for _, item := range items {
|
||||
label := strings.ToLower(strings.TrimSpace(item.Label))
|
||||
if label == "" || label == "unknown" {
|
||||
if isNoSexPositionLabel(label) {
|
||||
continue
|
||||
}
|
||||
|
||||
@ -235,7 +235,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe
|
||||
best := map[string]float64{}
|
||||
|
||||
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
|
||||
if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) {
|
||||
if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) {
|
||||
addHighlightResult(best, "position:"+sexPosition, pred.SexPositionScore)
|
||||
}
|
||||
|
||||
@ -245,7 +245,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe
|
||||
|
||||
for _, box := range pred.Boxes {
|
||||
label := strings.ToLower(strings.TrimSpace(box.Label))
|
||||
if label == "" || label == "unknown" {
|
||||
if isNoSexPositionLabel(label) {
|
||||
continue
|
||||
}
|
||||
|
||||
@ -264,7 +264,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe
|
||||
}
|
||||
|
||||
// Kombis nur erzeugen, wenn wirklich Position + Zusatz vorhanden ist.
|
||||
if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) {
|
||||
if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) {
|
||||
positionScore := pred.SexPositionScore
|
||||
if positionScore <= 0 {
|
||||
positionScore = 1
|
||||
@ -273,7 +273,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe
|
||||
addCombo := func(prefix string, items []TrainingScoredLabel) {
|
||||
for _, item := range items {
|
||||
label := strings.ToLower(strings.TrimSpace(item.Label))
|
||||
if label == "" || label == "unknown" {
|
||||
if isNoSexPositionLabel(label) {
|
||||
continue
|
||||
}
|
||||
|
||||
@ -316,7 +316,7 @@ func pickHighlightResults(results []NsfwFrameResult) []NsfwFrameResult {
|
||||
|
||||
for _, r := range results {
|
||||
label := strings.ToLower(strings.TrimSpace(r.Label))
|
||||
if label == "" || label == "unknown" {
|
||||
if isNoSexPositionLabel(label) {
|
||||
continue
|
||||
}
|
||||
|
||||
@ -1075,7 +1075,7 @@ type highlightSignal struct {
|
||||
|
||||
func normalizeHighlightSignalLabel(label string) string {
|
||||
label = strings.ToLower(strings.TrimSpace(label))
|
||||
if label == "" || label == "unknown" {
|
||||
if isNoSexPositionLabel(label) {
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -1094,28 +1094,28 @@ func normalizeHighlightSignalLabel(label string) string {
|
||||
|
||||
case strings.HasPrefix(label, "body:"):
|
||||
raw := strings.TrimPrefix(label, "body:")
|
||||
if raw == "" || raw == "unknown" {
|
||||
if isNoSexPositionLabel(raw) {
|
||||
return ""
|
||||
}
|
||||
return "body:" + raw
|
||||
|
||||
case strings.HasPrefix(label, "object:"):
|
||||
raw := strings.TrimPrefix(label, "object:")
|
||||
if raw == "" || raw == "unknown" {
|
||||
if isNoSexPositionLabel(raw) {
|
||||
return ""
|
||||
}
|
||||
return "object:" + raw
|
||||
|
||||
case strings.HasPrefix(label, "clothing:"):
|
||||
raw := strings.TrimPrefix(label, "clothing:")
|
||||
if raw == "" || raw == "unknown" {
|
||||
if isNoSexPositionLabel(raw) {
|
||||
return ""
|
||||
}
|
||||
return "clothing:" + raw
|
||||
|
||||
case strings.HasPrefix(label, "position:"):
|
||||
raw := strings.TrimPrefix(label, "position:")
|
||||
if raw == "" || raw == "unknown" || !isKnownPositionLabel(raw) {
|
||||
if isNoSexPositionLabel(raw) || !isKnownPositionLabel(raw) {
|
||||
return ""
|
||||
}
|
||||
return "position:" + raw
|
||||
@ -1173,7 +1173,7 @@ func rawAnalyzeSignalSeverityWeight(label string) float64 {
|
||||
}
|
||||
|
||||
raw := normalizeSegmentLabel(label)
|
||||
if raw == "" || raw == "unknown" {
|
||||
if isNoSexPositionLabel(raw) {
|
||||
return 0
|
||||
}
|
||||
|
||||
@ -1273,7 +1273,7 @@ func addHighlightSignalsFromScoredLabels(
|
||||
|
||||
for _, item := range items {
|
||||
label := strings.ToLower(strings.TrimSpace(item.Label))
|
||||
if label == "" || label == "unknown" {
|
||||
if isNoSexPositionLabel(label) {
|
||||
continue
|
||||
}
|
||||
|
||||
@ -1302,7 +1302,7 @@ func highlightComboPartOrder(label string) int {
|
||||
|
||||
func predictionHasAnyAnalyzeSignal(pred TrainingPrediction) bool {
|
||||
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
|
||||
if sexPosition != "" && sexPosition != "unknown" {
|
||||
if !isNoSexPositionLabel(sexPosition) {
|
||||
return true
|
||||
}
|
||||
|
||||
@ -1342,7 +1342,7 @@ func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64)
|
||||
|
||||
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
|
||||
|
||||
if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) {
|
||||
if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) {
|
||||
positionScore := pred.SexPositionScore
|
||||
if positionScore <= 0 {
|
||||
positionScore = 0.35
|
||||
@ -1364,7 +1364,7 @@ func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64)
|
||||
|
||||
for _, box := range pred.Boxes {
|
||||
label := strings.ToLower(strings.TrimSpace(box.Label))
|
||||
if label == "" || label == "unknown" {
|
||||
if isNoSexPositionLabel(label) {
|
||||
continue
|
||||
}
|
||||
if isIgnoredNSFWLabel(label) {
|
||||
@ -1540,7 +1540,7 @@ func buildHighlightHitsFromPrediction(pred TrainingPrediction, t float64) []anal
|
||||
best := map[string]highlightSignal{}
|
||||
|
||||
sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition))
|
||||
if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) {
|
||||
if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) {
|
||||
addHighlightSignal(best, "position:"+sexPosition, pred.SexPositionScore)
|
||||
}
|
||||
|
||||
@ -1550,7 +1550,7 @@ func buildHighlightHitsFromPrediction(pred TrainingPrediction, t float64) []anal
|
||||
|
||||
for _, box := range pred.Boxes {
|
||||
label := strings.ToLower(strings.TrimSpace(box.Label))
|
||||
if label == "" || label == "unknown" {
|
||||
if isNoSexPositionLabel(label) {
|
||||
continue
|
||||
}
|
||||
if isIgnoredNSFWLabel(label) {
|
||||
@ -2322,7 +2322,7 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit {
|
||||
|
||||
for _, h := range in {
|
||||
label := strings.ToLower(strings.TrimSpace(h.Label))
|
||||
if label == "" || label == "unknown" {
|
||||
if isNoSexPositionLabel(label) {
|
||||
continue
|
||||
}
|
||||
if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) {
|
||||
@ -2542,7 +2542,7 @@ type analyzeLabelSegmentPoint struct {
|
||||
|
||||
func isAllowedAnalyzeSegmentLabel(label string) bool {
|
||||
label = strings.ToLower(strings.TrimSpace(label))
|
||||
if label == "" || label == "unknown" {
|
||||
if isNoSexPositionLabel(label) {
|
||||
return false
|
||||
}
|
||||
if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) {
|
||||
@ -2559,7 +2559,7 @@ func isAllowedAnalyzeSegmentLabel(label string) bool {
|
||||
}
|
||||
|
||||
raw := normalizeSegmentLabel(label)
|
||||
if raw == "" || raw == "unknown" {
|
||||
if isNoSexPositionLabel(raw) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@ -76,20 +76,29 @@ func stopJobsInternal(list []*RecordJob) {
|
||||
}
|
||||
|
||||
pl := make([]payload, 0, len(list))
|
||||
ids := make([]string, 0, len(list))
|
||||
nowMs := time.Now().UnixMilli()
|
||||
|
||||
jobsMu.Lock()
|
||||
for _, job := range list {
|
||||
if job == nil {
|
||||
continue
|
||||
}
|
||||
if job.EndedAt != nil || isTerminalJobStatus(job.Status) || isPostworkJob(job) {
|
||||
continue
|
||||
}
|
||||
|
||||
job.Phase = "stopping"
|
||||
job.Progress = 10
|
||||
job.StopRequestedAtMs = nowMs
|
||||
job.StopAttempts++
|
||||
|
||||
pl = append(pl, payload{
|
||||
cmd: job.previewCmd,
|
||||
cancel: job.cancel,
|
||||
previewCancel: job.previewCancel,
|
||||
})
|
||||
ids = append(ids, job.ID)
|
||||
|
||||
job.previewCmd = nil
|
||||
job.previewCancel = nil
|
||||
@ -98,6 +107,11 @@ func stopJobsInternal(list []*RecordJob) {
|
||||
}
|
||||
jobsMu.Unlock()
|
||||
|
||||
for _, id := range ids {
|
||||
_ = publishJobSnapshot(id)
|
||||
_ = publishModelJobUpsert(id)
|
||||
}
|
||||
|
||||
for _, p := range pl {
|
||||
if p.previewCancel != nil {
|
||||
p.previewCancel()
|
||||
@ -109,6 +123,11 @@ func stopJobsInternal(list []*RecordJob) {
|
||||
p.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
_ = publishJobSnapshot(id)
|
||||
_ = publishModelJobUpsert(id)
|
||||
}
|
||||
}
|
||||
|
||||
func stopAllStoppableJobs() int {
|
||||
|
||||
@ -8,9 +8,17 @@ import (
|
||||
"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
|
||||
|
||||
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) {
|
||||
dir := filepath.Join(os.TempDir(), "nsfwapp-ml")
|
||||
|
||||
@ -22,6 +30,8 @@ func trainingEmbeddedMLDir() (string, error) {
|
||||
"predict_detector_model.py",
|
||||
"train_detector_model.py",
|
||||
"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.
|
||||
@ -33,10 +43,6 @@ func trainingEmbeddedMLDir() (string, error) {
|
||||
for _, name := range append(files, optionalFiles...) {
|
||||
srcPath := filepath.ToSlash(filepath.Join("ml", name))
|
||||
|
||||
if name == "detection_labels.json" {
|
||||
srcPath = "generated/training/detection_labels.json"
|
||||
}
|
||||
|
||||
b, err := embeddedMLFiles.ReadFile(srcPath)
|
||||
if err != nil {
|
||||
// Pflichtdateien müssen vorhanden sein.
|
||||
@ -51,14 +57,24 @@ func trainingEmbeddedMLDir() (string, error) {
|
||||
}
|
||||
|
||||
dstPath := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(dstPath, b, 0644); err != nil {
|
||||
if err := embeddedWriteFileIfNeeded(dstPath, b, 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := embeddedPoseModelPath(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
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) {
|
||||
dir := filepath.Join(os.TempDir(), "nsfwapp-ai-server")
|
||||
|
||||
@ -72,13 +88,33 @@ func embeddedAIServerDir() (string, error) {
|
||||
}
|
||||
|
||||
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 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) {
|
||||
return embeddedMLFiles.ReadFile(".env")
|
||||
}
|
||||
|
||||
@ -103,6 +103,9 @@ func appLogWriter() io.Writer {
|
||||
|
||||
func appLogWriteLine(line string) {
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if appLogLineSuppressed(line) {
|
||||
return
|
||||
}
|
||||
|
||||
ts := time.Now().Format("2006-01-02 15:04:05")
|
||||
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
|
||||
// eines Stream-Reconnects). Der Fehler wird weiterhin zurückgegeben, damit die
|
||||
// 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 {
|
||||
m := strings.ToLower(msg)
|
||||
|
||||
return strings.Contains(m, "leere hls url") ||
|
||||
strings.Contains(m, "http 403")
|
||||
return appLogLineSuppressed(msg) ||
|
||||
strings.Contains(m, "leere hls url")
|
||||
}
|
||||
|
||||
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 {
|
||||
logText = "… Log gekürzt, zeige die letzten " + fmt.Sprint(maxBytes/1024) + " KB …\n\n" + logText
|
||||
}
|
||||
|
||||
122
backend/main.go
122
backend/main.go
@ -95,8 +95,10 @@ type RecordJob struct {
|
||||
PreviewUA string `json:"-"` // user-agent
|
||||
|
||||
// ✅ Frontend Progress beim Stop/Finalize
|
||||
Phase string `json:"phase,omitempty"` // stopping | remuxing | moving
|
||||
Progress int `json:"progress,omitempty"` // 0..100
|
||||
Phase string `json:"phase,omitempty"` // stopping | remuxing | moving
|
||||
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"`
|
||||
PostWork *PostWorkKeyStatus `json:"postWork,omitempty"`
|
||||
@ -105,24 +107,26 @@ type RecordJob struct {
|
||||
}
|
||||
|
||||
type jobEventSnapshot struct {
|
||||
ID string
|
||||
SourceURL string
|
||||
Output string
|
||||
Status JobStatus
|
||||
StartedAt time.Time
|
||||
StartedAtMs int64
|
||||
EndedAt *time.Time
|
||||
EndedAtMs int64
|
||||
Error string
|
||||
Phase string
|
||||
Progress int
|
||||
SizeBytes int64
|
||||
DurationSeconds float64
|
||||
PreviewState string
|
||||
PostWorkKey string
|
||||
PostWork *PostWorkKeyStatus
|
||||
ModelImageURL string
|
||||
Avatar string
|
||||
ID string
|
||||
SourceURL string
|
||||
Output string
|
||||
Status JobStatus
|
||||
StartedAt time.Time
|
||||
StartedAtMs int64
|
||||
EndedAt *time.Time
|
||||
EndedAtMs int64
|
||||
Error string
|
||||
Phase string
|
||||
Progress int
|
||||
StopRequestedAtMs int64
|
||||
StopAttempts int
|
||||
SizeBytes int64
|
||||
DurationSeconds float64
|
||||
PreviewState string
|
||||
PostWorkKey string
|
||||
PostWork *PostWorkKeyStatus
|
||||
ModelImageURL string
|
||||
Avatar string
|
||||
}
|
||||
|
||||
type dummyResponseWriter struct {
|
||||
@ -165,25 +169,27 @@ func canonicalAssetIDFromName(name string) string {
|
||||
|
||||
func publishJobUpsertSnapshot(s jobEventSnapshot) {
|
||||
ev := map[string]any{
|
||||
"type": "job_upsert",
|
||||
"jobId": s.ID,
|
||||
"status": string(s.Status),
|
||||
"sourceUrl": s.SourceURL,
|
||||
"output": s.Output,
|
||||
"startedAt": s.StartedAt,
|
||||
"startedAtMs": s.StartedAtMs,
|
||||
"endedAt": s.EndedAt,
|
||||
"endedAtMs": s.EndedAtMs,
|
||||
"error": s.Error,
|
||||
"phase": s.Phase,
|
||||
"progress": s.Progress,
|
||||
"sizeBytes": s.SizeBytes,
|
||||
"durationSeconds": s.DurationSeconds,
|
||||
"previewState": s.PreviewState,
|
||||
"postWorkKey": s.PostWorkKey,
|
||||
"ts": time.Now().UnixMilli(),
|
||||
"modelImageUrl": s.ModelImageURL,
|
||||
"avatar": s.Avatar,
|
||||
"type": "job_upsert",
|
||||
"jobId": s.ID,
|
||||
"status": string(s.Status),
|
||||
"sourceUrl": s.SourceURL,
|
||||
"output": s.Output,
|
||||
"startedAt": s.StartedAt,
|
||||
"startedAtMs": s.StartedAtMs,
|
||||
"endedAt": s.EndedAt,
|
||||
"endedAtMs": s.EndedAtMs,
|
||||
"error": s.Error,
|
||||
"phase": s.Phase,
|
||||
"progress": s.Progress,
|
||||
"stopRequestedAtMs": s.StopRequestedAtMs,
|
||||
"stopAttempts": s.StopAttempts,
|
||||
"sizeBytes": s.SizeBytes,
|
||||
"durationSeconds": s.DurationSeconds,
|
||||
"previewState": s.PreviewState,
|
||||
"postWorkKey": s.PostWorkKey,
|
||||
"ts": time.Now().UnixMilli(),
|
||||
"modelImageUrl": s.ModelImageURL,
|
||||
"avatar": s.Avatar,
|
||||
}
|
||||
|
||||
if s.PostWork != nil {
|
||||
@ -222,24 +228,26 @@ func publishJobSnapshot(jobID string) bool {
|
||||
}
|
||||
|
||||
snap := jobEventSnapshot{
|
||||
ID: job.ID,
|
||||
SourceURL: job.SourceURL,
|
||||
Output: job.Output,
|
||||
Status: job.Status,
|
||||
StartedAt: job.StartedAt,
|
||||
StartedAtMs: job.StartedAtMs,
|
||||
EndedAt: endedAtCopy,
|
||||
EndedAtMs: job.EndedAtMs,
|
||||
Error: job.Error,
|
||||
Phase: job.Phase,
|
||||
Progress: job.Progress,
|
||||
SizeBytes: job.SizeBytes,
|
||||
DurationSeconds: job.DurationSeconds,
|
||||
PreviewState: job.PreviewState,
|
||||
PostWorkKey: job.PostWorkKey,
|
||||
PostWork: pw,
|
||||
ModelImageURL: job.ModelImageURL,
|
||||
Avatar: job.Avatar,
|
||||
ID: job.ID,
|
||||
SourceURL: job.SourceURL,
|
||||
Output: job.Output,
|
||||
Status: job.Status,
|
||||
StartedAt: job.StartedAt,
|
||||
StartedAtMs: job.StartedAtMs,
|
||||
EndedAt: endedAtCopy,
|
||||
EndedAtMs: job.EndedAtMs,
|
||||
Error: job.Error,
|
||||
Phase: job.Phase,
|
||||
Progress: job.Progress,
|
||||
StopRequestedAtMs: job.StopRequestedAtMs,
|
||||
StopAttempts: job.StopAttempts,
|
||||
SizeBytes: job.SizeBytes,
|
||||
DurationSeconds: job.DurationSeconds,
|
||||
PreviewState: job.PreviewState,
|
||||
PostWorkKey: job.PostWorkKey,
|
||||
PostWork: pw,
|
||||
ModelImageURL: job.ModelImageURL,
|
||||
Avatar: job.Avatar,
|
||||
}
|
||||
jobsMu.RUnlock()
|
||||
|
||||
|
||||
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)))
|
||||
|
||||
|
||||
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():
|
||||
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)
|
||||
parser.add_argument("--debug-image", action="store_true")
|
||||
@ -25,11 +51,11 @@ def main():
|
||||
root = Path(args.root)
|
||||
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():
|
||||
print(json.dumps({
|
||||
"available": False,
|
||||
"source": "detector_missing",
|
||||
"source": model_source,
|
||||
"modelPath": str(model_path),
|
||||
"boxes": [],
|
||||
}, ensure_ascii=False))
|
||||
@ -124,7 +150,7 @@ def main():
|
||||
|
||||
print(json.dumps({
|
||||
"available": True,
|
||||
"source": "yolo26_detector",
|
||||
"source": model_source,
|
||||
"modelPath": str(model_path),
|
||||
"image": str(image_path),
|
||||
"conf": float(args.conf),
|
||||
@ -139,4 +165,4 @@ def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
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 {
|
||||
label = strings.ToLower(strings.TrimSpace(label))
|
||||
if isNoSexPositionLabel(label) {
|
||||
return false
|
||||
}
|
||||
|
||||
switch label {
|
||||
case "unknown",
|
||||
"missionary",
|
||||
case "missionary",
|
||||
"doggy",
|
||||
"doggystyle",
|
||||
"cowgirl",
|
||||
@ -306,7 +308,7 @@ type ratingSignalSet struct {
|
||||
|
||||
func normalizeRatingSignalLabel(label string) string {
|
||||
label = strings.ToLower(strings.TrimSpace(label))
|
||||
if label == "" || label == "unknown" {
|
||||
if isNoSexPositionLabel(label) {
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -359,7 +361,7 @@ func normalizeRatingSignalLabel(label string) string {
|
||||
}
|
||||
|
||||
raw := normalizeSegmentLabel(label)
|
||||
if raw == "" || raw == "unknown" {
|
||||
if isNoSexPositionLabel(raw) {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
@ -1540,13 +1540,21 @@ func recordStop(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
jobsMu.RLock()
|
||||
job, ok := jobs[id]
|
||||
shouldStop := ok &&
|
||||
job != nil &&
|
||||
job.EndedAt == nil &&
|
||||
!isTerminalJobStatus(job.Status) &&
|
||||
!isPostworkJob(job)
|
||||
jobsMu.RUnlock()
|
||||
if !ok {
|
||||
http.Error(w, "job nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
stopJobsInternal([]*RecordJob{job})
|
||||
if shouldStop {
|
||||
stopJobsInternal([]*RecordJob{job})
|
||||
}
|
||||
|
||||
respondJSON(w, job)
|
||||
}
|
||||
|
||||
|
||||
@ -1206,6 +1206,13 @@ func recordHLSPlaylistSegments(
|
||||
|
||||
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 !fileExistsNonEmpty(outFile) {
|
||||
if finalErr != nil {
|
||||
|
||||
@ -63,7 +63,11 @@ func RecordStreamMFC(
|
||||
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 {
|
||||
|
||||
@ -354,8 +354,6 @@ func waitForUsableOutput(path string, timeout time.Duration) (os.FileInfo, error
|
||||
lastErr = appErrorf("stat returned nil")
|
||||
} else if fi.IsDir() {
|
||||
lastErr = appErrorf("path is dir")
|
||||
} else {
|
||||
lastErr = appErrorf("file size is 0")
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
@ -411,8 +409,6 @@ func waitForStableUsableOutput(path string, timeout time.Duration, stableFor tim
|
||||
lastErr = appErrorf("stat returned nil")
|
||||
} else if fi.IsDir() {
|
||||
lastErr = appErrorf("path is dir")
|
||||
} else {
|
||||
lastErr = appErrorf("file size is 0")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -112,6 +112,7 @@ var (
|
||||
aiServerMu sync.Mutex
|
||||
aiServerCurrent *aiServerProcess
|
||||
aiServerCtx context.Context
|
||||
aiServerRoot string
|
||||
)
|
||||
|
||||
func aiServerAutostartEnabled() bool {
|
||||
@ -349,13 +350,17 @@ func findTrainingRootDir(scriptDir string) (trainingRoot string, appBaseDir stri
|
||||
|
||||
// Wichtig für Entwicklung:
|
||||
// findAIServerScriptDir() findet meistens dein backend-Verzeichnis.
|
||||
add(scriptDir)
|
||||
if scriptDir != "" && !isTempBuildDir(scriptDir) {
|
||||
add(scriptDir)
|
||||
}
|
||||
|
||||
// Falls du aus repo-root oder backend startest.
|
||||
add(cwd)
|
||||
|
||||
// Wichtig für gebaute EXE.
|
||||
add(exeDir)
|
||||
if exeDir != "" && !isTempBuildDir(exeDir) {
|
||||
add(exeDir)
|
||||
}
|
||||
|
||||
// Bevorzugt den Pfad, wo detection_labels.json liegt.
|
||||
for _, c := range candidates {
|
||||
@ -472,6 +477,7 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
|
||||
}
|
||||
|
||||
trainingRoot, appBaseDir := findTrainingRootDir(scriptDir)
|
||||
aiServerRoot = trainingRoot
|
||||
|
||||
mlDir, mlErr := trainingEmbeddedMLDir()
|
||||
if mlErr != nil {
|
||||
@ -493,7 +499,8 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
|
||||
detectionLabelsPath = filepath.Clean(detectionLabelsPath)
|
||||
}
|
||||
|
||||
defaultModelPath := filepath.Join(trainingRoot, "detector", "model", "best.pt")
|
||||
defaultModel := trainingResolveDetectorModel(trainingRoot)
|
||||
defaultModelPath := defaultModel.EffectivePath
|
||||
|
||||
pythonPath := aiServerPythonPath()
|
||||
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() {
|
||||
appLogln("⚠️ YOLO Modell nicht gefunden:", defaultModelPath)
|
||||
} else {
|
||||
appLogln("✅ YOLO Modell:", defaultModelPath)
|
||||
appLogln("✅ YOLO Modell:", defaultModelPath, "Quelle:", defaultModel.Source)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(os.Getenv("YOLO_IMGSZ")) == "" {
|
||||
@ -647,8 +654,10 @@ func restartAIServer() {
|
||||
return
|
||||
}
|
||||
|
||||
appLogln("🔄 AI Server wird neu gestartet…")
|
||||
publishAppNotification("info", "AI Server", "AI Server wird neu gestartet…", 2200)
|
||||
|
||||
if aiServerCurrent != nil {
|
||||
appLogln("🔄 AI Server wird neu gestartet…")
|
||||
aiServerCurrent.Stop()
|
||||
aiServerCurrent = nil
|
||||
}
|
||||
@ -656,6 +665,7 @@ func restartAIServer() {
|
||||
proc, err := startAIServer(ctx)
|
||||
if err != nil {
|
||||
appLogln("⚠️ AI Server Neustart fehlgeschlagen:", err)
|
||||
publishAppNotification("warning", "AI Server", "Neustart fehlgeschlagen: "+err.Error(), 5000)
|
||||
return
|
||||
}
|
||||
|
||||
@ -663,6 +673,97 @@ func restartAIServer() {
|
||||
|
||||
if proc != nil {
|
||||
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()
|
||||
aiServerCtx = appCtx
|
||||
aiServerCurrent = aiProc
|
||||
watchedTrainingRoot := aiServerRoot
|
||||
aiServerMu.Unlock()
|
||||
|
||||
go startTrainingBestModelWatcher(appCtx, watchedTrainingRoot)
|
||||
|
||||
// ✅ Hier: alte manuelle Autostart-Pause beim echten App-Start zurücksetzen.
|
||||
resetAutostartPauseOnStartup()
|
||||
|
||||
|
||||
173
backend/sse.go
173
backend/sse.go
@ -23,25 +23,27 @@ type appSSE struct {
|
||||
var sseApp *appSSE
|
||||
|
||||
type jobEvent struct {
|
||||
Type string `json:"type"`
|
||||
Model string `json:"model"`
|
||||
JobID string `json:"jobId"`
|
||||
Status JobStatus `json:"status"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
Progress int `json:"progress,omitempty"`
|
||||
SourceURL string `json:"sourceUrl,omitempty"`
|
||||
Output string `json:"output,omitempty"`
|
||||
StartedAt string `json:"startedAt,omitempty"`
|
||||
StartedAtMs int64 `json:"startedAtMs,omitempty"`
|
||||
EndedAt string `json:"endedAt,omitempty"`
|
||||
EndedAtMs int64 `json:"endedAtMs,omitempty"`
|
||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||
DurationSeconds float64 `json:"durationSeconds,omitempty"`
|
||||
PreviewState string `json:"previewState,omitempty"`
|
||||
RoomStatus string `json:"roomStatus,omitempty"`
|
||||
IsOnline bool `json:"isOnline,omitempty"`
|
||||
ModelImageURL string `json:"modelImageUrl,omitempty"`
|
||||
ModelChatRoomURL string `json:"modelChatRoomUrl,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Model string `json:"model"`
|
||||
JobID string `json:"jobId"`
|
||||
Status JobStatus `json:"status"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
Progress int `json:"progress,omitempty"`
|
||||
StopRequestedAtMs int64 `json:"stopRequestedAtMs,omitempty"`
|
||||
StopAttempts int `json:"stopAttempts,omitempty"`
|
||||
SourceURL string `json:"sourceUrl,omitempty"`
|
||||
Output string `json:"output,omitempty"`
|
||||
StartedAt string `json:"startedAt,omitempty"`
|
||||
StartedAtMs int64 `json:"startedAtMs,omitempty"`
|
||||
EndedAt string `json:"endedAt,omitempty"`
|
||||
EndedAtMs int64 `json:"endedAtMs,omitempty"`
|
||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||
DurationSeconds float64 `json:"durationSeconds,omitempty"`
|
||||
PreviewState string `json:"previewState,omitempty"`
|
||||
RoomStatus string `json:"roomStatus,omitempty"`
|
||||
IsOnline bool `json:"isOnline,omitempty"`
|
||||
ModelImageURL string `json:"modelImageUrl,omitempty"`
|
||||
ModelChatRoomURL string `json:"modelChatRoomUrl,omitempty"`
|
||||
|
||||
PostWorkKey string `json:"postWorkKey,omitempty"`
|
||||
PostWork any `json:"postWork,omitempty"`
|
||||
@ -82,6 +84,42 @@ type taskStateEvent struct {
|
||||
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 string `json:"type"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
@ -413,22 +451,24 @@ func visibleJobEventsJSON() []ssePublishItem {
|
||||
}
|
||||
|
||||
payload := jobEvent{
|
||||
Type: "job_upsert",
|
||||
Model: eventName,
|
||||
JobID: j.ID,
|
||||
Status: j.Status,
|
||||
Phase: j.Phase,
|
||||
Progress: j.Progress,
|
||||
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: j.PostWork,
|
||||
TS: 0, // wichtig: stabil halten, damit Dedupe funktioniert
|
||||
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: j.PostWork,
|
||||
TS: 0, // wichtig: stabil halten, damit Dedupe funktioniert
|
||||
}
|
||||
|
||||
if sm := sseStoredModelForJob(j); sm != nil {
|
||||
@ -478,6 +518,69 @@ func publishJobRemove(j *RecordJob) {
|
||||
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 {
|
||||
if j == nil {
|
||||
return ""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -18,6 +18,56 @@ type TrainingGroupedLabels struct {
|
||||
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 {
|
||||
if p, err := ensureTrainingDetectionLabelsFile(); err == nil {
|
||||
return p
|
||||
@ -114,13 +164,13 @@ func trainingGroupedLabels() (TrainingGroupedLabels, error) {
|
||||
}
|
||||
|
||||
grouped.People = uniqueNonEmptyLabels(grouped.People)
|
||||
grouped.SexPositions = uniqueNonEmptyLabels(grouped.SexPositions)
|
||||
grouped.SexPositions = normalizeSexPositionLabels(grouped.SexPositions)
|
||||
grouped.BodyParts = uniqueNonEmptyLabels(grouped.BodyParts)
|
||||
grouped.Objects = uniqueNonEmptyLabels(grouped.Objects)
|
||||
grouped.Clothing = uniqueNonEmptyLabels(grouped.Clothing)
|
||||
|
||||
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 {
|
||||
@ -146,7 +196,7 @@ func trainingDetectorLabels() ([]string, error) {
|
||||
|
||||
for _, label := range grouped.SexPositions {
|
||||
clean := strings.TrimSpace(label)
|
||||
if clean == "" || clean == "unknown" {
|
||||
if isNoSexPositionLabel(clean) {
|
||||
continue
|
||||
}
|
||||
|
||||
@ -205,11 +255,65 @@ func trainingDetectorDatasetNamesYAML() (string, error) {
|
||||
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 {
|
||||
grouped, err := trainingGroupedLabels()
|
||||
if err != nil {
|
||||
grouped = TrainingGroupedLabels{
|
||||
SexPositions: []string{"unknown"},
|
||||
SexPositions: []string{trainingNoSexPositionLabel},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -124,8 +124,8 @@ func TestTrainingEffectiveCorrectionClearsNegativeAnnotation(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
if effective.SexPosition != "unknown" {
|
||||
t.Fatalf("sex position = %q, want unknown", effective.SexPosition)
|
||||
if effective.SexPosition != trainingNoSexPositionLabel {
|
||||
t.Fatalf("sex position = %q, want %s", effective.SexPosition, trainingNoSexPositionLabel)
|
||||
}
|
||||
if len(effective.Boxes) != 0 {
|
||||
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 loadDoneCount()
|
||||
void loadTaskStateOnce()
|
||||
@ -2981,6 +3015,7 @@ export default function App() {
|
||||
es.addEventListener('taskState', onTaskState as any)
|
||||
es.addEventListener('training', onTraining as any)
|
||||
es.addEventListener('analysisProgress', onAnalysisProgress as any)
|
||||
es.addEventListener('notification', onNotification as any)
|
||||
|
||||
const onVis = () => {
|
||||
if (document.hidden) return
|
||||
@ -3008,6 +3043,7 @@ export default function App() {
|
||||
es.removeEventListener('taskState', onTaskState as any)
|
||||
es.removeEventListener('training', onTraining as any)
|
||||
es.removeEventListener('analysisProgress', onAnalysisProgress as any)
|
||||
es.removeEventListener('notification', onNotification as any)
|
||||
|
||||
const handler = onModelJobEventRef.current
|
||||
if (handler) {
|
||||
@ -3110,6 +3146,7 @@ export default function App() {
|
||||
async function stopJob(id: string) {
|
||||
try {
|
||||
await apiJSON(`/api/record/stop?id=${encodeURIComponent(id)}`, { method: 'POST' })
|
||||
void loadJobs()
|
||||
} catch (e: any) {
|
||||
notify.error('Stop fehlgeschlagen', e?.message ?? String(e))
|
||||
}
|
||||
@ -3118,6 +3155,7 @@ export default function App() {
|
||||
async function stopAllJobs() {
|
||||
try {
|
||||
await apiJSON('/api/record/stop-all', { method: 'POST' })
|
||||
void loadJobs()
|
||||
} catch (e: any) {
|
||||
notify.error('Alle stoppen fehlgeschlagen', e?.message ?? String(e))
|
||||
}
|
||||
@ -3887,6 +3925,7 @@ export default function App() {
|
||||
}, [autoAddEnabled, autoStartEnabled, startUrl])
|
||||
|
||||
const isTrainingTab = selectedTab === 'training'
|
||||
const [trainingTabMounted, setTrainingTabMounted] = useState(() => isTrainingTab)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTrainingTab) {
|
||||
@ -3894,6 +3933,12 @@ export default function App() {
|
||||
}
|
||||
}, [isTrainingTab])
|
||||
|
||||
useEffect(() => {
|
||||
if (isTrainingTab) {
|
||||
setTrainingTabMounted(true)
|
||||
}
|
||||
}, [isTrainingTab])
|
||||
|
||||
if (!authChecked) {
|
||||
return <div className="min-h-[100dvh] grid place-items-center">Lade…</div>
|
||||
}
|
||||
@ -4236,11 +4281,17 @@ export default function App() {
|
||||
<ModelsTab onAddToDownloads={handleAddToDownloads} />
|
||||
) : null}
|
||||
|
||||
{selectedTab === 'training' ? (
|
||||
<TrainingTab
|
||||
onTrainingRunningChange={setTrainingTabRunning}
|
||||
onImageExpandedChange={setTrainingImageExpanded}
|
||||
/>
|
||||
{trainingTabMounted ? (
|
||||
<div
|
||||
className={isTrainingTab ? undefined : 'hidden'}
|
||||
aria-hidden={isTrainingTab ? undefined : true}
|
||||
>
|
||||
<TrainingTab
|
||||
active={isTrainingTab}
|
||||
onTrainingRunningChange={setTrainingTabRunning}
|
||||
onImageExpandedChange={setTrainingImageExpanded}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedTab === 'categories' ? <CategoriesTab /> : null}
|
||||
|
||||
@ -8,8 +8,10 @@ export type AiLabelGroup =
|
||||
| 'clothing'
|
||||
| 'unknown'
|
||||
|
||||
const NO_POSITION_LABELS = new Set(['', 'keine'])
|
||||
|
||||
export const AI_POSITION_LABELS = [
|
||||
'unknown',
|
||||
'keine',
|
||||
'missionary',
|
||||
'doggy',
|
||||
'doggystyle',
|
||||
@ -109,7 +111,7 @@ export const AI_LABEL_FALLBACKS: Record<string, string> = {
|
||||
stockings: 'Strümpfe',
|
||||
croptop: 'Crop Top',
|
||||
|
||||
unknown: 'Unbekannt',
|
||||
keine: 'Keine',
|
||||
missionary: 'Missionary',
|
||||
doggy: 'Doggy',
|
||||
doggystyle: 'Doggy',
|
||||
@ -237,7 +239,7 @@ export function prettyAiLabel(value: unknown): string {
|
||||
export function aiLabelGroup(value: unknown): AiLabelGroup {
|
||||
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 (POSITION_SET.has(label)) return 'position'
|
||||
if (BODY_SET.has(label)) return 'body'
|
||||
@ -248,7 +250,8 @@ export function aiLabelGroup(value: unknown): AiLabelGroup {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@ -308,7 +308,7 @@ const addedAtMsOf = (r: DownloadRow): number => {
|
||||
const phaseLabel = (p?: string) => {
|
||||
switch ((p ?? '').toLowerCase()) {
|
||||
case 'stopping':
|
||||
return 'Stop wird angefordert…'
|
||||
return 'Stoppe Aufnahme…'
|
||||
case 'probe':
|
||||
return 'Analysiere Datei (Dauer/Streams)…'
|
||||
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(
|
||||
job: RecordJob,
|
||||
override?: { pos?: number; total?: number }
|
||||
@ -370,9 +409,11 @@ function postWorkLabel(
|
||||
function StatusCell({
|
||||
job,
|
||||
postworkInfo,
|
||||
nowMs,
|
||||
}: {
|
||||
job: RecordJob
|
||||
postworkInfo?: { pos?: number; total?: number }
|
||||
nowMs: number
|
||||
}) {
|
||||
const anyJ = job as any
|
||||
const phaseRaw = String(anyJ?.phase ?? '').trim()
|
||||
@ -398,7 +439,11 @@ function StatusCell({
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
text = phaseRaw ? (phaseLabel(phaseRaw) || phaseRaw) : String(anyJ?.status ?? '').trim().toLowerCase()
|
||||
if (phase === 'stopping' && canRetryStoppingJob(job, nowMs)) {
|
||||
text = 'Stop dauert zu lange - erneut stoppen'
|
||||
} else {
|
||||
text = phaseRaw ? (phaseLabel(phaseRaw) || phaseRaw) : String(anyJ?.status ?? '').trim().toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
const showBar =
|
||||
@ -720,30 +765,59 @@ export default function Downloads({
|
||||
|
||||
const [stopRequestedIds, setStopRequestedIds] = 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 [removeQueuedPostworkRequestedIds, setRemoveQueuedPostworkRequestedIds] = useState<Record<string, true>>({})
|
||||
|
||||
const markStopRequested = useCallback((ids: string | string[]) => {
|
||||
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) => {
|
||||
const next = { ...prev }
|
||||
for (const id of arr) {
|
||||
if (id) next[id] = true
|
||||
for (const id of cleanIds) {
|
||||
next[id] = true
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
setStopInitiatedIds((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const id of arr) {
|
||||
if (id) next[id] = true
|
||||
for (const id of cleanIds) {
|
||||
next[id] = true
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const timer of Object.values(stopRequestClearTimersRef.current)) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
stopRequestClearTimersRef.current = {}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const markRemovePendingRequested = useCallback((key: string) => {
|
||||
if (!key) return
|
||||
setRemovePendingRequestedKeys((prev) => ({ ...prev, [key]: true }))
|
||||
@ -791,7 +865,10 @@ export default function Downloads({
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
if (isStopping) {
|
||||
@ -994,15 +1071,12 @@ export default function Downloads({
|
||||
if (isPostworkJob(j)) return false
|
||||
if ((j as any).endedAt) return false
|
||||
|
||||
const phase = String((j as any).phase ?? '').trim()
|
||||
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
||||
const phaseLower = phase.trim().toLowerCase()
|
||||
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
|
||||
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
|
||||
const isStopping = isStoppingForUi(j, isStopRequested, nowMs)
|
||||
return !isStopping
|
||||
})
|
||||
.map((j) => j.id)
|
||||
}, [jobs, stopRequestedIds])
|
||||
}, [jobs, stopRequestedIds, nowMs])
|
||||
|
||||
const columns = useMemo<Column<DownloadRow>[]>(() => {
|
||||
return [
|
||||
@ -1187,7 +1261,7 @@ export default function Downloads({
|
||||
cell: (r) => {
|
||||
if (r.kind === '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
|
||||
@ -1308,16 +1382,14 @@ export default function Downloads({
|
||||
}
|
||||
|
||||
const j = r.job
|
||||
const phase = String((j as any).phase ?? '').trim()
|
||||
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
||||
const phaseLower = phase.trim().toLowerCase()
|
||||
|
||||
const postworkState = getEffectivePostworkState(j)
|
||||
const isQueuedPostwork = postworkState === 'queued'
|
||||
const isRemovingQueuedPostwork = Boolean(removeQueuedPostworkRequestedIds[j.id])
|
||||
|
||||
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
|
||||
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
|
||||
const canRetryStop = canRetryStoppingJob(j, nowMs) && !isStopRequested
|
||||
const isStopping = isStoppingForUi(j, isStopRequested, nowMs)
|
||||
|
||||
const buttonBusy = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping
|
||||
const disableStopButton = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping
|
||||
@ -1375,7 +1447,9 @@ export default function Downloads({
|
||||
>
|
||||
{isQueuedPostwork
|
||||
? (isRemovingQueuedPostwork ? 'Entferne…' : 'Entfernen')
|
||||
: (isStopping ? 'Stoppe…' : 'Stoppen')}
|
||||
: canRetryStop
|
||||
? 'Stop erneut'
|
||||
: (isStopping ? 'Stoppe…' : 'Stoppen')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@ -1519,15 +1593,16 @@ export default function Downloads({
|
||||
const stopRequested = Boolean(stopRequestedIds[j.id])
|
||||
const removingQueued = Boolean(removeQueuedPostworkRequestedIds[j.id])
|
||||
const isQueuedPostwork = getEffectivePostworkState(j) === 'queued'
|
||||
const stopWaiting = isStopWaitingForJob(j, stopRequested, nowMs)
|
||||
|
||||
return [
|
||||
'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',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}, [removePendingRequestedKeys, stopRequestedIds, removeQueuedPostworkRequestedIds])
|
||||
}, [removePendingRequestedKeys, stopRequestedIds, removeQueuedPostworkRequestedIds, nowMs])
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
@ -1707,17 +1782,16 @@ export default function Downloads({
|
||||
if (r.kind !== 'job') return null
|
||||
|
||||
const j = r.job
|
||||
const phase = String((j as any).phase ?? '').trim().toLowerCase()
|
||||
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
||||
const postworkState = getEffectivePostworkState(j)
|
||||
const isQueuedPostwork = postworkState === 'queued'
|
||||
const isBusyPhase = phase !== '' && phase !== 'recording'
|
||||
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
|
||||
const canRetryStop = canRetryStoppingJob(j, nowMs) && !isStopRequested
|
||||
const isStopping = isStoppingForUi(j, isStopRequested, nowMs)
|
||||
|
||||
return (
|
||||
<SwipeActionRow
|
||||
key={`dl:${j.id}`}
|
||||
actionLabel={isStopping ? 'Stoppe…' : 'Stoppen'}
|
||||
actionLabel={canRetryStop ? 'Stop erneut' : isStopping ? 'Stoppe…' : 'Stoppen'}
|
||||
actionColor="indigo"
|
||||
disabled={isStopping || isQueuedPostwork}
|
||||
onAction={async () => {
|
||||
@ -1822,20 +1896,21 @@ export default function Downloads({
|
||||
if (r.kind !== 'job') return null
|
||||
|
||||
const j = r.job
|
||||
const phase = String((j as any).phase ?? '').trim().toLowerCase()
|
||||
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
||||
|
||||
const postworkState = getEffectivePostworkState(j)
|
||||
const isQueuedPostwork = postworkState === 'queued'
|
||||
|
||||
const isBusyPhase = phase !== '' && phase !== 'recording'
|
||||
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
|
||||
const canRetryStop = canRetryStoppingJob(j, nowMs) && !isStopRequested
|
||||
const isStopping = isStoppingForUi(j, isStopRequested, nowMs)
|
||||
|
||||
const isRemovingQueuedPostwork = Boolean(removeQueuedPostworkRequestedIds[j.id])
|
||||
|
||||
const label = isQueuedPostwork
|
||||
? (isRemovingQueuedPostwork ? 'Entferne…' : 'Entfernen')
|
||||
: (isStopping ? 'Stoppe…' : 'Stoppen')
|
||||
: canRetryStop
|
||||
? 'Stop erneut'
|
||||
: (isStopping ? 'Stoppe…' : 'Stoppen')
|
||||
|
||||
const color = isQueuedPostwork ? 'red' : 'indigo'
|
||||
const disabled = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping
|
||||
@ -1979,4 +2054,4 @@ export default function Downloads({
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -320,7 +320,7 @@ const formatBytes = (bytes: number | null): string => {
|
||||
const phaseLabel = (p?: string) => {
|
||||
switch ((p ?? '').toLowerCase()) {
|
||||
case 'stopping':
|
||||
return 'Stop wird angefordert…'
|
||||
return 'Stoppe Aufnahme…'
|
||||
case 'probe':
|
||||
return 'Analysiere Datei (Dauer/Streams)…'
|
||||
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 s = String(status ?? '').trim().toLowerCase()
|
||||
return (
|
||||
@ -734,13 +773,12 @@ export default function DownloadsCardRow({
|
||||
const isRecording = phaseLower === 'recording'
|
||||
|
||||
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
||||
const rawStatus = String(j.status ?? '').toLowerCase()
|
||||
|
||||
const postworkState = getEffectivePostworkState(j)
|
||||
const isQueuedPostwork = postworkState === 'queued'
|
||||
|
||||
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
|
||||
const isStopping = isBusyPhase || rawStatus !== 'running' || isStopRequested
|
||||
const canRetryStop = canRetryStoppingJob(j, nowMs) && !isStopRequested
|
||||
const isStopping = isStoppingForUi(j, isStopRequested, nowMs)
|
||||
|
||||
const showRemoveQueuedButton = isQueuedPostwork
|
||||
const disableStopButton = showRemoveQueuedButton ? false : isStopping
|
||||
@ -768,7 +806,11 @@ export default function DownloadsCardRow({
|
||||
phaseText = phaseLabel(phase) || postWorkLabel(j, postworkInfoOf(j))
|
||||
}
|
||||
} else {
|
||||
phaseText = phase ? (phaseLabel(phase) || phase) : ''
|
||||
if (phaseLower === 'stopping' && canRetryStop) {
|
||||
phaseText = 'Stop dauert zu lange - erneut stoppen'
|
||||
} else {
|
||||
phaseText = phase ? (phaseLabel(phase) || phase) : ''
|
||||
}
|
||||
}
|
||||
|
||||
const progressLabel = phaseText || roomStatus
|
||||
@ -979,7 +1021,11 @@ export default function DownloadsCardRow({
|
||||
await onStopJob(j.id)
|
||||
}}
|
||||
>
|
||||
{showRemoveQueuedButton ? 'Entfernen' : (isStopping ? 'Stoppe…' : 'Stop')}
|
||||
{showRemoveQueuedButton
|
||||
? 'Entfernen'
|
||||
: canRetryStop
|
||||
? 'Stop erneut'
|
||||
: (isStopping ? 'Stoppe…' : 'Stop')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -987,4 +1033,4 @@ export default function DownloadsCardRow({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -120,6 +120,24 @@ const baseName = (p: string) => {
|
||||
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 f = baseName(p || '')
|
||||
return stripHotPrefix(f.replace(/\.[^.]+$/, ''))
|
||||
@ -2585,8 +2603,8 @@ export default function FinishedDownloads({
|
||||
(job: RecordJob): RecordJob => {
|
||||
const out = norm(job.output || '')
|
||||
const file = baseName(out)
|
||||
const override = renamedFiles[file]
|
||||
if (!override) return job
|
||||
const override = resolveRenamedFile(file, renamedFiles)
|
||||
if (!override || override === file) return job
|
||||
|
||||
const idx = out.lastIndexOf('/')
|
||||
const dir = idx >= 0 ? out.slice(0, idx + 1) : ''
|
||||
@ -2897,8 +2915,11 @@ export default function FinishedDownloads({
|
||||
}, [activeVisibleRows, keyFor, selectionStore, selectionVersion])
|
||||
|
||||
const hasSearchQuery = (searchQuery || '').trim() !== ''
|
||||
const selectionActionsVisible = selectedCount > 0 && selectionActionsOpen
|
||||
const selectionModeActive = selectedCount > 0
|
||||
const selectionActionsVisible = selectionActionsOpen && activeVisibleRows.length > 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 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(
|
||||
(key: string, file?: string) => {
|
||||
cancelRemoveTimer(key)
|
||||
@ -3470,7 +3513,7 @@ export default function FinishedDownloads({
|
||||
rowKey: key,
|
||||
setBusy: (v) => markDeleting(key, v),
|
||||
isBusyNow: () => deletingKeys.has(key),
|
||||
optimisticRemove: true,
|
||||
optimisticRemove: false,
|
||||
alreadyRemoved: opts?.alreadyRemoved,
|
||||
labels: {
|
||||
invalidTitle: 'Löschen nicht möglich',
|
||||
@ -3501,6 +3544,7 @@ export default function FinishedDownloads({
|
||||
},
|
||||
onSuccess: async (result: any) => {
|
||||
selectionStore.deselect(key)
|
||||
removeRowNow(key, file)
|
||||
|
||||
const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
|
||||
const from = typeof result?.from === 'string' ? result.from : undefined
|
||||
@ -3525,6 +3569,7 @@ export default function FinishedDownloads({
|
||||
runFileMutation,
|
||||
emitCountHint,
|
||||
selectionStore,
|
||||
removeRowNow,
|
||||
]
|
||||
)
|
||||
|
||||
@ -3600,10 +3645,28 @@ export default function FinishedDownloads({
|
||||
|
||||
setRenamedFiles((prev) => {
|
||||
const next: Record<string, string> = { ...prev }
|
||||
|
||||
const sources = new Set<string>([oldFile])
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if (k === newFile || v === newFile) {
|
||||
delete next[k]
|
||||
}
|
||||
}
|
||||
next[oldFile] = newFile
|
||||
|
||||
for (const source of sources) {
|
||||
if (!source || source === newFile) continue
|
||||
next[source] = newFile
|
||||
}
|
||||
|
||||
delete next[newFile]
|
||||
|
||||
return next
|
||||
})
|
||||
|
||||
@ -3614,9 +3677,33 @@ export default function FinishedDownloads({
|
||||
if (!a && !b) return
|
||||
setRenamedFiles((prev) => {
|
||||
const next: Record<string, string> = { ...prev }
|
||||
|
||||
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
|
||||
})
|
||||
}, [])
|
||||
@ -4025,7 +4112,7 @@ export default function FinishedDownloads({
|
||||
|
||||
if (item?.ok) {
|
||||
deletedKeys.add(key)
|
||||
animateRemove(key, file)
|
||||
removeRowNow(key, file)
|
||||
} else {
|
||||
failedKeys.add(key)
|
||||
}
|
||||
@ -4089,9 +4176,9 @@ export default function FinishedDownloads({
|
||||
}, [
|
||||
bulkBusy,
|
||||
selectedFiles,
|
||||
animateRemove,
|
||||
clearSelection,
|
||||
emitCountHint,
|
||||
removeRowNow,
|
||||
notify,
|
||||
])
|
||||
|
||||
@ -5031,10 +5118,10 @@ export default function FinishedDownloads({
|
||||
}, [isSmall])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCount === 0 && selectionActionsOpen) {
|
||||
if (activeVisibleRows.length === 0 && selectionActionsOpen) {
|
||||
setSelectionActionsOpen(false)
|
||||
}
|
||||
}, [selectedCount, selectionActionsOpen])
|
||||
}, [activeVisibleRows.length, selectionActionsOpen])
|
||||
|
||||
const desktopToolbarControlsClass = [
|
||||
'flex flex-col gap-3',
|
||||
@ -5278,24 +5365,23 @@ export default function FinishedDownloads({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedCount > 0 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="soft"
|
||||
onClick={() => setSelectionActionsOpen((v) => !v)}
|
||||
title={
|
||||
selectionActionsOpen
|
||||
? 'Auswahl-Aktionen ausblenden'
|
||||
: 'Auswahl-Aktionen anzeigen'
|
||||
}
|
||||
className={[
|
||||
'shrink-0 whitespace-nowrap font-semibold',
|
||||
selectionActionsOpen ? 'ring-2 ring-indigo-500/60' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
{selectedCount} ausgewählt
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="soft"
|
||||
disabled={selectionButtonDisabled}
|
||||
onClick={() => setSelectionActionsOpen((v) => !v)}
|
||||
title={
|
||||
selectionActionsOpen
|
||||
? 'Auswahl-Aktionen ausblenden'
|
||||
: 'Auswahlmodus öffnen'
|
||||
}
|
||||
className={[
|
||||
'shrink-0 whitespace-nowrap font-semibold',
|
||||
selectionActionsOpen ? 'ring-2 ring-indigo-500/60' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
{selectionButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@ -5340,7 +5426,7 @@ export default function FinishedDownloads({
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
leadingIcon={<XMarkIcon className="size-4 shrink-0" />}
|
||||
disabled={bulkBusy}
|
||||
disabled={selectedBulkActionsDisabled}
|
||||
onClick={clearSelection}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
@ -5352,7 +5438,7 @@ export default function FinishedDownloads({
|
||||
variant="soft"
|
||||
color="emerald"
|
||||
leadingIcon={<ArchiveBoxIcon className="size-4 shrink-0" />}
|
||||
disabled={bulkBusy}
|
||||
disabled={selectedBulkActionsDisabled}
|
||||
onClick={() => void bulkKeepSelected()}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
@ -5364,7 +5450,7 @@ export default function FinishedDownloads({
|
||||
variant="soft"
|
||||
color="red"
|
||||
leadingIcon={<TrashIcon className="size-4 shrink-0" />}
|
||||
disabled={bulkBusy}
|
||||
disabled={selectedBulkActionsDisabled}
|
||||
onClick={() => void bulkDeleteSelected()}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
@ -5396,7 +5482,7 @@ export default function FinishedDownloads({
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
leadingIcon={<XMarkIcon className="size-4 shrink-0" />}
|
||||
disabled={bulkBusy}
|
||||
disabled={selectedBulkActionsDisabled}
|
||||
onClick={clearSelection}
|
||||
>
|
||||
Auswahl aufheben
|
||||
@ -5409,7 +5495,7 @@ export default function FinishedDownloads({
|
||||
variant="soft"
|
||||
color="emerald"
|
||||
leadingIcon={<ArchiveBoxIcon className="size-4 shrink-0" />}
|
||||
disabled={bulkBusy}
|
||||
disabled={selectedBulkActionsDisabled}
|
||||
onClick={() => void bulkKeepSelected()}
|
||||
>
|
||||
Behalten
|
||||
@ -5420,7 +5506,7 @@ export default function FinishedDownloads({
|
||||
variant="soft"
|
||||
color="red"
|
||||
leadingIcon={<TrashIcon className="size-4 shrink-0" />}
|
||||
disabled={bulkBusy}
|
||||
disabled={selectedBulkActionsDisabled}
|
||||
onClick={() => void bulkDeleteSelected()}
|
||||
>
|
||||
Löschen
|
||||
@ -5610,11 +5696,28 @@ export default function FinishedDownloads({
|
||||
<div className="lg:hidden">
|
||||
<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="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">
|
||||
Abgeschlossene Downloads
|
||||
</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 ? (
|
||||
<LoadingSpinner
|
||||
size="sm"
|
||||
@ -5646,7 +5749,7 @@ export default function FinishedDownloads({
|
||||
size="md"
|
||||
variant={lastAction ? 'soft' : 'secondary'}
|
||||
color="emerald"
|
||||
className="flex-1"
|
||||
className="!hidden"
|
||||
disabled={!lastAction || undoing}
|
||||
onClick={undoLastAction}
|
||||
leadingIcon={<ArrowUturnLeftIcon className="size-4 shrink-0" />}
|
||||
@ -5699,23 +5802,22 @@ export default function FinishedDownloads({
|
||||
<span className="sr-only">Report</span>
|
||||
</Button>
|
||||
|
||||
{selectedCount > 0 ? (
|
||||
<Button
|
||||
size="md"
|
||||
variant="soft"
|
||||
onClick={() => {
|
||||
setSelectionActionsOpen((v) => !v)
|
||||
setMobileOptionsOpen(false)
|
||||
}}
|
||||
title={selectionActionsOpen ? 'Auswahl-Aktionen ausblenden' : 'Auswahl-Aktionen anzeigen'}
|
||||
className={[
|
||||
'shrink-0 whitespace-nowrap px-3 font-semibold',
|
||||
selectionActionsOpen ? 'ring-2 ring-indigo-500/60' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
{selectedCount} ausgewählt
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="md"
|
||||
variant="soft"
|
||||
disabled={selectionButtonDisabled}
|
||||
onClick={() => {
|
||||
setSelectionActionsOpen((v) => !v)
|
||||
setMobileOptionsOpen(false)
|
||||
}}
|
||||
title={selectionActionsOpen ? 'Auswahl-Aktionen ausblenden' : 'Auswahlmodus öffnen'}
|
||||
className={[
|
||||
'shrink-0 whitespace-nowrap px-3 font-semibold',
|
||||
selectionActionsOpen ? 'ring-2 ring-indigo-500/60' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
{selectionButtonLabel}
|
||||
</Button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@ -5779,7 +5881,7 @@ export default function FinishedDownloads({
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
leadingIcon={<XMarkIcon className="size-4 shrink-0" />}
|
||||
disabled={bulkBusy}
|
||||
disabled={selectedBulkActionsDisabled}
|
||||
onClick={clearSelection}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
@ -5791,7 +5893,7 @@ export default function FinishedDownloads({
|
||||
variant="soft"
|
||||
color="emerald"
|
||||
leadingIcon={<ArchiveBoxIcon className="size-4 shrink-0" />}
|
||||
disabled={bulkBusy}
|
||||
disabled={selectedBulkActionsDisabled}
|
||||
onClick={() => void bulkKeepSelected()}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
@ -5803,7 +5905,7 @@ export default function FinishedDownloads({
|
||||
variant="primary"
|
||||
color="red"
|
||||
leadingIcon={<TrashIcon className="size-4 shrink-0" />}
|
||||
disabled={bulkBusy}
|
||||
disabled={selectedBulkActionsDisabled}
|
||||
onClick={() => void bulkDeleteSelected()}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
@ -6069,6 +6171,7 @@ export default function FinishedDownloads({
|
||||
<FinishedDownloadsCardsView
|
||||
rows={cardRows}
|
||||
selectionStore={selectionStore}
|
||||
selectionModeActive={selectionModeActive}
|
||||
onToggleSelected={toggleSelected}
|
||||
onToggleSelectAllPage={handleToggleSelectAllPage}
|
||||
onSelectOnly={selectOnly}
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import Card from './Card'
|
||||
@ -63,6 +64,7 @@ type Props = {
|
||||
isLoading?: boolean
|
||||
isSmall: boolean
|
||||
selectionStore: FinishedSelectionStore
|
||||
selectionModeActive: boolean
|
||||
onToggleSelected: (job: RecordJob) => void
|
||||
onToggleSelectAllPage: (checked: boolean) => void
|
||||
onSelectOnly: (job: RecordJob) => void
|
||||
@ -320,6 +322,7 @@ function FinishedDownloadsCardsView({
|
||||
rows,
|
||||
isSmall,
|
||||
selectionStore,
|
||||
selectionModeActive,
|
||||
onToggleSelected,
|
||||
isLoading,
|
||||
blurPreviews,
|
||||
@ -450,7 +453,7 @@ function FinishedDownloadsCardsView({
|
||||
const k = keyFor(j)
|
||||
|
||||
const handleSelectOrOpen = () => {
|
||||
if (selectionStore.hasAnySelection()) {
|
||||
if (selectionModeActive) {
|
||||
onToggleSelected(j)
|
||||
return true
|
||||
}
|
||||
@ -869,7 +872,7 @@ function FinishedDownloadsCardsView({
|
||||
|
||||
if (isSmall) return
|
||||
|
||||
if (selectionStore.hasAnySelection()) {
|
||||
if (selectionModeActive) {
|
||||
onToggleSelected(j)
|
||||
return
|
||||
}
|
||||
@ -982,6 +985,36 @@ function FinishedDownloadsCardsView({
|
||||
}
|
||||
|
||||
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(() => {
|
||||
if (!isSmall || skippedMobileKeys.length === 0) return rows
|
||||
@ -1007,13 +1040,14 @@ function FinishedDownloadsCardsView({
|
||||
|
||||
const topKey = keyFor(topRow)
|
||||
|
||||
lockMobileStackHeight()
|
||||
setSpeedHoldKey((prev) => (prev === topKey ? null : prev))
|
||||
|
||||
setSkippedMobileKeys((prev) => [
|
||||
...prev.filter((key) => key !== topKey),
|
||||
topKey,
|
||||
])
|
||||
}, [isSmall, mobileOrderedRows, keyFor])
|
||||
}, [isSmall, mobileOrderedRows, keyFor, lockMobileStackHeight])
|
||||
|
||||
const mobileStackDepth = 3
|
||||
|
||||
@ -1125,6 +1159,11 @@ function FinishedDownloadsCardsView({
|
||||
|
||||
// weil wir nach OBEN stacken, brauchen wir oben Platz
|
||||
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 (
|
||||
@ -1139,6 +1178,7 @@ function FinishedDownloadsCardsView({
|
||||
key={`${k}__desktop`}
|
||||
job={j}
|
||||
selectionStore={selectionStore}
|
||||
selectionModeActive={selectionModeActive}
|
||||
isDeleting={deletingKeys.has(k)}
|
||||
isKeeping={keepingKeys.has(k)}
|
||||
isRemoving={removingKeys.has(k)}
|
||||
@ -1196,8 +1236,9 @@ function FinishedDownloadsCardsView({
|
||||
) : null
|
||||
) : (
|
||||
<div
|
||||
ref={mobileStackFrameRef}
|
||||
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”) */}
|
||||
<div
|
||||
@ -1295,14 +1336,14 @@ function FinishedDownloadsCardsView({
|
||||
renderRole: 'top',
|
||||
})
|
||||
|
||||
const selectionModeActive = selectionStore.hasAnySelection()
|
||||
const topCardSelectionModeActive = selectionModeActive
|
||||
|
||||
const canSpeedHold =
|
||||
!inlineActive &&
|
||||
!busy &&
|
||||
(
|
||||
allowTeaserAnimation ||
|
||||
(selectionModeActive && teaserState.mode !== 'still')
|
||||
(topCardSelectionModeActive && teaserState.mode !== 'still')
|
||||
)
|
||||
|
||||
return (
|
||||
@ -1331,7 +1372,7 @@ function FinishedDownloadsCardsView({
|
||||
onTap={() => {
|
||||
if (!mobileTopTapEnabled) return
|
||||
|
||||
if (selectionStore.hasAnySelection()) {
|
||||
if (topCardSelectionModeActive) {
|
||||
onToggleSelected(j)
|
||||
return
|
||||
}
|
||||
@ -1350,10 +1391,12 @@ function FinishedDownloadsCardsView({
|
||||
})
|
||||
}}
|
||||
onSwipeLeft={async () => {
|
||||
lockMobileStackHeight()
|
||||
setMobileTopTeaserEnabled(false)
|
||||
return await deleteVideo(j)
|
||||
}}
|
||||
onSwipeRight={async () => {
|
||||
lockMobileStackHeight()
|
||||
setMobileTopTeaserEnabled(false)
|
||||
return await keepVideo(j)
|
||||
}}
|
||||
@ -1411,4 +1454,4 @@ function FinishedDownloadsCardsView({
|
||||
}
|
||||
|
||||
const MemoFinishedDownloadsCardsView = memo(FinishedDownloadsCardsView)
|
||||
export default MemoFinishedDownloadsCardsView
|
||||
export default MemoFinishedDownloadsCardsView
|
||||
|
||||
@ -46,6 +46,7 @@ import { autoTagsForJob, mergeTags } from './autoTags'
|
||||
export type FinishedDownloadsDesktopCardProps = {
|
||||
job: RecordJob
|
||||
selectionStore: FinishedSelectionStore
|
||||
selectionModeActive: boolean
|
||||
isDeleting: boolean
|
||||
isKeeping: boolean
|
||||
isRemoving: boolean
|
||||
@ -137,6 +138,7 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
function FinishedDownloadsDesktopCard({
|
||||
job: j,
|
||||
selectionStore,
|
||||
selectionModeActive,
|
||||
isDeleting,
|
||||
isKeeping,
|
||||
isRemoving,
|
||||
@ -199,12 +201,12 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
})
|
||||
|
||||
const handleSelectOrOpen = useCallback(() => {
|
||||
if (selectionStore.hasAnySelection()) {
|
||||
if (selectionModeActive) {
|
||||
onToggleSelected(j)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, [selectionStore, onToggleSelected, j])
|
||||
}, [selectionModeActive, onToggleSelected, j])
|
||||
|
||||
const clearLongPressTimer = useCallback(() => {
|
||||
if (longPressTimerRef.current !== null) {
|
||||
@ -216,14 +218,14 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
const startLongPressSelect = useCallback(() => {
|
||||
clearLongPressTimer()
|
||||
|
||||
if (selectionStore.hasAnySelection()) return
|
||||
if (selectionModeActive) return
|
||||
|
||||
longPressTimerRef.current = window.setTimeout(() => {
|
||||
longPressTimerRef.current = null
|
||||
longPressTriggeredRef.current = true
|
||||
onToggleSelected(j)
|
||||
}, 450)
|
||||
}, [clearLongPressTimer, selectionStore, onToggleSelected, j])
|
||||
}, [clearLongPressTimer, selectionModeActive, onToggleSelected, j])
|
||||
|
||||
const previewMuted = forcePreviewMuted ? true : !allowSound
|
||||
|
||||
@ -378,7 +380,7 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
<div
|
||||
className={[
|
||||
'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(' ')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
@ -624,4 +626,4 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
}
|
||||
)
|
||||
|
||||
export default FinishedDownloadsDesktopCard
|
||||
export default FinishedDownloadsDesktopCard
|
||||
|
||||
@ -90,6 +90,60 @@ function baseName(path: string) {
|
||||
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({
|
||||
job,
|
||||
getFileName,
|
||||
@ -319,8 +373,14 @@ export default function FinishedVideoPreview({
|
||||
return !document.hidden
|
||||
})
|
||||
|
||||
const mobilePreviewConstrained = useMobilePreviewConstrained()
|
||||
const effectiveInView = forceActive || inView
|
||||
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' =
|
||||
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 }
|
||||
}
|
||||
|
||||
const hardStop = (v: HTMLVideoElement | null) => {
|
||||
const hardStop = useCallback((v: HTMLVideoElement | null) => {
|
||||
if (!v) return
|
||||
try {
|
||||
v.pause()
|
||||
@ -528,7 +588,7 @@ export default function FinishedVideoPreview({
|
||||
v.src = ''
|
||||
v.load()
|
||||
} catch {}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@ -581,7 +641,7 @@ export default function FinishedVideoPreview({
|
||||
window.removeEventListener('player:release', onRelease as EventListener)
|
||||
window.removeEventListener('player:close', onRelease as EventListener)
|
||||
}
|
||||
}, [file])
|
||||
}, [file, hardStop])
|
||||
|
||||
// --- IntersectionObserver: echtes inView (für Playback)
|
||||
useEffect(() => {
|
||||
@ -716,7 +776,8 @@ export default function FinishedVideoPreview({
|
||||
|
||||
// ✅ Teaser/Clips "vorwärmen": schon in nearView erlauben
|
||||
const shouldPreloadAnimatedAssets =
|
||||
forceActive || effectiveNearView || effectiveInView || (wantsHover && hovered)
|
||||
!mobilePreviewConstrained &&
|
||||
(forceActive || effectiveNearView || effectiveInView || (wantsHover && hovered))
|
||||
|
||||
useEffect(() => {
|
||||
if (animatedMode !== 'teaser' || showingInlineVideo || !teaserActive) {
|
||||
@ -808,10 +869,12 @@ export default function FinishedVideoPreview({
|
||||
}
|
||||
|
||||
// ✅ aktive Asset-Nutzung (z.B. poster etc.)
|
||||
const shouldLoadAssets = shouldPreloadAnimatedAssets
|
||||
const shouldLoadAssets =
|
||||
shouldPreloadAnimatedAssets || effectiveInView || showingInlineVideo || teaserActive
|
||||
|
||||
const teaserCanPrewarm =
|
||||
animated &&
|
||||
!mobilePreviewConstrained &&
|
||||
animatedMode === 'teaser' &&
|
||||
Boolean(teaserSrc) &&
|
||||
!showingInlineVideo &&
|
||||
@ -1037,12 +1100,12 @@ export default function FinishedVideoPreview({
|
||||
const onLoadedData = () => {
|
||||
setTeaserReady(true)
|
||||
tryPlay()
|
||||
scheduleRetry(120)
|
||||
scheduleRetry(teaserRetryDelayMs)
|
||||
}
|
||||
|
||||
const onCanPlay = () => {
|
||||
tryPlay()
|
||||
scheduleRetry(120)
|
||||
scheduleRetry(teaserRetryDelayMs)
|
||||
}
|
||||
|
||||
const onPause = () => {
|
||||
@ -1053,12 +1116,12 @@ export default function FinishedVideoPreview({
|
||||
if (el.ended) return
|
||||
|
||||
// iOS-Fall: erster Frame sichtbar, aber Playback stoppt direkt wieder
|
||||
scheduleRetry(120)
|
||||
scheduleRetry(teaserRetryDelayMs)
|
||||
}
|
||||
|
||||
const onWaiting = () => {
|
||||
if (cancelled) return
|
||||
scheduleRetry(120)
|
||||
scheduleRetry(teaserRetryDelayMs)
|
||||
}
|
||||
|
||||
vv.addEventListener('playing', onPlaying)
|
||||
@ -1090,15 +1153,15 @@ export default function FinishedVideoPreview({
|
||||
const stalledFor = now - teaserLastProgressTimeRef.current
|
||||
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 })
|
||||
return
|
||||
}
|
||||
|
||||
if (el.paused && !el.ended && stalledFor > 180) {
|
||||
if (el.paused && !el.ended && stalledFor > teaserWatchdogMs) {
|
||||
tryPlay()
|
||||
}
|
||||
}, 180)
|
||||
}, teaserWatchdogMs)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
@ -1114,7 +1177,15 @@ export default function FinishedVideoPreview({
|
||||
if (typeof raf2 === 'number') cancelAnimationFrame(raf2)
|
||||
window.clearInterval(watchdog)
|
||||
}
|
||||
}, [teaserActive, animatedMode, teaserSrc, pageVisible])
|
||||
}, [
|
||||
teaserActive,
|
||||
animatedMode,
|
||||
teaserSrc,
|
||||
pageVisible,
|
||||
teaserRetryDelayMs,
|
||||
teaserWatchdogMs,
|
||||
teaserSpeedHold,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activationNonce) return
|
||||
@ -1213,7 +1284,7 @@ export default function FinishedVideoPreview({
|
||||
sync()
|
||||
|
||||
// ✅ 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
|
||||
const onLoaded = () => sync()
|
||||
@ -1230,7 +1301,7 @@ export default function FinishedVideoPreview({
|
||||
vv.removeEventListener('durationchange', onLoaded)
|
||||
vv.removeEventListener('timeupdate', onTime)
|
||||
}
|
||||
}, [showProgressBar, progressVideoRef, progressTotalSeconds, previewClipMapKey, progressKind, previewClipMap, progressMountTick])
|
||||
}, [showProgressBar, progressVideoRef, progressTotalSeconds, previewClipMapKey, progressKind, previewClipMap, progressMountTick, mobilePreviewConstrained])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showingInlineVideo) return
|
||||
@ -1287,7 +1358,7 @@ export default function FinishedVideoPreview({
|
||||
|
||||
// ✅ brauchen wir noch hidden-metadata-load?
|
||||
const needHiddenMeta =
|
||||
(forceActive || effectiveNearView || effectiveInView) &&
|
||||
metadataLoadRangeActive &&
|
||||
(onDuration || onResolution) &&
|
||||
!metaLoaded &&
|
||||
!showingInlineVideo &&
|
||||
@ -1437,7 +1508,7 @@ export default function FinishedVideoPreview({
|
||||
playsInline
|
||||
autoPlay
|
||||
loop
|
||||
preload="auto"
|
||||
preload={mobilePreviewConstrained ? 'metadata' : 'auto'}
|
||||
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
|
||||
onLoadedData={() => {
|
||||
setTeaserOk(true)
|
||||
|
||||
@ -1100,8 +1100,8 @@ const SEGMENT_LABEL_META: SegmentLabelMeta[] = [
|
||||
icon: DoggyIcon,
|
||||
},
|
||||
{
|
||||
match: ['unknown'],
|
||||
text: 'Unbekannt',
|
||||
match: ['keine'],
|
||||
text: 'Keine',
|
||||
icon: UnknownContentIcon,
|
||||
},
|
||||
// People
|
||||
@ -1697,4 +1697,4 @@ export function getHighestPrioritySegmentLabelItem(
|
||||
if (pa !== pb) return pa - pb
|
||||
return a.text.localeCompare(b.text, 'de')
|
||||
})[0]
|
||||
}
|
||||
}
|
||||
|
||||
@ -451,8 +451,6 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
||||
thresholdRef.current ||
|
||||
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 tiltDeg = clamp(dx / 34, -ROTATE_MAX_DEG, ROTATE_MAX_DEG)
|
||||
@ -478,17 +476,14 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
||||
transform:
|
||||
dx !== 0
|
||||
? `translate3d(${dx}px,0,0) rotate(${tiltDeg}deg) scale(${dragScale})`
|
||||
: undefined,
|
||||
: 'translate3d(0,0,0)',
|
||||
transition: animMs
|
||||
? `transform ${animMs}ms cubic-bezier(0.22, 1, 0.36, 1)`
|
||||
: undefined,
|
||||
touchAction: baseTouchAction,
|
||||
willChange: dx !== 0 ? 'transform' : undefined,
|
||||
willChange: 'transform',
|
||||
backfaceVisibility: 'hidden',
|
||||
borderRadius: dx !== 0 ? '12px' : undefined,
|
||||
filter:
|
||||
dx !== 0
|
||||
? `saturate(${1 + reveal * 0.06}) brightness(${1 + reveal * 0.015})`
|
||||
: undefined,
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (!enabled || disabled) return
|
||||
@ -879,4 +874,4 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
||||
)
|
||||
})
|
||||
|
||||
export default SwipeCard
|
||||
export default SwipeCard
|
||||
|
||||
@ -84,6 +84,19 @@ function clampPercent(value: number) {
|
||||
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 {
|
||||
const x = clamp01(Number(box.x))
|
||||
const y = clamp01(Number(box.y))
|
||||
@ -104,7 +117,7 @@ function annotationEffectiveCorrection(
|
||||
): TrainingFeedbackCorrection {
|
||||
if (annotation.negative) {
|
||||
return {
|
||||
sexPosition: 'unknown',
|
||||
sexPosition: NO_SEX_POSITION_LABEL,
|
||||
peoplePresent: [],
|
||||
bodyPartsPresent: [],
|
||||
objectsPresent: [],
|
||||
@ -114,11 +127,14 @@ function annotationEffectiveCorrection(
|
||||
}
|
||||
|
||||
if (annotation.correction) {
|
||||
return annotation.correction
|
||||
return {
|
||||
...annotation.correction,
|
||||
sexPosition: normalizeSexPositionValue(annotation.correction.sexPosition),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sexPosition: annotation.prediction.sexPosition || 'unknown',
|
||||
sexPosition: normalizeSexPositionValue(annotation.prediction.sexPosition),
|
||||
peoplePresent: (annotation.prediction.peoplePresent ?? []).map((item) => item.label),
|
||||
bodyPartsPresent: (annotation.prediction.bodyPartsPresent ?? []).map((item) => item.label),
|
||||
objectsPresent: (annotation.prediction.objectsPresent ?? []).map((item) => item.label),
|
||||
@ -685,7 +701,7 @@ function FeedbackListItem(props: {
|
||||
</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">
|
||||
{effective.sexPosition || 'unknown'}
|
||||
{normalizeSexPositionValue(effective.sexPosition)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -850,7 +866,7 @@ function ResultLabelsCard(props: {
|
||||
const groups = [
|
||||
{
|
||||
title: 'Position',
|
||||
values: [props.effective.sexPosition || 'unknown'],
|
||||
values: [normalizeSexPositionValue(props.effective.sexPosition)],
|
||||
},
|
||||
{
|
||||
title: 'Personen',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -190,6 +190,8 @@ export type RecordJob = {
|
||||
|
||||
phase?: string
|
||||
progress?: number
|
||||
stopRequestedAtMs?: number
|
||||
stopAttempts?: number
|
||||
|
||||
postWorkKey?: string
|
||||
postWork?: PostWorkKeyStatus
|
||||
@ -741,4 +743,4 @@ export type VideoHeatmapSegment = {
|
||||
intensity: number
|
||||
label?: string
|
||||
source?: VideoHeatmapSource
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user