diff --git a/backend/ai_server.py b/backend/ai_server.py index df06f7c..1487a4e 100644 --- a/backend/ai_server.py +++ b/backend/ai_server.py @@ -248,7 +248,10 @@ _POSE_RELIABLE_MIN_QUALITY = float(os.environ.get("YOLO_POSE_RELIABLE_MIN_QUALIT _POSITION_CONTEXT_MIN_SCORE = float(os.environ.get("YOLO_POSITION_CONTEXT_MIN_SCORE", "0.22")) _POSITION_CONTEXT_MAX_SCORE = float(os.environ.get("YOLO_POSITION_CONTEXT_MAX_SCORE", "0.44")) _POSITION_CONTEXT_BOOST_WEIGHT = float(os.environ.get("YOLO_POSITION_CONTEXT_BOOST_WEIGHT", "0.60")) -_POSITION_CONTEXT_OVERRIDE_MARGIN = float(os.environ.get("YOLO_POSITION_CONTEXT_OVERRIDE_MARGIN", "0.16")) +_POSE_CONFIRMING_CONTEXT_MIN_SCORE = float(os.environ.get("YOLO_POSE_CONFIRMING_CONTEXT_MIN_SCORE", "0.14")) +_POSE_UNCONFIRMED_MAX_SCORE = float(os.environ.get("YOLO_POSE_UNCONFIRMED_MAX_SCORE", "0.38")) +_POSE_STRONG_UNCONFIRMED_MIN_SCORE = float(os.environ.get("YOLO_POSE_STRONG_UNCONFIRMED_MIN_SCORE", "0.70")) +_POSE_STRONG_UNCONFIRMED_MAX_SCORE = float(os.environ.get("YOLO_POSE_STRONG_UNCONFIRMED_MAX_SCORE", "0.46")) _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"} @@ -919,6 +922,24 @@ def box_horizontal_overlap_ratio(a: dict, b: dict) -> float: return clamp01((right - left) / min_width) +def box_vertical_overlap_ratio(a: dict, b: dict) -> float: + a = normalized_box(a) + b = normalized_box(b) + if not a or not b: + return 0.0 + + top = max(float(a["y"]), float(b["y"])) + bottom = min(float(a["y"]) + float(a["h"]), float(b["y"]) + float(b["h"])) + if bottom <= top: + return 0.0 + + min_height = min(float(a["h"]), float(b["h"])) + if min_height <= 0: + return 0.0 + + return clamp01((bottom - top) / min_height) + + def is_person_like_label(label: str) -> bool: clean = str(label or "").strip().lower() return clean == "person" or clean.startswith("person_") @@ -1170,6 +1191,49 @@ def point_distance(a: tuple[float, float] | None, b: tuple[float, float] | None) return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) +def projected_point_distance( + a: tuple[float, float] | None, + b: tuple[float, float] | None, + axis_x: float, + axis_y: float, +) -> float: + if not a or not b: + return 0.0 + + return abs((a[0] - b[0]) * axis_x + (a[1] - b[1]) * axis_y) + + +def pose_extents_along_axis( + person: dict, + origin: tuple[float, float], + axis_x: float, + axis_y: float, + perp_x: float, + perp_y: float, + min_conf: float = 0.20, +) -> tuple[float, float] | None: + long_values: list[float] = [] + cross_values: list[float] = [] + + for point in person.get("keypoints", []) or []: + conf = float(point.get("conf") or 0.0) + x = float(point.get("x") or 0.0) + y = float(point.get("y") or 0.0) + + if conf < min_conf or not is_finite01(x) or not is_finite01(y): + continue + + dx = x - origin[0] + dy = y - origin[1] + long_values.append(dx * axis_x + dy * axis_y) + cross_values.append(dx * perp_x + dy * perp_y) + + if len(long_values) < 3: + return None + + return abs(max(long_values) - min(long_values)), abs(max(cross_values) - min(cross_values)) + + def pose_person_geometry(person: dict) -> dict: box = normalized_box(person.get("box")) box_center_point = box_center(box) if box else None @@ -1189,30 +1253,74 @@ def pose_person_geometry(person: dict) -> dict: torso_dx = 0.0 torso_dy = 0.0 torso_len = 0.0 + torso_angle = 0.0 + has_torso_axis = False + axis_x = 0.0 + axis_y = 1.0 + perp_x = -1.0 + perp_y = 0.0 if hip and shoulder: - torso_dx = abs(hip[0] - shoulder[0]) - torso_dy = abs(hip[1] - shoulder[1]) - torso_len = math.sqrt(torso_dx * torso_dx + torso_dy * torso_dy) + raw_dx = hip[0] - shoulder[0] + raw_dy = hip[1] - shoulder[1] + torso_dx = abs(raw_dx) + torso_dy = abs(raw_dy) + torso_len = math.sqrt(raw_dx * raw_dx + raw_dy * raw_dy) + if torso_len >= 0.07: + has_torso_axis = True + axis_x = raw_dx / torso_len + axis_y = raw_dy / torso_len + perp_x = -axis_y + perp_y = axis_x + torso_angle = math.atan2(axis_y, axis_x) hip_width = point_distance(left_hip, right_hip) knee_width = point_distance(left_knee, right_knee) knees_below_hips = bool(knee and hip and knee[1] > hip[1] + 0.045) + if has_torso_axis and knee and hip: + knee_projection = (knee[0] - hip[0]) * axis_x + (knee[1] - hip[1]) * axis_y + knees_below_hips = knee_projection > 0.045 + + if has_torso_axis and left_knee and right_knee: + knee_width = projected_point_distance(left_knee, right_knee, perp_x, perp_y) + if left_hip and right_hip: + hip_width = projected_point_distance(left_hip, right_hip, perp_x, perp_y) + knees_wide = bool( knee_width > 0 and knee_width >= max(0.11, hip_width * 1.12) ) straddling = knees_below_hips and knees_wide + body_long = torso_len + body_cross = max(hip_width, knee_width) + + if has_torso_axis: + pose_extents = pose_extents_along_axis(person, center, axis_x, axis_y, perp_x, perp_y) + if pose_extents: + body_long = max(body_long, pose_extents[0]) + body_cross = max(body_cross, pose_extents[1]) + + if box: + box_long = abs(axis_x) * float(box["w"]) + abs(axis_y) * float(box["h"]) + box_cross = abs(perp_x) * float(box["w"]) + abs(perp_y) * float(box["h"]) + body_long = max(body_long, box_long) + body_cross = max(body_cross, box_cross) + elif box: + body_long = max(float(box["w"]), float(box["h"])) + body_cross = min(float(box["w"]), float(box["h"])) + + elongated = body_long >= max(0.18, body_cross * 1.18) + torso_horizontal = torso_len >= 0.07 and torso_dx >= torso_dy * 1.15 torso_vertical = torso_len >= 0.07 and torso_dy >= torso_dx * 1.15 box_horizontal = bool(box and float(box["w"]) >= float(box["h"]) * 1.05) box_vertical = bool(box and float(box["h"]) >= float(box["w"]) * 1.25) - lying = torso_horizontal or box_horizontal + lying = torso_horizontal or box_horizontal or (has_torso_axis and elongated and not box_vertical) upright = torso_vertical or box_vertical - all_fours = torso_horizontal and knees_below_hips + all_fours = knees_below_hips and not straddling and (torso_horizontal or (has_torso_axis and elongated)) bent_or_kneeling = all_fours or (knees_below_hips and not straddling) return { @@ -1222,6 +1330,15 @@ def pose_person_geometry(person: dict) -> dict: "hip": hip, "shoulder": shoulder, "knee": knee, + "torso_angle": torso_angle, + "has_torso_axis": has_torso_axis, + "axis_x": axis_x, + "axis_y": axis_y, + "perp_x": perp_x, + "perp_y": perp_y, + "body_long": body_long, + "body_cross": body_cross, + "elongated": elongated, "lying": lying, "upright": upright, "straddling": straddling, @@ -1242,6 +1359,14 @@ def combine_position_score(scores: dict[str, float], label: str, score: float) - scores[label] = clamp01(1 - (1 - current) * (1 - score)) +def pose_axis_alignment(a: dict, b: dict) -> tuple[float, bool]: + if not a.get("has_torso_axis") or not b.get("has_torso_axis"): + return 0.0, False + + # Körperachsen sind richtungslos: 0° und 180° gelten beide als parallel. + return abs(math.cos(float(a.get("torso_angle") or 0.0) - float(b.get("torso_angle") or 0.0))), True + + def append_prediction_source(prediction: dict, source: str) -> None: source = str(source or "").strip() if not source: @@ -1272,9 +1397,6 @@ def fuse_hybrid_position_scores( best_score = 0.0 best_has_pose = False best_has_context = False - best_pose_position = "" - best_pose_score = 0.0 - best_pose_has_context = False for label in labels: pose_score = clamp01(pose_scores.get(label, 0.0)) @@ -1284,10 +1406,15 @@ def fuse_hybrid_position_scores( score = 0.0 if has_pose: - score = pose_score - if has_context: + if has_context and context_score >= _POSE_CONFIRMING_CONTEXT_MIN_SCORE: + score = pose_score boost = clamp01(context_score * _POSITION_CONTEXT_BOOST_WEIGHT) score = clamp01(1 - (1 - score) * (1 - boost)) + else: + max_unconfirmed_score = _POSE_UNCONFIRMED_MAX_SCORE + if pose_score >= _POSE_STRONG_UNCONFIRMED_MIN_SCORE: + max_unconfirmed_score = _POSE_STRONG_UNCONFIRMED_MAX_SCORE + score = min(max_unconfirmed_score, pose_score) elif context_score >= _POSITION_CONTEXT_MIN_SCORE: score = min(_POSITION_CONTEXT_MAX_SCORE, context_score) @@ -1297,21 +1424,6 @@ def fuse_hybrid_position_scores( best_has_pose = has_pose best_has_context = has_context - if has_pose and score > best_pose_score: - best_pose_position = label - best_pose_score = score - best_pose_has_context = has_context - - if ( - best_pose_position - and not best_has_pose - and best_score <= best_pose_score + _POSITION_CONTEXT_OVERRIDE_MARGIN - ): - best_position = best_pose_position - best_score = best_pose_score - best_has_pose = True - best_has_context = best_pose_has_context - return best_position, clamp01(best_score), best_has_pose, best_has_context @@ -1338,6 +1450,7 @@ def add_pose_pair_geometry_scores(scores: dict[str, float], persons: list[dict]) gap = box_gap(left_box, right_box) overlap = box_overlap_ratio(left_box, right_box) horizontal_overlap = box_horizontal_overlap_ratio(left_box, right_box) + vertical_overlap = box_vertical_overlap_ratio(left_box, right_box) close = gap <= 0.12 or overlap >= 0.08 if not close: @@ -1349,34 +1462,79 @@ def add_pose_pair_geometry_scores(scores: dict[str, float], persons: list[dict]) bottom = right if top is left else left top_above = top["center"][1] <= bottom["center"][1] - 0.055 - strong_stack = horizontal_overlap >= 0.35 and (top_above or overlap >= 0.22) - top_has_rider_shape = ( - top["straddling"] - or top["knees_wide"] - or (top["upright"] and top["knees_below_hips"]) - ) + horizontal_stack = horizontal_overlap >= 0.35 and (top_above or overlap >= 0.22) + lateral_stack = vertical_overlap >= 0.35 and overlap >= 0.12 + strong_stack = horizontal_stack or lateral_stack - if strong_stack and top_has_rider_shape: + axis_alignment, has_axis_alignment = pose_axis_alignment(left, right) + axes_parallel = has_axis_alignment and axis_alignment >= 0.74 + axes_crossed = has_axis_alignment and axis_alignment <= 0.56 + + def has_strong_rider_shape(geometry: dict) -> bool: + return bool( + geometry["straddling"] + or ( + geometry["knees_wide"] + and geometry["knees_below_hips"] + and (geometry["upright"] or axes_crossed) + ) + ) + + def has_weak_rider_shape(geometry: dict) -> bool: + return bool(geometry["knees_wide"] and geometry["knees_below_hips"]) + + left_has_strong_rider_shape = has_strong_rider_shape(left) + right_has_strong_rider_shape = has_strong_rider_shape(right) + top_has_strong_rider_shape = has_strong_rider_shape(top) + top_has_weak_rider_shape = has_weak_rider_shape(top) + + rider = top + base = bottom + rider_has_strong_shape = top_has_strong_rider_shape + rider_has_weak_shape = top_has_weak_rider_shape + if left_has_strong_rider_shape != right_has_strong_rider_shape: + if left_has_strong_rider_shape: + rider = left + base = right + rider_has_strong_shape = True + rider_has_weak_shape = has_weak_rider_shape(left) + else: + rider = right + base = left + rider_has_strong_shape = True + rider_has_weak_shape = has_weak_rider_shape(right) + + if strong_stack and rider_has_strong_shape and not axes_parallel: add("cowgirl", 0.20) add("reverse_cowgirl", 0.17) - if bottom["lying"]: + if base["lying"]: add("cowgirl", 0.12) add("reverse_cowgirl", 0.10) - if top["straddling"]: + if rider["straddling"]: add("cowgirl", 0.08) add("reverse_cowgirl", 0.06) + elif strong_stack and rider_has_weak_shape and not has_axis_alignment: + # Ohne verwertbare Achsen bleibt Cowgirl nur ein schwaches Signal. + # Sobald die Achsen parallel sind, sieht die Szene eher nach + # Missionary/Überlagerung aus und soll nicht in Cowgirl kippen. + add("cowgirl", 0.08) + add("reverse_cowgirl", 0.06) - if strong_stack and bottom["lying"]: - add("missionary", 0.14) - if not top["straddling"]: + if strong_stack and (bottom["lying"] or (axes_parallel and top_above)): + if not top_has_strong_rider_shape or axes_parallel: + add("missionary", 0.14) + if axes_parallel: + add("missionary", 0.08) + if not top["straddling"] and not top_has_strong_rider_shape: add("missionary", 0.08) both_lying = left["lying"] and right["lying"] same_level = abs(left_center[1] - right_center[1]) <= 0.15 side_by_side = abs(left_center[0] - right_center[0]) >= 0.10 - if both_lying and (same_level or side_by_side): + parallel_side_by_side = axes_parallel and left["elongated"] and right["elongated"] and close and not strong_stack + if (both_lying and (same_level or side_by_side)) or parallel_side_by_side: add("spooning", 0.18) if overlap >= 0.10: add("prone_bone", 0.07) diff --git a/backend/analyze.go b/backend/analyze.go index 856e1c9..c661b74 100644 --- a/backend/analyze.go +++ b/backend/analyze.go @@ -95,10 +95,15 @@ const ( analyzePositionClipWindowSeconds = 3.0 analyzePositionClipMinScore = 0.22 analyzePositionClipMinFrames = 2 + analyzePositionConflictMargin = 0.08 + analyzePositionMinStableSeconds = 8.0 + analyzePositionShortMaxScore = 0.62 ) type analyzePositionEvidence struct { Time float64 + Start float64 + End float64 Label string Score float64 Source string @@ -108,6 +113,18 @@ type analyzePositionEvidence struct { HasClip bool } +func isAnalyzeContextOnlyPositionLabel(label string) bool { + return false +} + +func isAnalyzeTimelinePositionLabel(label string) bool { + label = strings.ToLower(strings.TrimSpace(label)) + label = strings.TrimPrefix(label, "position:") + return !isNoSexPositionLabel(label) && + isKnownPositionLabel(label) && + !isAnalyzeContextOnlyPositionLabel(label) +} + func analyzeVideoFrameFilter(intervalSeconds int) string { if intervalSeconds <= 0 { intervalSeconds = 1 @@ -251,7 +268,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe best := map[string]float64{} sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition)) - if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) { + if isAnalyzeTimelinePositionLabel(sexPosition) { addHighlightResult(best, "position:"+sexPosition, pred.SexPositionScore) } @@ -280,7 +297,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe } // Kombis nur erzeugen, wenn wirklich Position + Zusatz vorhanden ist. - if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) { + if isAnalyzeTimelinePositionLabel(sexPosition) { positionScore := pred.SexPositionScore if positionScore <= 0 { positionScore = 1 @@ -975,7 +992,7 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute) defer cancel() - hits, analyzeStartedAtMs, analyzeTotalFrames, err := analyzeVideoFromFrames(ctx, outPath) + hits, analyzeStartedAtMs, _, err := analyzeVideoFromFrames(ctx, outPath) if err != nil { logAnalyzeError("analyze-frames", file, outPath, err) @@ -989,12 +1006,10 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { return } - // Wichtig: publishAnalysisFinished erst NACH writeVideoAIForFile feuern, - // damit refreshDoneMetaForFile im Frontend das Rating bereits in der Datei vorfindet. - defer publishAnalysisFinished(analyzeStartedAtMs, analyzeTotalFrames, file, "Analyse abgeschlossen") - appLogf("🧪 [analyze] hits file=%q count=%d", file, len(hits)) + publishAnalyzePersistProgress(analyzeStartedAtMs, file, 0.1, "") + durationSec, derr := durationSecondsForAnalyze(ctx, outPath) if derr != nil { logAnalyzeError("duration-after-analyze", file, outPath, derr) @@ -1002,6 +1017,7 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { if durationSec <= 0 { err := appErrorf("videolänge konnte nach analyse nicht bestimmt werden") logAnalyzeError("duration-invalid", file, outPath, err) + publishAnalysisError(analyzeStartedAtMs, file, "Analyse fehlgeschlagen", err) respondJSON(w, analyzeVideoResp{ OK: false, @@ -1019,6 +1035,8 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { segments = prepareAIRatingSegments(segments) appLogf("🧪 [analyze] rating segments file=%q count=%d", file, len(segments)) + publishAnalyzePersistProgress(analyzeStartedAtMs, file, 0.45, "") + rating := computeHighlightRatingForVideo(segments, durationSec, outPath) ai := &aiAnalysisMeta{ @@ -1033,6 +1051,7 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { if err := writeVideoAIForFile(ctx, outPath, "", ai); err != nil { logAnalyzeError("write-meta-ai", file, outPath, err) + publishAnalysisError(analyzeStartedAtMs, file, "Speichern fehlgeschlagen", err) respondJSON(w, analyzeVideoResp{ OK: false, @@ -1047,6 +1066,8 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { } autoDeleteLowRatedDownloadAfterAnalysis(ctx, outPath, rating) + publishAnalyzePersistProgress(analyzeStartedAtMs, file, 1, "") + publishAnalysisFinished(analyzeStartedAtMs, analyzeProgressTotal, file, "Analyse abgeschlossen") appLogf( "✅ [analyze] done file=%q hits=%d segments=%d rating=%.1f stars=%d", @@ -1125,7 +1146,7 @@ func normalizeHighlightSignalLabel(label string) string { case strings.HasPrefix(label, "position:"): raw := strings.TrimPrefix(label, "position:") - if isNoSexPositionLabel(raw) || !isKnownPositionLabel(raw) { + if !isAnalyzeTimelinePositionLabel(raw) { return "" } return "position:" + raw @@ -1135,7 +1156,7 @@ func normalizeHighlightSignalLabel(label string) string { return "" } - if isKnownPositionLabel(label) { + if isAnalyzeTimelinePositionLabel(label) { return "position:" + label } @@ -1352,7 +1373,7 @@ func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64) sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition)) - if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) { + if isAnalyzeTimelinePositionLabel(sexPosition) { positionScore := pred.SexPositionScore if positionScore <= 0 { positionScore = 0.35 @@ -1550,7 +1571,7 @@ func buildHighlightHitsFromPrediction(pred TrainingPrediction, t float64) []anal best := map[string]highlightSignal{} sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition)) - if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) { + if isAnalyzeTimelinePositionLabel(sexPosition) { addHighlightSignal(best, "position:"+sexPosition, pred.SexPositionScore) } @@ -1631,6 +1652,61 @@ func appendHighlightHitsFromPrediction( return append(hits, next...) } +func analyzeHitWithoutRawPosition(hit analyzeHit) (analyzeHit, bool) { + label := strings.ToLower(strings.TrimSpace(hit.Label)) + if label == "" { + return analyzeHit{}, false + } + + if strings.HasPrefix(label, "position:") { + return analyzeHit{}, false + } + + if !strings.HasPrefix(label, "combo:") { + hit.Label = label + return hit, true + } + + parts := []string{} + for _, part := range strings.Split(strings.TrimPrefix(label, "combo:"), "+") { + part = strings.ToLower(strings.TrimSpace(part)) + if part == "" || strings.HasPrefix(part, "position:") { + continue + } + parts = append(parts, part) + } + + switch len(parts) { + case 0: + return analyzeHit{}, false + case 1: + hit.Label = parts[0] + default: + hit.Label = "combo:" + strings.Join(parts, "+") + } + + return hit, true +} + +func appendVideoFrameHighlightHitsFromPrediction( + hits []analyzeHit, + pred TrainingPrediction, + t float64, +) []analyzeHit { + next := appendHighlightHitsFromPrediction(nil, pred, t) + if len(next) == 0 { + return hits + } + + for _, hit := range next { + if stripped, ok := analyzeHitWithoutRawPosition(hit); ok { + hits = append(hits, stripped) + } + } + + return hits +} + func analyzePositionEvidenceFromPrediction( pred TrainingPrediction, t float64, @@ -1640,7 +1716,7 @@ func analyzePositionEvidenceFromPrediction( } label := strings.ToLower(strings.TrimSpace(pred.SexPosition)) - if isNoSexPositionLabel(label) || !isKnownPositionLabel(label) { + if !isAnalyzeTimelinePositionLabel(label) { return analyzePositionEvidence{}, false } @@ -1665,6 +1741,8 @@ func analyzePositionEvidenceFromPrediction( return analyzePositionEvidence{ Time: t, + Start: t, + End: t, Label: label, Score: score, Source: source, @@ -1677,16 +1755,16 @@ func analyzePositionEvidenceFromPrediction( func analyzePositionEvidenceWeight(item analyzePositionEvidence) float64 { weight := 1.0 - if item.HasClip && item.HasPose { - weight = 1.28 + if item.HasClip && (item.HasPose || item.HasContext) { + weight = 1.70 } else if item.HasClip { - weight = 1.18 + weight = 1.55 } else if item.HasPose && item.HasContext { - weight = 1.15 + weight = 1.05 } else if item.HasPose { - weight = 1.0 + weight = 0.76 } else if item.HasContext { - weight = 0.72 + weight = 0.82 } if item.PersonCount >= 2 { @@ -1696,6 +1774,336 @@ func analyzePositionEvidenceWeight(item analyzePositionEvidence) float64 { return weight } +type analyzePositionAggregate struct { + Label string + WeightedSum float64 + WeightSum float64 + Count int + PoseCount int + ContextCount int + ClipCount int + Start float64 + End float64 + Marker float64 + BestScore float64 +} + +type analyzePositionCandidate struct { + Agg *analyzePositionAggregate + Score float64 +} + +type analyzePositionLedgerEntry struct { + Start float64 + End float64 + Center float64 + Candidates []analyzePositionCandidate + Winner analyzePositionCandidate + Conflict bool +} + +func analyzePositionEvidenceSpan(item analyzePositionEvidence) (float64, float64) { + start := item.Start + end := item.End + + if start == 0 && end == 0 { + start = item.Time + end = item.Time + } + if start < 0 { + start = item.Time + } + if end < 0 { + end = item.Time + } + if start > end { + start, end = end, start + } + + return start, end +} + +func analyzePositionAggregateScore(agg *analyzePositionAggregate, requiredFrames int) (float64, bool) { + if agg == nil || agg.WeightSum <= 0 { + return 0, false + } + + minCount := requiredFrames + if agg.ClipCount > 0 { + // Ein VideoMAE-Clip steht bereits für ein Zeitfenster und muss nicht + // zusätzlich noch mehrere Einzel-Frames derselben Position haben. + minCount = 1 + } else if agg.PoseCount > 0 && agg.ContextCount == 0 { + // Pose-only braucht etwas mehr zeitliche Stabilität. + if minCount < 3 { + minCount = 3 + } + } + + if agg.Count < minCount { + return 0, false + } + + avg := clamp01(agg.WeightedSum / agg.WeightSum) + stability := clamp01(float64(agg.Count) / math.Max(float64(requiredFrames), 3)) + sourceBonus := 0.0 + if agg.ClipCount > 0 && (agg.PoseCount > 0 || agg.ContextCount > 0) { + sourceBonus = 0.07 + } else if agg.ClipCount > 0 { + sourceBonus = 0.06 + } else if agg.PoseCount > 0 && agg.ContextCount > 0 { + sourceBonus = 0.04 + } + + score := clamp01(avg*(0.86+0.14*stability) + sourceBonus) + if agg.ClipCount == 0 && agg.PoseCount == 0 { + score = math.Min(score, trainingPositionContextMaxScore) + } + if agg.ClipCount == 0 && agg.ContextCount == 0 { + score = math.Min(score, trainingPoseStrongUnconfirmedMaxScore) + } + + if score < analyzePositionClipMinScore { + return 0, false + } + + return score, true +} + +func analyzePositionCandidateConflict(best, other analyzePositionCandidate) bool { + if best.Agg == nil || other.Agg == nil { + return false + } + if best.Agg.Label == other.Agg.Label { + return false + } + + // VideoMAE darf bei gleicher Groessenordnung als zeitliches Hauptsignal + // gewinnen. Wenn aber zwei Clip-Positionen fast gleich stark sind, ist + // das echter Konflikt und wird lieber nicht hart segmentiert. + if best.Agg.ClipCount > 0 { + if other.Agg.ClipCount > 0 { + return other.Score >= best.Score-analyzePositionConflictMargin + } + return false + } + + if other.Agg.ClipCount > 0 { + return true + } + + return other.Score >= best.Score-analyzePositionConflictMargin +} + +func analyzePositionLedgerCenters(evidence []analyzePositionEvidence) []float64 { + centers := make([]float64, 0, len(evidence)*3) + + for _, item := range evidence { + start, end := analyzePositionEvidenceSpan(item) + center := item.Time + if center < start || center > end { + center = (start + end) / 2 + } + + centers = append(centers, center) + + if item.HasClip && end > start { + centers = append(centers, start, end) + } + } + + sort.Float64s(centers) + + out := centers[:0] + for _, center := range centers { + if len(out) == 0 || math.Abs(center-out[len(out)-1]) > 0.20 { + out = append(out, center) + } + } + + return out +} + +func analyzePositionCandidatesForWindow( + evidence []analyzePositionEvidence, + windowStart float64, + windowEnd float64, + requiredFrames int, +) []analyzePositionCandidate { + byLabel := map[string]*analyzePositionAggregate{} + + for _, item := range evidence { + itemStart, itemEnd := analyzePositionEvidenceSpan(item) + if itemEnd < windowStart-0.001 || itemStart > windowEnd+0.001 { + continue + } + + label := strings.ToLower(strings.TrimSpace(item.Label)) + if !isAnalyzeTimelinePositionLabel(label) { + continue + } + + score := clamp01(item.Score) + if score <= 0 { + continue + } + + agg := byLabel[label] + if agg == nil { + agg = &analyzePositionAggregate{ + Label: label, + Start: itemStart, + End: itemEnd, + Marker: item.Time, + } + byLabel[label] = agg + } + + weight := analyzePositionEvidenceWeight(item) + agg.WeightedSum += score * weight + agg.WeightSum += weight + agg.Count++ + if item.HasPose { + agg.PoseCount++ + } + if item.HasContext { + agg.ContextCount++ + } + if item.HasClip { + agg.ClipCount++ + } + if itemStart < agg.Start { + agg.Start = itemStart + } + if itemEnd > agg.End { + agg.End = itemEnd + } + if score > agg.BestScore { + agg.BestScore = score + agg.Marker = item.Time + } + } + + candidates := make([]analyzePositionCandidate, 0, len(byLabel)) + for _, agg := range byLabel { + score, ok := analyzePositionAggregateScore(agg, requiredFrames) + if ok { + candidates = append(candidates, analyzePositionCandidate{ + Agg: agg, + Score: score, + }) + } + } + + sort.SliceStable(candidates, func(i, j int) bool { + if candidates[i].Score != candidates[j].Score { + return candidates[i].Score > candidates[j].Score + } + if candidates[i].Agg.ClipCount != candidates[j].Agg.ClipCount { + return candidates[i].Agg.ClipCount > candidates[j].Agg.ClipCount + } + return candidates[i].Agg.Label < candidates[j].Agg.Label + }) + + return candidates +} + +func stabilizeAnalyzePositionHits(in []analyzeHit, duration float64) []analyzeHit { + if len(in) == 0 { + return []analyzeHit{} + } + + out := make([]analyzeHit, 0, len(in)) + for _, hit := range in { + label := strings.ToLower(strings.TrimSpace(hit.Label)) + position := strings.TrimPrefix(label, "position:") + if !isAnalyzeTimelinePositionLabel(position) { + continue + } + + start := hit.Start + end := hit.End + if end < start { + start, end = end, start + } + if duration > 0 { + start = math.Max(0, math.Min(duration, start)) + end = math.Max(0, math.Min(duration, end)) + } + + span := end - start + if duration >= 60 && + span < analyzePositionMinStableSeconds && + hit.Score < analyzePositionShortMaxScore { + continue + } + + hit.Label = "position:" + position + hit.Start = start + hit.End = end + out = append(out, hit) + } + + return mergeAnalyzeHits(out) +} + +func buildAnalyzePositionLedger( + evidence []analyzePositionEvidence, + duration float64, + requiredFrames int, +) []analyzePositionLedgerEntry { + if len(evidence) == 0 { + return []analyzePositionLedgerEntry{} + } + + halfWindow := analyzePositionClipWindowSeconds / 2 + if halfWindow <= 0 { + halfWindow = math.Max(1, float64(analyzeVideoFrameIntervalSeconds)) + } + + centers := analyzePositionLedgerCenters(evidence) + ledger := make([]analyzePositionLedgerEntry, 0, len(centers)) + + for _, center := range centers { + windowStart := math.Max(0, center-halfWindow) + windowEnd := center + halfWindow + if duration > 0 { + windowEnd = math.Min(duration, windowEnd) + } + if windowEnd <= windowStart { + windowEnd = windowStart + math.Max(1, float64(analyzeVideoFrameIntervalSeconds)) + if duration > 0 { + windowEnd = math.Min(duration, windowEnd) + } + } + + entry := analyzePositionLedgerEntry{ + Start: windowStart, + End: windowEnd, + Center: center, + } + entry.Candidates = analyzePositionCandidatesForWindow( + evidence, + windowStart, + windowEnd, + requiredFrames, + ) + + if len(entry.Candidates) > 0 { + if len(entry.Candidates) > 1 && + analyzePositionCandidateConflict(entry.Candidates[0], entry.Candidates[1]) { + entry.Conflict = true + } else { + entry.Winner = entry.Candidates[0] + } + } + + ledger = append(ledger, entry) + } + + return ledger +} + func buildClipPositionHitsFromEvidence( evidence []analyzePositionEvidence, duration float64, @@ -1719,162 +2127,74 @@ func buildClipPositionHitsFromEvidence( requiredFrames = 1 } - halfWindow := analyzePositionClipWindowSeconds / 2 - if halfWindow <= 0 { - halfWindow = math.Max(1, float64(analyzeVideoFrameIntervalSeconds)) + ledger := buildAnalyzePositionLedger(evidence, duration, requiredFrames) + hits := make([]analyzeHit, 0, len(ledger)) + + var cur analyzeHit + hasCur := false + flush := func() { + if !hasCur { + return + } + if cur.End <= cur.Start { + cur.End = cur.Start + math.Max(1, float64(analyzeVideoFrameIntervalSeconds)) + if duration > 0 { + cur.End = math.Min(duration, cur.End) + } + } + cur.Time = (cur.Start + cur.End) / 2 + hits = append(hits, cur) + hasCur = false } - type aggregate struct { - Label string - WeightedSum float64 - WeightSum float64 - Count int - PoseCount int - ContextCount int - ClipCount int - Start float64 - End float64 - Marker float64 - BestScore float64 - } - - hits := make([]analyzeHit, 0, len(evidence)) - - for _, center := range evidence { - windowStart := center.Time - halfWindow - windowEnd := center.Time + halfWindow - if windowStart < 0 { - windowStart = 0 - } - if duration > 0 && windowEnd > duration { - windowEnd = duration - } - if windowEnd <= windowStart { - windowEnd = windowStart + math.Max(1, float64(analyzeVideoFrameIntervalSeconds)) - if duration > 0 && windowEnd > duration { - windowEnd = duration - } - } - - byLabel := map[string]*aggregate{} - - for _, item := range evidence { - if item.Time < windowStart-0.001 || item.Time > windowEnd+0.001 { - continue - } - - label := strings.ToLower(strings.TrimSpace(item.Label)) - if isNoSexPositionLabel(label) || !isKnownPositionLabel(label) { - continue - } - - score := clamp01(item.Score) - if score <= 0 { - continue - } - - agg := byLabel[label] - if agg == nil { - agg = &aggregate{ - Label: label, - Start: item.Time, - End: item.Time, - Marker: item.Time, - } - byLabel[label] = agg - } - - weight := analyzePositionEvidenceWeight(item) - agg.WeightedSum += score * weight - agg.WeightSum += weight - agg.Count++ - if item.HasPose { - agg.PoseCount++ - } - if item.HasContext { - agg.ContextCount++ - } - if item.HasClip { - agg.ClipCount++ - } - if item.Time < agg.Start { - agg.Start = item.Time - } - if item.Time > agg.End { - agg.End = item.Time - } - if score > agg.BestScore { - agg.BestScore = score - agg.Marker = item.Time - } - } - - var best *aggregate - bestScore := 0.0 - - for _, agg := range byLabel { - if agg.WeightSum <= 0 || agg.Count < requiredFrames { - continue - } - - avg := clamp01(agg.WeightedSum / agg.WeightSum) - stability := clamp01(float64(agg.Count) / math.Max(float64(requiredFrames), 3)) - sourceBonus := 0.0 - if agg.PoseCount > 0 && agg.ContextCount > 0 { - sourceBonus = 0.04 - } else if agg.ClipCount > 0 && (agg.PoseCount > 0 || agg.ContextCount > 0) { - sourceBonus = 0.04 - } else if agg.ClipCount > 0 { - sourceBonus = 0.03 - } else if agg.PoseCount > 0 { - sourceBonus = 0.02 - } - - score := clamp01(avg*(0.86+0.14*stability) + sourceBonus) - if agg.PoseCount == 0 && agg.ClipCount == 0 { - score = math.Min(score, trainingPositionContextMaxScore) - } - - if score < analyzePositionClipMinScore { - continue - } - - if score > bestScore { - best = agg - bestScore = score - } - } - - if best == nil { + for _, entry := range ledger { + if entry.Conflict || entry.Winner.Agg == nil { + flush() continue } - start := math.Max(0, best.Start-halfWindow) - end := best.End + halfWindow - if duration > 0 { - end = math.Min(duration, end) - } - if end <= start { - end = math.Min(duration, start+math.Max(1, float64(analyzeVideoFrameIntervalSeconds))) + label := "position:" + entry.Winner.Agg.Label + if hasCur && + cur.Label == label && + entry.Start <= cur.End+analyzeHitContinuationGapSeconds() { + if entry.End > cur.End { + cur.End = entry.End + } + if entry.Winner.Score > cur.Score { + cur.Score = entry.Winner.Score + } + continue } - hits = append(hits, analyzeHit{ - Time: best.Marker, - Label: "position:" + best.Label, - Score: bestScore, - Start: start, - End: end, - }) + flush() + cur = analyzeHit{ + Time: entry.Center, + Label: label, + Score: entry.Winner.Score, + Start: entry.Start, + End: entry.End, + } + hasCur = true } + flush() - return mergeAnalyzeHits(hits) + return stabilizeAnalyzePositionHits(mergeAnalyzeHits(hits), duration) } func analyzeVideoFromFrames(ctx context.Context, outPath string) ([]analyzeHit, int64, int, error) { return analyzeVideoFromFramesForGoal(ctx, outPath) } -const analyzeProgressTotal = 1000 +const ( + analyzeProgressTotal = 1000 + analyzeProgressExtractEnd = 450 + analyzeProgressInferenceStart = analyzeProgressExtractEnd + analyzeProgressInferenceEnd = 850 + analyzeProgressVideoMAEStart = analyzeProgressInferenceEnd + analyzeProgressVideoMAEEnd = 950 + analyzeProgressPersistStart = analyzeProgressVideoMAEEnd + analyzeProgressPersistEnd = 990 +) func logAnalyzeError(stage string, file string, outPath string, err error) { if err == nil { @@ -1901,20 +2221,13 @@ func publishAnalyzeExtractProgress( progress float64, message string, ) { - progress = math.Max(0, math.Min(1, progress)) - - current := int(math.Round(progress * 0.5 * analyzeProgressTotal)) - - message = strings.TrimSpace(message) - if message == "" || strings.EqualFold(message, "Analyse") || strings.Contains(message, "Frames werden extrahiert") { - message = analyzeGlobalPercentMessageFromCurrent(current, analyzeProgressTotal) - } - - publishAnalysisStep( + publishAnalyzePhaseProgress( startedAtMs, - current, - analyzeProgressTotal, file, + "Analyse", + 0, + analyzeProgressExtractEnd, + progress, message, ) } @@ -1952,22 +2265,81 @@ func publishAnalyzeInferenceProgress( ratio := float64(currentFrame) / float64(totalFrames) ratio = math.Max(0, math.Min(1, ratio)) - current := int(math.Round((0.5 + ratio*0.5) * analyzeProgressTotal)) - - message = strings.TrimSpace(message) - if message == "" || strings.EqualFold(message, "Analyse") { - message = analyzeGlobalPercentMessageFromCurrent(current, analyzeProgressTotal) - } - - publishAnalysisStep( + publishAnalyzePhaseProgress( startedAtMs, - current, - analyzeProgressTotal, file, + "Analyse", + analyzeProgressInferenceStart, + analyzeProgressInferenceEnd, + ratio, message, ) } +func publishAnalyzeVideoMAEProgress(startedAtMs int64, file string, currentClip int, totalClips int) { + if totalClips <= 0 { + totalClips = 1 + } + + ratio := float64(currentClip) / float64(totalClips) + publishAnalyzePhaseProgress( + startedAtMs, + file, + "Analyse", + analyzeProgressVideoMAEStart, + analyzeProgressVideoMAEEnd, + ratio, + "", + ) +} + +func publishAnalyzePersistProgress(startedAtMs int64, file string, progress float64, message string) { + publishAnalyzePhaseProgress( + startedAtMs, + file, + "Speichern", + analyzeProgressPersistStart, + analyzeProgressPersistEnd, + progress, + message, + ) +} + +func publishAnalyzePhaseProgress( + startedAtMs int64, + file string, + phase string, + rangeStart int, + rangeEnd int, + progress float64, + message string, +) { + progress = math.Max(0, math.Min(1, progress)) + + if rangeEnd < rangeStart { + rangeEnd = rangeStart + } + + current := rangeStart + int(math.Round(progress*float64(rangeEnd-rangeStart))) + if current < 0 { + current = 0 + } + if current > analyzeProgressTotal { + current = analyzeProgressTotal + } + + message = strings.TrimSpace(message) + if message == "" || strings.EqualFold(message, "Analyse") || strings.Contains(message, "Frames werden extrahiert") { + phase = strings.TrimSpace(phase) + if phase == "" { + phase = "Analyse" + } + message = fmt.Sprintf("%s %d%%", phase, analyzeGlobalPercentFromCurrent(current, analyzeProgressTotal)) + } + + publishAnalysisStep(startedAtMs, current, analyzeProgressTotal, file, message) +} + func analyzeGlobalPercentFromCurrent(current int, total int) int { if total <= 0 { total = analyzeProgressTotal @@ -2041,8 +2413,6 @@ func analyzeVideoFromFramesForGoal( } for p := *lastPercent + 1; p <= nextPercent; p++ { - label := fmt.Sprintf("Analyse %d%%", p) - if extractPhase { ratio := float64(p) / 50.0 if ratio < 0 { @@ -2056,7 +2426,7 @@ func analyzeVideoFromFramesForGoal( startedAtMs, file, ratio, - label, + "", ) } else { inferenceCurrent := current @@ -2072,7 +2442,7 @@ func analyzeVideoFromFramesForGoal( file, inferenceCurrent, inferenceTotal, - label, + "", ) } } @@ -2174,7 +2544,7 @@ func analyzeVideoFromFramesForGoal( file, 0, total, - "Analyse 50%", + "", ) if ctx.Err() != nil { @@ -2236,7 +2606,7 @@ func analyzeVideoFromFramesForGoal( file, 0, total, - "Analyse 50%", + "", ) break @@ -2263,7 +2633,7 @@ func analyzeVideoFromFramesForGoal( ) } - highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, sample.Time) + highlightHits = appendVideoFrameHighlightHitsFromPrediction(highlightHits, pred, sample.Time) if item, ok := analyzePositionEvidenceFromPrediction(pred, sample.Time); ok { positionEvidence = append(positionEvidence, item) } @@ -2304,7 +2674,13 @@ func analyzeVideoFromFramesForGoal( durationSec, highlightHits, positionEvidence, + func(current int, total int) { + publishAnalyzeVideoMAEProgress(startedAtMs, file, current, total) + }, ) + if ctx.Err() != nil { + return failCancelled() + } highlightHits = append( highlightHits, @@ -2328,7 +2704,7 @@ func analyzeVideoFromFramesForGoal( return failCancelled() } - highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, sample.Time) + highlightHits = appendVideoFrameHighlightHitsFromPrediction(highlightHits, pred, sample.Time) if item, ok := analyzePositionEvidenceFromPrediction(pred, sample.Time); ok { positionEvidence = append(positionEvidence, item) } @@ -2369,7 +2745,13 @@ func analyzeVideoFromFramesForGoal( durationSec, highlightHits, positionEvidence, + func(current int, total int) { + publishAnalyzeVideoMAEProgress(startedAtMs, file, current, total) + }, ) + if ctx.Err() != nil { + return failCancelled() + } highlightHits = append( highlightHits, @@ -2531,7 +2913,7 @@ func segmentPositionFromAnalyzeLabel(label string) string { part = strings.TrimSpace(part) part = strings.TrimPrefix(part, "position:") - if isKnownPositionLabel(part) { + if isAnalyzeTimelinePositionLabel(part) { return part } } @@ -2561,7 +2943,7 @@ func segmentTagsFromAnalyzeLabel(label string) []string { if strings.HasPrefix(part, "position:") { pos := strings.TrimPrefix(part, "position:") - if pos == "" || !isKnownPositionLabel(pos) { + if !isAnalyzeTimelinePositionLabel(pos) { continue } @@ -2844,8 +3226,14 @@ func isAllowedAnalyzeSegmentLabel(label string) bool { if isNoSexPositionLabel(raw) { return false } + if strings.HasPrefix(label, "position:") && !isAnalyzeTimelinePositionLabel(label) { + return false + } + if isAnalyzeContextOnlyPositionLabel(raw) { + return false + } - return shouldAutoSelectAnalyzeHit(raw) || isKnownPositionLabel(raw) + return shouldAutoSelectAnalyzeHit(raw) || isAnalyzeTimelinePositionLabel(raw) } func buildLabelContinuitySegmentsFromAnalyzeHits( diff --git a/backend/analyze_videomae.go b/backend/analyze_videomae.go index 0716ec5..30df8c9 100644 --- a/backend/analyze_videomae.go +++ b/backend/analyze_videomae.go @@ -115,12 +115,16 @@ func buildAnalyzeVideoMAEClips( func predictVideoMAEPositionClipsForAnalyze( ctx context.Context, clips []analyzeVideoMAEClipReqItem, + onProgress func(current int, total int), ) ([]analyzeVideoMAEClipPrediction, error) { if len(clips) == 0 { return []analyzeVideoMAEClipPrediction{}, nil } if !trainingRecognitionEnabled() { + if onProgress != nil { + onProgress(len(clips), len(clips)) + } return []analyzeVideoMAEClipPrediction{}, nil } @@ -192,10 +196,16 @@ func predictVideoMAEPositionClipsForAnalyze( } if !parsed.Available { + if onProgress != nil { + onProgress(len(clips), len(clips)) + } return out, nil } out = append(out, parsed.Predictions...) + if onProgress != nil { + onProgress(end, len(clips)) + } } return out, nil @@ -207,6 +217,7 @@ func applyVideoMAEPositionClipsForAnalyze( duration float64, highlightHits []analyzeHit, positionEvidence []analyzePositionEvidence, + onProgress func(current int, total int), ) ([]analyzeHit, []analyzePositionEvidence) { if !analyzeVideoMAEEnabled() { return highlightHits, positionEvidence @@ -217,9 +228,16 @@ func applyVideoMAEPositionClipsForAnalyze( return highlightHits, positionEvidence } - predictions, err := predictVideoMAEPositionClipsForAnalyze(ctx, clips) + if onProgress != nil { + onProgress(0, len(clips)) + } + + predictions, err := predictVideoMAEPositionClipsForAnalyze(ctx, clips, onProgress) if err != nil { if ctx.Err() == nil { + if onProgress != nil { + onProgress(len(clips), len(clips)) + } appLogln("VideoMAE Clip-Analyse übersprungen:", err) } return highlightHits, positionEvidence @@ -227,7 +245,7 @@ func applyVideoMAEPositionClipsForAnalyze( for _, pred := range predictions { label := strings.ToLower(strings.TrimSpace(pred.SexPosition)) - if isNoSexPositionLabel(label) || !isKnownPositionLabel(label) { + if !isAnalyzeTimelinePositionLabel(label) { continue } @@ -250,16 +268,10 @@ func applyVideoMAEPositionClipsForAnalyze( source = "videomae" } - highlightHits = append(highlightHits, analyzeHit{ - Time: pred.Time, - Label: "position:" + label, - Score: score, - Start: start, - End: end, - }) - positionEvidence = append(positionEvidence, analyzePositionEvidence{ Time: pred.Time, + Start: start, + End: end, Label: label, Score: score, Source: source, diff --git a/backend/assets_generate.go b/backend/assets_generate.go index d49fa0b..aeec87d 100644 --- a/backend/assets_generate.go +++ b/backend/assets_generate.go @@ -172,15 +172,17 @@ func ensureAnalyzeAllGoalsForVideoCtxForce( return false, skipAnalysisBecauseBadSpriteError(reason) } - hits, analyzeStartedAtMs, analyzeTotalFrames, err := analyzeVideoFromFrames(actx, videoPath) + hits, analyzeStartedAtMs, _, err := analyzeVideoFromFrames(actx, videoPath) if err != nil { return false, err } - defer publishAnalysisFinished(analyzeStartedAtMs, analyzeTotalFrames, filepath.Base(videoPath), "Analyse abgeschlossen") + analyzeFile := filepath.Base(videoPath) + publishAnalyzePersistProgress(analyzeStartedAtMs, analyzeFile, 0.1, "") segments := buildAnalyzeSegmentsForGoal(hits, durationSec) segments = prepareAIRatingSegments(segments) + publishAnalyzePersistProgress(analyzeStartedAtMs, analyzeFile, 0.45, "") rating := computeHighlightRatingForVideo(segments, durationSec, videoPath) @@ -195,17 +197,22 @@ func ensureAnalyzeAllGoalsForVideoCtxForce( } if err := writeVideoAIForFile(actx, videoPath, meta.sourceURL, ai); err != nil { + publishAnalysisError(analyzeStartedAtMs, analyzeFile, "Speichern fehlgeschlagen", err) return false, err } analysisReady := hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals()) if !analysisReady { - return false, nil + err := appErrorf("analyse-meta konnte nicht verifiziert werden") + publishAnalysisError(analyzeStartedAtMs, analyzeFile, "Analyse fehlgeschlagen", err) + return false, err } // Direkt nach erfolgreicher Analyse löschen. // Wichtig: hier rating übergeben, nicht nil. autoDeleteLowRatedDownloadAfterAnalysis(actx, videoPath, rating) + publishAnalyzePersistProgress(analyzeStartedAtMs, analyzeFile, 1, "") + publishAnalysisFinished(analyzeStartedAtMs, analyzeProgressTotal, analyzeFile, "Analyse abgeschlossen") return true, nil } @@ -1587,15 +1594,17 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string return out, nil } - hits, analyzeStartedAtMs2, analyzeTotalFrames2, aerr := analyzeVideoFromFrames(ctx, videoPath) + hits, analyzeStartedAtMs2, _, aerr := analyzeVideoFromFrames(ctx, videoPath) if aerr != nil { return out, nil } - defer publishAnalysisFinished(analyzeStartedAtMs2, analyzeTotalFrames2, filepath.Base(videoPath), "Analyse abgeschlossen") + analyzeFile2 := filepath.Base(videoPath) + publishAnalyzePersistProgress(analyzeStartedAtMs2, analyzeFile2, 0.1, "") segments := buildAnalyzeSegmentsForGoal(hits, durationSec) segments = prepareAIRatingSegments(segments) + publishAnalyzePersistProgress(analyzeStartedAtMs2, analyzeFile2, 0.45, "") rating := computeHighlightRatingForVideo(segments, durationSec, videoPath) @@ -1609,9 +1618,21 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string } if werr := writeVideoAIForFile(ctx, videoPath, sourceURL, ai); werr != nil { + publishAnalysisError(analyzeStartedAtMs2, analyzeFile2, "Speichern fehlgeschlagen", werr) return out, nil } out.AnalyzeReady = hasAIAnalysisForOutputGoal(videoPath, "highlights") + if out.AnalyzeReady { + publishAnalyzePersistProgress(analyzeStartedAtMs2, analyzeFile2, 1, "") + publishAnalysisFinished(analyzeStartedAtMs2, analyzeProgressTotal, analyzeFile2, "Analyse abgeschlossen") + } else { + publishAnalysisError( + analyzeStartedAtMs2, + analyzeFile2, + "Analyse fehlgeschlagen", + appErrorf("analyse-meta konnte nicht verifiziert werden"), + ) + } return out, nil } diff --git a/backend/dist/nsfwapp-linux-amd64 b/backend/dist/nsfwapp-linux-amd64 index 02fc904..e1a3a1b 100644 Binary files a/backend/dist/nsfwapp-linux-amd64 and b/backend/dist/nsfwapp-linux-amd64 differ diff --git a/backend/dist/nsfwapp.exe b/backend/dist/nsfwapp.exe index b748ca3..ab9b4f0 100644 Binary files a/backend/dist/nsfwapp.exe and b/backend/dist/nsfwapp.exe differ diff --git a/backend/dist/nsfwapp_amd64.deb b/backend/dist/nsfwapp_amd64.deb index e197049..907720b 100644 Binary files a/backend/dist/nsfwapp_amd64.deb and b/backend/dist/nsfwapp_amd64.deb differ diff --git a/backend/myfreecams_autostart.go b/backend/myfreecams_autostart.go index 604da0c..902027a 100644 --- a/backend/myfreecams_autostart.go +++ b/backend/myfreecams_autostart.go @@ -91,6 +91,14 @@ func resolveMyFreeCamsURL(m WatchedModelLite) string { return fmt.Sprintf("https://www.myfreecams.com/#%s", user) } +func myFreeCamsAutostartCanRun() bool { + s := getSettings() + return s.UseProviderAPIs && + s.UseMyFreeCamsAPI && + s.AutoStartAddedDownloads && + !isAutostartPaused() +} + // Startet watched MyFreeCams Models (ohne API) "best-effort". // Wenn nach kurzer Zeit keine Output-Datei existiert (oder 0 Bytes), wird abgebrochen und der Job wieder entfernt. func startMyFreeCamsAutoStartWorker(store *ModelStore) { @@ -116,8 +124,7 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) { for { select { case <-scanTicker.C: - s := getSettings() - if !s.UseProviderAPIs || !s.UseMyFreeCamsAPI || !s.AutoStartAddedDownloads || isAutostartPaused() { + if !myFreeCamsAutostartCanRun() { queue = queue[:0] queued = map[string]bool{} @@ -390,8 +397,9 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) { pendingAutoStartMu.Unlock() case <-startTicker.C: - s := getSettings() - if !s.UseProviderAPIs || !s.UseMyFreeCamsAPI || !s.AutoStartAddedDownloads || isAutostartPaused() { + if !myFreeCamsAutostartCanRun() { + queue = queue[:0] + queued = map[string]bool{} continue } if len(queue) == 0 { @@ -407,6 +415,12 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) { queue = queue[1:] delete(queued, it.userKey) + if !myFreeCamsAutostartCanRun() { + queue = queue[:0] + queued = map[string]bool{} + continue + } + if isJobRunningForURL(it.url) { continue } diff --git a/backend/pending_autostart.go b/backend/pending_autostart.go index c1561a4..fa8f932 100644 --- a/backend/pending_autostart.go +++ b/backend/pending_autostart.go @@ -405,6 +405,49 @@ func removeManualPendingAutoStartItemForProvider(provider, modelKey string) erro return nil } +func removeAllPendingAutoStartItemsForProvider(provider string) (int, error) { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" { + return 0, nil + } + + userKeys, err := listPendingAutoStartUserKeys() + if err != nil { + return 0, err + } + + keys := append([]string{pendingAutoStartGlobalUserKey}, userKeys...) + removed := 0 + + for _, userKey := range keys { + items, err := loadPendingAutoStartItems(userKey) + if err != nil { + return removed, err + } + + changed := false + next := make([]PendingAutoStartItem, 0, len(items)) + + for _, it := range items { + if pendingProviderFromURL(it.URL) == provider { + removed++ + changed = true + continue + } + + next = append(next, it) + } + + if changed { + if err := savePendingAutoStartItems(userKey, next); err != nil { + return removed, err + } + } + } + + return removed, nil +} + func loadPendingAutoStartItems(userKey string) ([]PendingAutoStartItem, error) { path := pendingAutoStartFilePath(userKey) diff --git a/backend/postwork.go b/backend/postwork.go index 64e9c4d..5c088a3 100644 --- a/backend/postwork.go +++ b/backend/postwork.go @@ -3,6 +3,7 @@ package main import ( "context" + "errors" "os" "strings" "sync" @@ -315,7 +316,7 @@ func (pq *PostWorkQueue) workerLoop(id int) { func() { defer func() { if r := recover(); r != nil { - _ = r + appLogln("postwork task panic:", task.Key, r) } pq.mu.Lock() @@ -357,7 +358,13 @@ func (pq *PostWorkQueue) workerLoop(id int) { pq.mu.Unlock() if task.Run != nil { - _ = task.Run(ctx) + if err := task.Run(ctx); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + appLogln("postwork task abgebrochen:", task.Key, err) + } else { + appLogln("postwork task fehlgeschlagen:", task.Key, err) + } + } } }() } diff --git a/backend/record.go b/backend/record.go index 1248732..965beea 100644 --- a/backend/record.go +++ b/backend/record.go @@ -1603,11 +1603,26 @@ func recordStopAll(w http.ResponseWriter, r *http.Request) { } type stopAllResp struct { - OK bool `json:"ok"` - Stopped int `json:"stopped"` - IDs []string `json:"ids,omitempty"` + OK bool `json:"ok"` + Stopped int `json:"stopped"` + IDs []string `json:"ids,omitempty"` + AutostartPaused bool `json:"autostartPaused"` + PendingRemoved int `json:"pendingRemoved,omitempty"` } + setAutostartPaused(true) + + pendingRemoved := 0 + pendingAutoStartMu.Lock() + for _, provider := range []string{"mfc", "chaturbate"} { + if n, err := removeAllPendingAutoStartItemsForProvider(provider); err != nil { + appLogln("⚠️ [stop-all] pending cleanup failed:", provider, err) + } else { + pendingRemoved += n + } + } + pendingAutoStartMu.Unlock() + jobsMu.RLock() toStop := make([]*RecordJob, 0, len(jobs)) ids := make([]string, 0, len(jobs)) @@ -1638,9 +1653,11 @@ func recordStopAll(w http.ResponseWriter, r *http.Request) { } respondJSON(w, stopAllResp{ - OK: true, - Stopped: len(toStop), - IDs: ids, + OK: true, + Stopped: len(toStop), + IDs: ids, + AutostartPaused: true, + PendingRemoved: pendingRemoved, }) } diff --git a/backend/server.go b/backend/server.go index b6a3bc4..5ca0a4d 100644 --- a/backend/server.go +++ b/backend/server.go @@ -1210,6 +1210,7 @@ func main() { startPostWorkStatusRefresher() startEnrichStatusRefresher() + startCleanupTaskOnceOnStartup(appCtx) startPostworkLeftoverScanOnStartup() appGo("generated-garbage-collector", startGeneratedGarbageCollector) diff --git a/backend/startup_leftovers.go b/backend/startup_leftovers.go index b7abbf5..7296921 100644 --- a/backend/startup_leftovers.go +++ b/backend/startup_leftovers.go @@ -90,8 +90,6 @@ func startPostworkLeftoverScanOnStartup() { finishCleanupJob(jobID, summary, nil) } - runPostworkLeftoverScan("startup") - ticker := time.NewTicker(postworkLeftoverScanInterval) defer ticker.Stop() diff --git a/backend/tasks_cleanup.go b/backend/tasks_cleanup.go index 38da912..4d362b1 100644 --- a/backend/tasks_cleanup.go +++ b/backend/tasks_cleanup.go @@ -67,6 +67,16 @@ type cleanupReq struct { BelowMB *int `json:"belowMB,omitempty"` } +type cleanupTaskConfig struct { + DoneAbs string + RecordAbs string + BelowMB int + DeleteLowRated bool + MaxStars int + QueuedText string + RunningText string +} + type cleanupTaskJob struct { State CleanupTaskState Cancel context.CancelFunc @@ -75,6 +85,8 @@ type cleanupTaskJob struct { var cleanupJobsMu sync.Mutex var cleanupJobs = map[string]*cleanupTaskJob{} +const startupCleanupInitialDelay = 8 * time.Second + func createCleanupJob(jobID string, text string, cancel context.CancelFunc) CleanupTaskState { st := CleanupTaskState{ ID: jobID, @@ -183,149 +195,193 @@ func finishCleanupJob(jobID string, text string, err error) { }) } +func cleanupTaskConfigFromSettings(req *cleanupReq, queuedText string, runningText string) (cleanupTaskConfig, error) { + s := getSettings() + + doneAbs, err := resolvePathRelativeToApp(s.DoneDir) + if err != nil || strings.TrimSpace(doneAbs) == "" { + return cleanupTaskConfig{}, fmt.Errorf("doneDir auflösung fehlgeschlagen") + } + + recordAbs, err := resolvePathRelativeToApp(s.RecordDir) + if err != nil || strings.TrimSpace(recordAbs) == "" { + recordAbs = strings.TrimSpace(s.RecordDir) + } + + mb := int(s.AutoDeleteSmallDownloadsBelowMB) + if req != nil && req.BelowMB != nil { + mb = *req.BelowMB + } + if mb < 0 { + mb = 0 + } + + return cleanupTaskConfig{ + DoneAbs: doneAbs, + RecordAbs: recordAbs, + BelowMB: mb, + DeleteLowRated: s.AutoDeleteLowRatedDownloads, + MaxStars: clampAutoDeleteRatingStars(s.AutoDeleteLowRatedDownloadsMaxStars), + QueuedText: strings.TrimSpace(queuedText), + RunningText: strings.TrimSpace(runningText), + }, nil +} + +func startCleanupTask(parentCtx context.Context, jobPrefix string, cfg cleanupTaskConfig) CleanupTaskState { + if parentCtx == nil { + parentCtx = context.Background() + } + + jobPrefix = strings.TrimSpace(jobPrefix) + if jobPrefix == "" { + jobPrefix = "cleanup" + } + + queuedText := strings.TrimSpace(cfg.QueuedText) + if queuedText == "" { + queuedText = "Wartet…" + } + + runningText := strings.TrimSpace(cfg.RunningText) + if runningText == "" { + runningText = "Räume auf…" + } + + jobID := newTaskID(jobPrefix) + ctx, cancel := context.WithCancel(parentCtx) + st := createCleanupJob(jobID, queuedText, cancel) + + go func(jobID string, ctx context.Context, cancel context.CancelFunc, cfg cleanupTaskConfig, runningText string) { + defer cancel() + + if err := acquireExclusiveTask(ctx); err != nil { + finishCleanupJob(jobID, "", err) + return + } + defer releaseExclusiveTask() + + updateCleanupJobState(jobID, func(st *CleanupTaskState) { + st.Queued = false + st.Running = true + st.StartedAt = time.Now() + st.Text = runningText + }) + + resp := cleanupResp{} + + if cfg.BelowMB > 0 || cfg.DeleteLowRated { + threshold := int64(cfg.BelowMB) * 1024 * 1024 + if err := cleanupSmallFiles(ctx, jobID, cfg.DoneAbs, threshold, cfg.DeleteLowRated, cfg.MaxStars, &resp); err != nil { + finishCleanupJob(jobID, "", err) + return + } + } + + // Neuer Cleanup-Abschnitt: alten 100%-Fortschritt vom Small-Files-Scan zurücksetzen. + updateCleanupJobState(jobID, func(st *CleanupTaskState) { + st.Queued = false + st.Running = true + if st.StartedAt.IsZero() { + st.StartedAt = time.Now() + } + st.Done = 0 + st.Total = 0 + st.CurrentFile = "" + st.Text = "Räume Record-Reste auf…" + st.Error = "" + st.FinishedAt = nil + }) + + if err := cleanupRecordDirOrphanAVFiles(ctx, jobID, cfg.RecordAbs, &resp); err != nil { + finishCleanupJob(jobID, "", err) + return + } + + select { + case <-ctx.Done(): + finishCleanupJob(jobID, "", ctx.Err()) + return + default: + } + + updateCleanupJobState(jobID, func(st *CleanupTaskState) { + st.Queued = false + st.Running = true + st.Done = 0 + st.Total = 0 + st.CurrentFile = "" + st.Text = "Räume Generated-Assets auf…" + st.Error = "" + st.FinishedAt = nil + }) + + gcStats := triggerGeneratedGarbageCollectorSync() + + resp.GeneratedOrphansChecked = gcStats.Checked + resp.GeneratedOrphansRemoved = gcStats.Removed + resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes + resp.GeneratedOrphansRemovedBytesHuman = formatBytesSI(gcStats.RemovedBytes) + + resp.DeletedBytes += gcStats.RemovedBytes + resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes) + + if resp.DeletedFiles > 0 || resp.GeneratedOrphansRemoved > 0 { + notifyDoneChanged() + } + + finishCleanupJob( + jobID, + cleanupProgressText(resp), + nil, + ) + }(jobID, ctx, cancel, cfg, runningText) + + return st +} + +func startCleanupTaskOnceOnStartup(parentCtx context.Context) { + if parentCtx == nil { + parentCtx = context.Background() + } + + appGo("startup-cleanup", func() { + timer := time.NewTimer(startupCleanupInitialDelay) + defer timer.Stop() + + select { + case <-parentCtx.Done(): + return + case <-timer.C: + } + + cfg, err := cleanupTaskConfigFromSettings(nil, "Wartet…", "Räume auf…") + if err != nil { + appLogln("⚠️ [startup-cleanup] cleanup config failed:", err) + return + } + + startCleanupTask(parentCtx, "cleanup-startup", cfg) + }) +} + // /api/settings/cleanup (POST) // - löscht kleine Dateien < threshold MB (mp4/ts; skip .part/.tmp; skip keep-Ordner) // - räumt Orphans (preview/thumbs + generated) auf func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: - s := getSettings() - - doneAbs, err := resolvePathRelativeToApp(s.DoneDir) - if err != nil || strings.TrimSpace(doneAbs) == "" { - http.Error(w, "doneDir auflösung fehlgeschlagen", http.StatusBadRequest) - return - } - - recordAbs, err := resolvePathRelativeToApp(s.RecordDir) - if err != nil || strings.TrimSpace(recordAbs) == "" { - recordAbs = strings.TrimSpace(s.RecordDir) - } - - mb := int(s.AutoDeleteSmallDownloadsBelowMB) - deleteLowRated := s.AutoDeleteLowRatedDownloads - maxStars := clampAutoDeleteRatingStars(s.AutoDeleteLowRatedDownloadsMaxStars) - var req cleanupReq if r.Body != nil { _ = json.NewDecoder(r.Body).Decode(&req) } - if req.BelowMB != nil { - mb = *req.BelowMB - } - if mb < 0 { - mb = 0 + + cfg, err := cleanupTaskConfigFromSettings(&req, "Wartet…", "Räume auf…") + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return } - jobID := newTaskID("cleanup") - ctx, cancel := context.WithCancel(context.Background()) - - st := CleanupTaskState{ - ID: jobID, - Queued: true, - Running: false, - Cancellable: true, - QueuedAt: time.Now(), - StartedAt: time.Time{}, - FinishedAt: nil, - Text: "Wartet…", - Error: "", - CurrentFile: "", - Done: 0, - Total: 0, - } - - cleanupJobsMu.Lock() - cleanupJobs[jobID] = &cleanupTaskJob{ - State: st, - Cancel: cancel, - } - cleanupJobsMu.Unlock() - - publishTaskState() - - go func(jobID string, ctx context.Context, doneAbs string, recordAbs string, mb int, deleteLowRated bool, maxStars int) { - if err := acquireExclusiveTask(ctx); err != nil { - finishCleanupJob(jobID, "", err) - return - } - defer releaseExclusiveTask() - - updateCleanupJobState(jobID, func(st *CleanupTaskState) { - st.Queued = false - st.Running = true - st.StartedAt = time.Now() - st.Text = "Räume auf…" - }) - - resp := cleanupResp{} - - if mb > 0 || deleteLowRated { - threshold := int64(mb) * 1024 * 1024 - if err := cleanupSmallFiles(ctx, jobID, doneAbs, threshold, deleteLowRated, maxStars, &resp); err != nil { - finishCleanupJob(jobID, "", err) - return - } - } - - // Neuer Cleanup-Abschnitt: alten 100%-Fortschritt vom Small-Files-Scan zurücksetzen. - updateCleanupJobState(jobID, func(st *CleanupTaskState) { - st.Queued = false - st.Running = true - if st.StartedAt.IsZero() { - st.StartedAt = time.Now() - } - st.Done = 0 - st.Total = 0 - st.CurrentFile = "" - st.Text = "Räume Record-Reste auf…" - st.Error = "" - st.FinishedAt = nil - }) - - if err := cleanupRecordDirOrphanAVFiles(ctx, jobID, recordAbs, &resp); err != nil { - finishCleanupJob(jobID, "", err) - return - } - - select { - case <-ctx.Done(): - finishCleanupJob(jobID, "", ctx.Err()) - return - default: - } - - updateCleanupJobState(jobID, func(st *CleanupTaskState) { - st.Queued = false - st.Running = true - st.Done = 0 - st.Total = 0 - st.CurrentFile = "" - st.Text = "Räume Generated-Assets auf…" - st.Error = "" - st.FinishedAt = nil - }) - - gcStats := triggerGeneratedGarbageCollectorSync() - - resp.GeneratedOrphansChecked = gcStats.Checked - resp.GeneratedOrphansRemoved = gcStats.Removed - resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes - resp.GeneratedOrphansRemovedBytesHuman = formatBytesSI(gcStats.RemovedBytes) - - resp.DeletedBytes += gcStats.RemovedBytes - resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes) - - if resp.DeletedFiles > 0 || resp.GeneratedOrphansRemoved > 0 { - notifyDoneChanged() - } - - finishCleanupJob( - jobID, - cleanupProgressText(resp), - nil, - ) - }(jobID, ctx, doneAbs, recordAbs, mb, deleteLowRated, maxStars) + st := startCleanupTask(context.Background(), "cleanup", cfg) writeJSON(w, http.StatusOK, st) return diff --git a/backend/training.go b/backend/training.go index 119b731..2a858c2 100644 --- a/backend/training.go +++ b/backend/training.go @@ -1311,7 +1311,10 @@ const trainingPoseReliableMinQuality = 0.45 const trainingPositionContextMinScore = 0.22 const trainingPositionContextMaxScore = 0.44 const trainingPositionContextBoostWeight = 0.60 -const trainingPositionContextOverrideMargin = 0.16 +const trainingPoseConfirmingContextMinScore = 0.14 +const trainingPoseUnconfirmedMaxScore = 0.38 +const trainingPoseStrongUnconfirmedMinScore = 0.70 +const trainingPoseStrongUnconfirmedMaxScore = 0.46 var errTrainingCancelled = errors.New("training cancelled") @@ -3471,6 +3474,48 @@ func trainingNegativeCorrection() *TrainingCorrection { } } +func trainingNormalizeCorrectionForStorage(correction *TrainingCorrection) *TrainingCorrection { + if correction == nil { + return nil + } + + normalized := *correction + normalized.SexPosition = normalizeSexPositionLabel(normalized.SexPosition) + if isNoSexPositionLabel(normalized.SexPosition) { + normalized.SexPosition = trainingNoSexPositionLabel + normalized.PosePersons = []TrainingPosePerson{} + } + + return &normalized +} + +func trainingAnnotationEffectiveSexPosition(annotation TrainingAnnotation) string { + if annotation.Negative { + return trainingNoSexPositionLabel + } + + if annotation.Correction != nil { + return normalizeSexPositionLabel(annotation.Correction.SexPosition) + } + + return normalizeSexPositionLabel(annotation.Prediction.SexPosition) +} + +func trainingStripPosePersonsForNoSexPosition(annotation TrainingAnnotation) TrainingAnnotation { + if !isNoSexPositionLabel(trainingAnnotationEffectiveSexPosition(annotation)) { + return annotation + } + + annotation.Prediction.Persons = nil + if annotation.Correction != nil { + correction := trainingNormalizeCorrectionForStorage(annotation.Correction) + correction.PosePersons = []TrainingPosePerson{} + annotation.Correction = correction + } + + return annotation +} + func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") @@ -3492,6 +3537,7 @@ func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) { req.Accepted = false req.Correction = trainingNegativeCorrection() } + req.Correction = trainingNormalizeCorrectionForStorage(req.Correction) root, err := trainingRootDir() if err != nil { @@ -3520,6 +3566,7 @@ func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) { Correction: req.Correction, Notes: strings.TrimSpace(req.Notes), } + annotation = trainingStripPosePersonsForNoSexPosition(annotation) if !annotation.Accepted && annotation.Correction == nil { trainingWriteError(w, http.StatusBadRequest, "correction missing") @@ -3569,6 +3616,7 @@ func trainingFeedbackUpdateHandler(w http.ResponseWriter, r *http.Request) { req.Accepted = false req.Correction = trainingNegativeCorrection() } + req.Correction = trainingNormalizeCorrectionForStorage(req.Correction) if req.SampleID == "" { trainingWriteError(w, http.StatusBadRequest, "sampleId missing") @@ -3629,6 +3677,7 @@ func trainingFeedbackUpdateHandler(w http.ResponseWriter, r *http.Request) { } else { updated.Correction = req.Correction } + updated = trainingStripPosePersonsForNoSexPosition(updated) items[matchIndex] = updated @@ -4874,19 +4923,25 @@ func trainingEffectiveCorrection(annotation TrainingAnnotation) TrainingCorrecti } if annotation.Correction != nil { - return *annotation.Correction + correction := trainingNormalizeCorrectionForStorage(annotation.Correction) + return *correction } p := annotation.Prediction + sexPosition := normalizeSexPositionLabel(p.SexPosition) + posePersons := p.Persons + if isNoSexPositionLabel(sexPosition) { + posePersons = []TrainingPosePerson{} + } return TrainingCorrection{ - SexPosition: p.SexPosition, + SexPosition: sexPosition, PeoplePresent: trainingScoredLabelsToStrings(p.PeoplePresent), BodyPartsPresent: trainingScoredLabelsToStrings(p.BodyPartsPresent), ObjectsPresent: trainingScoredLabelsToStrings(p.ObjectsPresent), ClothingPresent: trainingScoredLabelsToStrings(p.ClothingPresent), Boxes: p.Boxes, - PosePersons: p.Persons, + PosePersons: posePersons, } } @@ -6830,9 +6885,6 @@ func trainingFuseHybridPositionScores( bestPositionScore := 0.0 bestHasPose := false bestHasContext := false - bestPosePosition := "" - bestPoseScore := 0.0 - bestPoseHasContext := false for label := range labels { poseScore := clamp01(poseScores[label]) @@ -6843,12 +6895,21 @@ func trainingFuseHybridPositionScores( hasContext := contextScore > 0 if hasPose { - score = poseScore + if hasContext && contextScore >= trainingPoseConfirmingContextMinScore { + score = poseScore - // Kontext boostet die Pose, bleibt aber bewusst Nebeninformation. - if hasContext { + // Kontext boostet die Pose nur, wenn er dieselbe Position wirklich stützt. boost := clamp01(contextScore * trainingPositionContextBoostWeight) score = clamp01(1 - (1-score)*(1-boost)) + } else { + // Unbestätigte Pose bleibt nutzbar, dominiert aber nicht mehr gegen + // stärkere Box-/Szenen-Signale. Das reduziert False-Positives bei + // falsch erkannten oder schlecht sitzenden Skeletten. + maxUnconfirmedScore := trainingPoseUnconfirmedMaxScore + if poseScore >= trainingPoseStrongUnconfirmedMinScore { + maxUnconfirmedScore = trainingPoseStrongUnconfirmedMaxScore + } + score = math.Min(maxUnconfirmedScore, poseScore) } } else if contextScore >= trainingPositionContextMinScore { // Reiner Box-/Szenen-Kontext darf eine unsichere Prediction liefern, @@ -6863,20 +6924,6 @@ func trainingFuseHybridPositionScores( bestHasContext = hasContext } - if hasPose && score > bestPoseScore { - bestPosePosition = label - bestPoseScore = score - bestPoseHasContext = hasContext - } - } - - if bestPosePosition != "" && - !bestHasPose && - bestPositionScore <= bestPoseScore+trainingPositionContextOverrideMargin { - bestPosition = bestPosePosition - bestPositionScore = bestPoseScore - bestHasPose = true - bestHasContext = bestPoseHasContext } return bestPosition, clamp01(bestPositionScore), bestHasPose, bestHasContext @@ -6991,6 +7038,27 @@ func trainingBoxHorizontalOverlapRatio(a TrainingBox, b TrainingBox) float64 { return clamp01((right - left) / minWidth) } +func trainingBoxVerticalOverlapRatio(a TrainingBox, b TrainingBox) float64 { + a, okA := trainingNormalizedBox(a) + b, okB := trainingNormalizedBox(b) + if !okA || !okB { + return 0 + } + + top := math.Max(a.Y, b.Y) + bottom := math.Min(a.Y+a.H, b.Y+b.H) + if bottom <= top { + return 0 + } + + minHeight := math.Min(a.H, b.H) + if minHeight <= 0 { + return 0 + } + + return clamp01((bottom - top) / minHeight) +} + func trainingBoxesByLabel(boxes []TrainingBox, labels ...string) []TrainingBox { wanted := map[string]bool{} for _, label := range labels { @@ -7261,6 +7329,15 @@ type trainingPosePersonGeometry struct { box TrainingBox hasBox bool center trainingPosePoint + torsoAngle float64 + hasTorsoAxis bool + axisX float64 + axisY float64 + perpX float64 + perpY float64 + bodyLong float64 + bodyCross float64 + elongated bool lying bool upright bool straddling bool @@ -7328,6 +7405,64 @@ func trainingPosePointDistance(a trainingPosePoint, okA bool, b trainingPosePoin return math.Sqrt(dx*dx + dy*dy) } +func trainingPoseProjectedDistance(a trainingPosePoint, okA bool, b trainingPosePoint, okB bool, axisX float64, axisY float64) float64 { + if !okA || !okB { + return 0 + } + + return math.Abs((a.x-b.x)*axisX + (a.y-b.y)*axisY) +} + +func trainingPoseAxisAlignment(a trainingPosePersonGeometry, b trainingPosePersonGeometry) (float64, bool) { + if !a.hasTorsoAxis || !b.hasTorsoAxis { + return 0, false + } + + // Körperachsen sind richtungslos: 0° und 180° gelten beide als parallel. + return math.Abs(math.Cos(a.torsoAngle - b.torsoAngle)), true +} + +func trainingPoseExtentsAlongAxis(person TrainingPosePerson, origin trainingPosePoint, axisX float64, axisY float64, perpX float64, perpY float64) (float64, float64, bool) { + minLong := 0.0 + maxLong := 0.0 + minCross := 0.0 + maxCross := 0.0 + count := 0 + + for _, point := range person.Keypoints { + if point.Conf < trainingPoseKeypointMinConfidence || + !trainingIsFinite01(point.X) || + !trainingIsFinite01(point.Y) { + continue + } + + dx := point.X - origin.x + dy := point.Y - origin.y + long := dx*axisX + dy*axisY + cross := dx*perpX + dy*perpY + + if count == 0 { + minLong = long + maxLong = long + minCross = cross + maxCross = cross + } else { + minLong = math.Min(minLong, long) + maxLong = math.Max(maxLong, long) + minCross = math.Min(minCross, cross) + maxCross = math.Max(maxCross, cross) + } + + count++ + } + + if count < 3 { + return 0, 0, false + } + + return math.Abs(maxLong - minLong), math.Abs(maxCross - minCross), true +} + func trainingPosePersonGeometryFor(person TrainingPosePerson) trainingPosePersonGeometry { box, hasBox := trainingNormalizedBox(person.Box) center := trainingPosePoint{x: 0.5, y: 0.5} @@ -7353,34 +7488,94 @@ func trainingPosePersonGeometryFor(person TrainingPosePerson) trainingPosePerson torsoDX := 0.0 torsoDY := 0.0 torsoLen := 0.0 + torsoAngle := 0.0 + hasTorsoAxis := false + axisX := 0.0 + axisY := 1.0 + perpX := -1.0 + perpY := 0.0 if okHip && okShoulder { - torsoDX = math.Abs(hip.x - shoulder.x) - torsoDY = math.Abs(hip.y - shoulder.y) - torsoLen = math.Sqrt(torsoDX*torsoDX + torsoDY*torsoDY) + rawDX := hip.x - shoulder.x + rawDY := hip.y - shoulder.y + torsoDX = math.Abs(rawDX) + torsoDY = math.Abs(rawDY) + torsoLen = math.Sqrt(rawDX*rawDX + rawDY*rawDY) + if torsoLen >= 0.07 { + hasTorsoAxis = true + axisX = rawDX / torsoLen + axisY = rawDY / torsoLen + perpX = -axisY + perpY = axisX + torsoAngle = math.Atan2(axisY, axisX) + } } hipWidth := trainingPosePointDistance(leftHip, okLeftHip, rightHip, okRightHip) kneeWidth := trainingPosePointDistance(leftKnee, okLeftKnee, rightKnee, okRightKnee) kneesBelowHips := okKnee && okHip && knee.y > hip.y+0.045 + if hasTorsoAxis && okKnee && okHip { + kneeProjection := (knee.x-hip.x)*axisX + (knee.y-hip.y)*axisY + kneesBelowHips = kneeProjection > 0.045 + } + + if hasTorsoAxis && okLeftKnee && okRightKnee { + kneeWidth = trainingPoseProjectedDistance(leftKnee, okLeftKnee, rightKnee, okRightKnee, perpX, perpY) + + if okLeftHip && okRightHip { + hipWidth = trainingPoseProjectedDistance(leftHip, okLeftHip, rightHip, okRightHip, perpX, perpY) + } + } + kneesWide := kneeWidth > 0 && kneeWidth >= math.Max(0.11, hipWidth*1.12) straddling := kneesBelowHips && kneesWide + bodyLong := torsoLen + bodyCross := math.Max(hipWidth, kneeWidth) + + if hasTorsoAxis { + if poseLong, poseCross, ok := trainingPoseExtentsAlongAxis(person, center, axisX, axisY, perpX, perpY); ok { + bodyLong = math.Max(bodyLong, poseLong) + bodyCross = math.Max(bodyCross, poseCross) + } + + if hasBox { + boxLong := math.Abs(axisX)*box.W + math.Abs(axisY)*box.H + boxCross := math.Abs(perpX)*box.W + math.Abs(perpY)*box.H + bodyLong = math.Max(bodyLong, boxLong) + bodyCross = math.Max(bodyCross, boxCross) + } + } else if hasBox { + bodyLong = math.Max(box.W, box.H) + bodyCross = math.Min(box.W, box.H) + } + + elongated := bodyLong >= math.Max(0.18, bodyCross*1.18) + torsoHorizontal := torsoLen >= 0.07 && torsoDX >= torsoDY*1.15 torsoVertical := torsoLen >= 0.07 && torsoDY >= torsoDX*1.15 boxHorizontal := hasBox && box.W >= box.H*1.05 boxVertical := hasBox && box.H >= box.W*1.25 - lying := torsoHorizontal || boxHorizontal + lying := torsoHorizontal || boxHorizontal || (hasTorsoAxis && elongated && !boxVertical) upright := torsoVertical || boxVertical - allFours := torsoHorizontal && kneesBelowHips + allFours := kneesBelowHips && !straddling && (torsoHorizontal || (hasTorsoAxis && elongated)) bentOrKneeling := allFours || (kneesBelowHips && !straddling) return trainingPosePersonGeometry{ box: box, hasBox: hasBox, center: center, + torsoAngle: torsoAngle, + hasTorsoAxis: hasTorsoAxis, + axisX: axisX, + axisY: axisY, + perpX: perpX, + perpY: perpY, + bodyLong: bodyLong, + bodyCross: bodyCross, + elongated: elongated, lying: lying, upright: upright, straddling: straddling, @@ -7425,6 +7620,7 @@ func trainingAddPosePairGeometryScores( gap := trainingBoxGap(left.box, right.box) overlap := trainingBoxOverlapRatio(left.box, right.box) horizontalOverlap := trainingBoxHorizontalOverlapRatio(left.box, right.box) + verticalOverlap := trainingBoxVerticalOverlapRatio(left.box, right.box) close := gap <= 0.12 || overlap >= 0.08 if !close { continue @@ -7438,28 +7634,73 @@ func trainingAddPosePairGeometryScores( } topAbove := top.center.y <= bottom.center.y-0.055 - strongStack := horizontalOverlap >= 0.35 && (topAbove || overlap >= 0.22) - topHasRiderShape := top.straddling || - top.kneesWide || - (top.upright && top.kneesBelowHips) + horizontalStack := horizontalOverlap >= 0.35 && (topAbove || overlap >= 0.22) + lateralStack := verticalOverlap >= 0.35 && overlap >= 0.12 + strongStack := horizontalStack || lateralStack - if strongStack && topHasRiderShape { - add("cowgirl", 0.20) - add("reverse_cowgirl", 0.17) + axisAlignment, hasAxisAlignment := trainingPoseAxisAlignment(left, right) + axesParallel := hasAxisAlignment && axisAlignment >= 0.74 + axesCrossed := hasAxisAlignment && axisAlignment <= 0.56 - if bottom.lying { - add("cowgirl", 0.12) - add("reverse_cowgirl", 0.10) - } - if top.straddling { - add("cowgirl", 0.08) - add("reverse_cowgirl", 0.06) + hasStrongRiderShape := func(g trainingPosePersonGeometry) bool { + return g.straddling || + (g.kneesWide && g.kneesBelowHips && (g.upright || axesCrossed)) + } + hasWeakRiderShape := func(g trainingPosePersonGeometry) bool { + return g.kneesWide && g.kneesBelowHips + } + + leftHasStrongRiderShape := hasStrongRiderShape(left) + rightHasStrongRiderShape := hasStrongRiderShape(right) + topHasStrongRiderShape := hasStrongRiderShape(top) + topHasWeakRiderShape := hasWeakRiderShape(top) + + rider := top + base := bottom + riderHasStrongShape := topHasStrongRiderShape + riderHasWeakShape := topHasWeakRiderShape + if leftHasStrongRiderShape != rightHasStrongRiderShape { + if leftHasStrongRiderShape { + rider = left + base = right + riderHasStrongShape = true + riderHasWeakShape = hasWeakRiderShape(left) + } else { + rider = right + base = left + riderHasStrongShape = true + riderHasWeakShape = hasWeakRiderShape(right) } } - if strongStack && bottom.lying { - add("missionary", 0.14) - if !top.straddling { + if strongStack && riderHasStrongShape && !axesParallel { + add("cowgirl", 0.20) + add("reverse_cowgirl", 0.17) + + if base.lying { + add("cowgirl", 0.12) + add("reverse_cowgirl", 0.10) + } + if rider.straddling { + add("cowgirl", 0.08) + add("reverse_cowgirl", 0.06) + } + } else if strongStack && riderHasWeakShape && !hasAxisAlignment { + // Ohne verwertbare Achsen bleibt Cowgirl nur ein schwaches Signal. + // Sobald die Achsen parallel sind, sieht die Szene eher nach + // Missionary/Überlagerung aus und soll nicht in Cowgirl kippen. + add("cowgirl", 0.08) + add("reverse_cowgirl", 0.06) + } + + if strongStack && (bottom.lying || (axesParallel && topAbove)) { + if !topHasStrongRiderShape || axesParallel { + add("missionary", 0.14) + } + if axesParallel { + add("missionary", 0.08) + } + if !top.straddling && !topHasStrongRiderShape { add("missionary", 0.08) } } @@ -7467,7 +7708,8 @@ func trainingAddPosePairGeometryScores( bothLying := left.lying && right.lying sameLevel := math.Abs(left.center.y-right.center.y) <= 0.15 sideBySide := math.Abs(left.center.x-right.center.x) >= 0.10 - if bothLying && (sameLevel || sideBySide) { + parallelSideBySide := axesParallel && left.elongated && right.elongated && close && !strongStack + if (bothLying && (sameLevel || sideBySide)) || parallelSideBySide { add("spooning", 0.18) if overlap >= 0.10 { add("prone_bone", 0.07) diff --git a/backend/training_test.go b/backend/training_test.go index 26c6711..fbde0fa 100644 --- a/backend/training_test.go +++ b/backend/training_test.go @@ -132,6 +132,79 @@ func TestTrainingEffectiveCorrectionClearsNegativeAnnotation(t *testing.T) { } } +func TestTrainingEffectiveCorrectionClearsPosePersonsForUnknownCorrection(t *testing.T) { + effective := trainingEffectiveCorrection(TrainingAnnotation{ + Correction: &TrainingCorrection{ + SexPosition: "Unknown", + Boxes: []TrainingBox{ + {Label: "person_female", X: 0, Y: 0, W: 1, H: 1}, + }, + PosePersons: []TrainingPosePerson{ + {Label: "person", Score: 0.9}, + }, + }, + }) + + if effective.SexPosition != trainingNoSexPositionLabel { + t.Fatalf("sex position = %q, want %s", effective.SexPosition, trainingNoSexPositionLabel) + } + if len(effective.PosePersons) != 0 { + t.Fatalf("pose persons = %d, want 0 for unknown sex position", len(effective.PosePersons)) + } + if len(effective.Boxes) != 1 { + t.Fatalf("boxes = %d, want detector boxes preserved", len(effective.Boxes)) + } +} + +func TestTrainingEffectiveCorrectionClearsPosePersonsForNoPositionPrediction(t *testing.T) { + effective := trainingEffectiveCorrection(TrainingAnnotation{ + Accepted: true, + Prediction: TrainingPrediction{ + SexPosition: "unknown", + Persons: []TrainingPosePerson{ + {Label: "person", Score: 0.9}, + }, + }, + }) + + if effective.SexPosition != trainingNoSexPositionLabel { + t.Fatalf("sex position = %q, want %s", effective.SexPosition, trainingNoSexPositionLabel) + } + if len(effective.PosePersons) != 0 { + t.Fatalf("pose persons = %d, want 0 for unknown sex position", len(effective.PosePersons)) + } +} + +func TestTrainingStripPosePersonsForNoSexPositionBeforeStorage(t *testing.T) { + annotation := trainingStripPosePersonsForNoSexPosition(TrainingAnnotation{ + Prediction: TrainingPrediction{ + SexPosition: "keine", + Persons: []TrainingPosePerson{ + {Label: "person", Score: 0.9}, + }, + }, + Correction: &TrainingCorrection{ + SexPosition: "Unknown", + PosePersons: []TrainingPosePerson{ + {Label: "person", Score: 0.8}, + }, + }, + }) + + if len(annotation.Prediction.Persons) != 0 { + t.Fatalf("stored prediction persons = %d, want 0", len(annotation.Prediction.Persons)) + } + if annotation.Correction == nil { + t.Fatal("correction missing") + } + if len(annotation.Correction.PosePersons) != 0 { + t.Fatalf("stored correction pose persons = %d, want 0", len(annotation.Correction.PosePersons)) + } + if annotation.Correction.SexPosition != trainingNoSexPositionLabel { + t.Fatalf("stored correction sex position = %q, want %s", annotation.Correction.SexPosition, trainingNoSexPositionLabel) + } +} + func TestNoSexPositionAliasesTreatUnknownAsNoPosition(t *testing.T) { for _, value := range []string{"Unknown", "unknown", "unbekannt", "none", "no_position"} { if !isNoSexPositionLabel(value) { @@ -303,6 +376,85 @@ func TestTrainingApplyPoseToPredictionUsesOcclusionTolerantCowgirlGeometry(t *te } } +func TestTrainingPosePairGeometryDoesNotTreatParallelAxesAsCowgirl(t *testing.T) { + positionSet := stringSet(defaultTrainingLabelsFromJSON().SexPositions) + scores := map[string]float64{} + + pose := TrainingPosePrediction{ + Available: true, + Persons: []TrainingPosePerson{ + { + Label: "person", + Score: 0.74, + Box: TrainingBox{X: 0.30, Y: 0.10, W: 0.30, H: 0.42}, + Keypoints: trainingTestPoseKeypoints(map[string]TrainingKeypoint{ + "left_shoulder": {X: 0.40, Y: 0.18, Conf: 0.92}, + "right_shoulder": {X: 0.50, Y: 0.18, Conf: 0.92}, + "left_hip": {X: 0.40, Y: 0.38, Conf: 0.92}, + "right_hip": {X: 0.50, Y: 0.38, Conf: 0.92}, + "left_knee": {X: 0.32, Y: 0.58, Conf: 0.90}, + "right_knee": {X: 0.58, Y: 0.58, Conf: 0.90}, + }), + }, + { + Label: "person", + Score: 0.72, + Box: TrainingBox{X: 0.31, Y: 0.24, W: 0.30, H: 0.42}, + Keypoints: trainingTestPoseKeypoints(map[string]TrainingKeypoint{ + "left_shoulder": {X: 0.40, Y: 0.30, Conf: 0.92}, + "right_shoulder": {X: 0.50, Y: 0.30, Conf: 0.92}, + "left_hip": {X: 0.40, Y: 0.50, Conf: 0.92}, + "right_hip": {X: 0.50, Y: 0.50, Conf: 0.92}, + "left_knee": {X: 0.39, Y: 0.62, Conf: 0.90}, + "right_knee": {X: 0.51, Y: 0.62, Conf: 0.90}, + }), + }, + }, + } + + trainingAddPosePairGeometryScores(scores, positionSet, pose) + + if got := scores["cowgirl"]; got > 0 { + t.Fatalf("cowgirl score = %.3f, want 0 for parallel body axes", got) + } + if got := scores["reverse_cowgirl"]; got > 0 { + t.Fatalf("reverse_cowgirl score = %.3f, want 0 for parallel body axes", got) + } + if got := scores["missionary"]; got <= 0 { + t.Fatalf("missionary score = %.3f, want > 0 for stacked parallel body axes", got) + } +} + +func TestTrainingPosePersonGeometryUsesBodyAxisForAllFours(t *testing.T) { + person := TrainingPosePerson{ + Label: "person", + Score: 0.84, + Box: TrainingBox{X: 0.38, Y: 0.22, W: 0.24, H: 0.58}, + Keypoints: trainingTestPoseKeypoints(map[string]TrainingKeypoint{ + "left_shoulder": {X: 0.45, Y: 0.30, Conf: 0.92}, + "right_shoulder": {X: 0.55, Y: 0.30, Conf: 0.92}, + "left_hip": {X: 0.45, Y: 0.50, Conf: 0.92}, + "right_hip": {X: 0.55, Y: 0.50, Conf: 0.92}, + "left_knee": {X: 0.46, Y: 0.68, Conf: 0.90}, + "right_knee": {X: 0.54, Y: 0.68, Conf: 0.90}, + }), + } + + geometry := trainingPosePersonGeometryFor(person) + if !geometry.kneesBelowHips { + t.Fatalf("kneesBelowHips = false, want true") + } + if geometry.straddling { + t.Fatalf("straddling = true, want false") + } + if !geometry.elongated { + t.Fatalf("elongated = false, want true") + } + if !geometry.allFours { + t.Fatalf("allFours = false, want true for body-axis aligned bent pose") + } +} + func TestTrainingApplyPoseToPredictionKeepsUnreliablePoseOutOfContext(t *testing.T) { pred := TrainingPrediction{ SexPosition: trainingNoSexPositionLabel, @@ -369,6 +521,45 @@ func TestTrainingApplyPoseToPredictionUsesBoxContextWithoutPose(t *testing.T) { } } +func TestTrainingFuseHybridPositionScoresCapsUnconfirmedPose(t *testing.T) { + positionSet := stringSet(defaultTrainingLabelsFromJSON().SexPositions) + poseScores := map[string]float64{} + contextScores := map[string]float64{} + + trainingCombinePositionScore(poseScores, positionSet, "doggy", 0.82) + + gotPosition, gotScore, gotPose, gotContext := trainingFuseHybridPositionScores(poseScores, contextScores) + if gotPosition != "doggy" { + t.Fatalf("position = %q, want doggy", gotPosition) + } + if !gotPose || gotContext { + t.Fatalf("signals pose=%v context=%v, want pose only", gotPose, gotContext) + } + if gotScore > trainingPoseStrongUnconfirmedMaxScore { + t.Fatalf("score = %.3f, want capped <= %.3f", gotScore, trainingPoseStrongUnconfirmedMaxScore) + } +} + +func TestTrainingFuseHybridPositionScoresPrefersStrongContextOverUnconfirmedPose(t *testing.T) { + positionSet := stringSet(defaultTrainingLabelsFromJSON().SexPositions) + poseScores := map[string]float64{} + contextScores := map[string]float64{} + + trainingCombinePositionScore(poseScores, positionSet, "doggy", 0.62) + trainingCombinePositionScore(contextScores, positionSet, "blowjob", trainingPositionContextMaxScore) + + gotPosition, gotScore, gotPose, gotContext := trainingFuseHybridPositionScores(poseScores, contextScores) + if gotPosition != "blowjob" { + t.Fatalf("position = %q, want blowjob", gotPosition) + } + if gotPose || !gotContext { + t.Fatalf("signals pose=%v context=%v, want context only", gotPose, gotContext) + } + if gotScore < trainingPositionContextMinScore { + t.Fatalf("score = %.3f, want context score >= %.3f", gotScore, trainingPositionContextMinScore) + } +} + func TestTrainingApplyPoseToPredictionBoostsPoseWithBoxContext(t *testing.T) { pred := TrainingPrediction{ ModelAvailable: true, @@ -450,6 +641,108 @@ func TestBuildClipPositionHitsFromEvidenceCapsContextOnlyScore(t *testing.T) { } } +func TestAppendVideoFrameHighlightHitsFromPredictionOmitsRawPosition(t *testing.T) { + hits := appendVideoFrameHighlightHitsFromPrediction(nil, TrainingPrediction{ + ModelAvailable: true, + SexPosition: "doggy", + SexPositionScore: 0.85, + ObjectsPresent: []TrainingScoredLabel{ + {Label: "vibrator", Score: 0.90}, + }, + }, 4) + + if len(hits) == 0 { + t.Fatal("expected non-position video frame highlight") + } + + for _, hit := range hits { + if strings.Contains(hit.Label, "position:") { + t.Fatalf("hit label = %q, want raw position removed", hit.Label) + } + } +} + +func TestBuildClipPositionHitsFromEvidencePrefersVideoMAEClipOverFramePose(t *testing.T) { + hits := buildClipPositionHitsFromEvidence([]analyzePositionEvidence{ + {Time: 1, Label: "doggy", Score: 0.60, HasPose: true, PersonCount: 2}, + {Time: 2, Label: "doggy", Score: 0.60, HasPose: true, PersonCount: 2}, + {Time: 3, Label: "doggy", Score: 0.60, HasPose: true, PersonCount: 2}, + {Time: 2, Label: "cowgirl", Score: 0.58, HasClip: true}, + }, 8) + + if len(hits) == 0 { + t.Fatal("expected VideoMAE-backed position hit") + } + if hits[0].Label != "position:cowgirl" { + t.Fatalf("label = %q, want position:cowgirl", hits[0].Label) + } +} + +func TestBuildClipPositionHitsFromEvidenceUsesVideoMAEClipSpan(t *testing.T) { + hits := buildClipPositionHitsFromEvidence([]analyzePositionEvidence{ + {Time: 12, Start: 10, End: 14, Label: "cowgirl", Score: 0.62, HasClip: true}, + }, 20) + + if len(hits) == 0 { + t.Fatal("expected VideoMAE clip ledger hit") + } + if hits[0].Label != "position:cowgirl" { + t.Fatalf("label = %q, want position:cowgirl", hits[0].Label) + } + if hits[0].Start > 10 || hits[0].End < 14 { + t.Fatalf("span = %.1f..%.1f, want to cover clip 10..14", hits[0].Start, hits[0].End) + } +} + +func TestBuildClipPositionHitsFromEvidenceKeepsStableToyPlayTimelinePosition(t *testing.T) { + hits := buildClipPositionHitsFromEvidence([]analyzePositionEvidence{ + {Time: 12, Start: 10, End: 24, Label: "toy_play", Score: 0.90, HasClip: true}, + }, 120) + + if len(hits) == 0 { + t.Fatal("expected stable toy_play timeline position") + } + if hits[0].Label != "position:toy_play" { + t.Fatalf("label = %q, want position:toy_play", hits[0].Label) + } +} + +func TestBuildClipPositionHitsFromEvidenceDropsShortWeakPositionFlipInLongVideo(t *testing.T) { + hits := buildClipPositionHitsFromEvidence([]analyzePositionEvidence{ + {Time: 10, Start: 8, End: 28, Label: "missionary", Score: 0.70, HasClip: true}, + {Time: 32, Start: 30, End: 34, Label: "cowgirl", Score: 0.46, HasClip: true}, + {Time: 35, Start: 34, End: 38, Label: "toy_play", Score: 0.46, HasClip: true}, + {Time: 50, Start: 36, End: 64, Label: "missionary", Score: 0.72, HasClip: true}, + }, 120) + + if len(hits) == 0 { + t.Fatal("expected stable missionary position hits") + } + for _, hit := range hits { + if hit.Label == "position:cowgirl" { + t.Fatalf("hits = %+v, want short weak cowgirl flip omitted", hits) + } + if hit.Label == "position:toy_play" { + t.Fatalf("hits = %+v, want short weak toy_play flip omitted", hits) + } + } +} + +func TestBuildClipPositionHitsFromEvidenceDropsCloseFrameConflict(t *testing.T) { + hits := buildClipPositionHitsFromEvidence([]analyzePositionEvidence{ + {Time: 1, Label: "doggy", Score: 0.52, HasPose: true, HasContext: true, PersonCount: 2}, + {Time: 2, Label: "doggy", Score: 0.52, HasPose: true, HasContext: true, PersonCount: 2}, + {Time: 3, Label: "doggy", Score: 0.52, HasPose: true, HasContext: true, PersonCount: 2}, + {Time: 1, Label: "cowgirl", Score: 0.50, HasPose: true, HasContext: true, PersonCount: 2}, + {Time: 2, Label: "cowgirl", Score: 0.50, HasPose: true, HasContext: true, PersonCount: 2}, + {Time: 3, Label: "cowgirl", Score: 0.50, HasPose: true, HasContext: true, PersonCount: 2}, + }, 8) + + if len(hits) != 0 { + t.Fatalf("hits = %+v, want no hard position for close conflict", hits) + } +} + func TestTrainingFilterPosePersonsByContextDropsUnannotatedPeople(t *testing.T) { persons := []TrainingPosePerson{ { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5c4c899..2e8f81e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2771,6 +2771,10 @@ export default function App() { // Nur automatische Starts in pending-autostart schieben. // Manuelle UI-Starts müssen sofort direkt starten. + if (!immediate && !recSettingsRef.current.autoStartAddedDownloads) { + return true + } + if (!immediate) { if ( provider === 'chaturbate' && @@ -3159,6 +3163,13 @@ export default function App() { : 0 const percent = Math.round(progress * 100) + const message = String(msg?.message ?? '').trim() + const runningLabel = + total > 0 + ? /\d{1,3}%/.test(message) + ? message + : `Analyse ${percent}%` + : message || 'Analyse läuft' const state = phase === 'error' @@ -3172,9 +3183,7 @@ export default function App() { ? 'Analyse Fehler' : phase === 'done' ? 'Analyse fertig' - : total > 0 - ? `Analyse ${percent}%` - : String(msg?.message ?? '').trim() || 'Analyse läuft' + : runningLabel window.dispatchEvent( new CustomEvent('finished-downloads:postwork', { @@ -3450,6 +3459,8 @@ export default function App() { try { await apiJSON('/api/record/stop-all', { method: 'POST' }) void loadJobs() + void loadPendingAutoStarts() + void loadAutostartState().catch(() => {}) } catch (e: any) { notify.error('Alle stoppen fehlgeschlagen', e?.message ?? String(e)) } diff --git a/frontend/src/components/ui/Downloads.tsx b/frontend/src/components/ui/Downloads.tsx index 572cdf8..ba46b8c 100644 --- a/frontend/src/components/ui/Downloads.tsx +++ b/frontend/src/components/ui/Downloads.tsx @@ -1557,14 +1557,17 @@ export default function Downloads({ }, [pending]) const totalCount = downloadJobRows.length + postworkRows.length + pendingRows.length + const stopAllTargetCount = stoppableIds.length + pendingRows.length const stopAll = useCallback(async () => { if (stopAllBusy) return - if (stoppableIds.length === 0) return + if (stopAllTargetCount === 0) return setStopAllBusy(true) try { - markStopRequested(stoppableIds) + if (stoppableIds.length > 0) { + markStopRequested(stoppableIds) + } if (onStopAllJobs) { await onStopAllJobs() @@ -1574,7 +1577,7 @@ export default function Downloads({ } finally { setStopAllBusy(false) } - }, [stopAllBusy, stoppableIds, markStopRequested, onStopAllJobs, onStopJob]) + }, [stopAllBusy, stopAllTargetCount, stoppableIds, markStopRequested, onStopAllJobs, onStopJob]) const rowClassName = useCallback((r: DownloadRow) => { if (r.kind === 'pending') { @@ -1678,16 +1681,16 @@ export default function Downloads({ @@ -1752,15 +1755,15 @@ export default function Downloads({ diff --git a/frontend/src/components/ui/FinishedDownloads.tsx b/frontend/src/components/ui/FinishedDownloads.tsx index 17379fb..7219a77 100644 --- a/frontend/src/components/ui/FinishedDownloads.tsx +++ b/frontend/src/components/ui/FinishedDownloads.tsx @@ -161,6 +161,31 @@ const keyFor = (j: RecordJob) => { return stableFromOutput || String((j as any)?.output || '') } +function updateStringSet( + prev: Set, + values: Iterable, + enabled: boolean +) { + let changed = false + const next = new Set(prev) + + for (const value of values) { + const clean = String(value || '').trim() + if (!clean) continue + + if (enabled) { + if (!next.has(clean)) { + next.add(clean) + changed = true + } + } else if (next.delete(clean)) { + changed = true + } + } + + return changed ? next : prev +} + const isTrashOutput = (output?: string) => { const p = norm(String(output ?? '')) // match: ".../.trash/file.ext" oder "...\ .trash\file.ext" @@ -1685,7 +1710,7 @@ function samePostworkBadge(a: FinishedPostworkBadge, b: FinishedPostworkBadge): function analysisProgressPercent(label?: string): number | null { const s = String(label ?? '').trim() - const percentMatch = s.match(/^analyse\s+(\d{1,3})%$/i) + const percentMatch = s.match(/^(?:analyse|videomae|speichern|finalisieren)\s+(\d{1,3})%$/i) if (percentMatch) { const n = Number(percentMatch[1]) if (!Number.isFinite(n)) return null @@ -1725,8 +1750,22 @@ function runningStepStateLabel(step: FinishedPostworkStepSummary): string { } function normalizeAnalysisProgressLabel(label?: string): string | undefined { + const raw = String(label ?? '').trim() const percent = analysisProgressPercent(label) if (percent == null) return label + + const phaseMatch = raw.match(/^(videomae|speichern|finalisieren)\s+\d{1,3}%$/i) + if (phaseMatch) { + const phase = phaseMatch[1].toLowerCase() + const prefix = + phase === 'videomae' + ? 'VideoMAE' + : phase === 'speichern' + ? 'Speichern' + : 'Finalisieren' + return `${prefix} ${percent}%` + } + return `Analyse ${percent}%` } @@ -2316,6 +2355,7 @@ export default function FinishedDownloads({ const [bulkDeletedSuccessKeys, setBulkDeletedSuccessKeys] = useState>(() => new Set()) const [keepingKeys, setKeepingKeys] = useState>(() => new Set()) const [keptSuccessKeys, setKeptSuccessKeys] = useState>(() => new Set()) + const [bulkKeptSuccessKeys, setBulkKeptSuccessKeys] = useState>(() => new Set()) const [removingKeys, setRemovingKeys] = useState>(() => new Set()) const [hiddenFiles, setHiddenFiles] = useState>(() => new Set()) const [restoredJobsByKey, setRestoredJobsByKey] = useState>({}) @@ -3163,12 +3203,22 @@ export default function FinishedDownloads({ return next }, [bulkDeletedSuccessKeys, deletedSuccessKeys]) + const visibleKeptSuccessKeys = useMemo(() => { + if (bulkKeptSuccessKeys.size === 0) return keptSuccessKeys + + const next = new Set(keptSuccessKeys) + for (const key of bulkKeptSuccessKeys) { + next.add(key) + } + return next + }, [bulkKeptSuccessKeys, keptSuccessKeys]) + const autoPageSizeSuspended = bulkBusy || deletingKeys.size > 0 || visibleDeletedSuccessKeys.size > 0 || keepingKeys.size > 0 || - keptSuccessKeys.size > 0 || + visibleKeptSuccessKeys.size > 0 || removingKeys.size > 0 // ----------------------------------------------------------------------------- @@ -3592,6 +3642,26 @@ export default function FinishedDownloads({ return aliases }, [fileAliasesFor]) + const markDeletingAliases = useCallback((key: string, file: string | undefined, value: boolean) => { + const aliases = rowKeyAliasesFor(key, file) + setDeletingKeys((prev) => updateStringSet(prev, aliases, value)) + }, [rowKeyAliasesFor]) + + const markKeepingAliases = useCallback((key: string, file: string | undefined, value: boolean) => { + const aliases = rowKeyAliasesFor(key, file) + setKeepingKeys((prev) => updateStringSet(prev, aliases, value)) + }, [rowKeyAliasesFor]) + + const markBulkDeletedSuccessAliases = useCallback((key: string, file: string | undefined, value: boolean) => { + const aliases = rowKeyAliasesFor(key, file) + setBulkDeletedSuccessKeys((prev) => updateStringSet(prev, aliases, value)) + }, [rowKeyAliasesFor]) + + const markBulkKeptSuccessAliases = useCallback((key: string, file: string | undefined, value: boolean) => { + const aliases = rowKeyAliasesFor(key, file) + setBulkKeptSuccessKeys((prev) => updateStringSet(prev, aliases, value)) + }, [rowKeyAliasesFor]) + const removeRowNow = useCallback( (key: string, file?: string) => { cancelRemoveTimer(key) @@ -3645,8 +3715,10 @@ export default function FinishedDownloads({ markDeletedSuccess(key, false) markBulkDeletedSuccess(key, false) markKeptSuccess(key, false) - markDeleting(key, false) - markKeeping(key, false) + markDeletingAliases(key, file, false) + markKeepingAliases(key, file, false) + markBulkDeletedSuccessAliases(key, file, false) + markBulkKeptSuccessAliases(key, file, false) markDeleted(key) markRemoving(key, false) @@ -3659,8 +3731,10 @@ export default function FinishedDownloads({ markDeletedSuccess, markBulkDeletedSuccess, markKeptSuccess, - markDeleting, - markKeeping, + markDeletingAliases, + markKeepingAliases, + markBulkDeletedSuccessAliases, + markBulkKeptSuccessAliases, markDeleted, markRemoving, refreshHoverPreviewFromPointer, @@ -3694,6 +3768,7 @@ export default function FinishedDownloads({ setBulkDeletedSuccessKeys(removeKeyAliases) setKeepingKeys(removeKeyAliases) setKeptSuccessKeys(removeKeyAliases) + setBulkKeptSuccessKeys(removeKeyAliases) if (fileAliases.size > 0) { setHiddenFiles((prev) => { @@ -4560,20 +4635,17 @@ export default function FinishedDownloads({ if (selectedDeleteItems.length === 0) return - const selectedDeleteKeys = selectedDeleteItems.map((item) => item.key) const selectedDeleteFiles = selectedDeleteItems.map((item) => item.file) + const aliasesForDeleteItem = (item: { key: string; file: string }) => + Array.from(rowKeyAliasesFor(item.key, item.file)) + const selectedDeleteAliases = selectedDeleteItems.flatMap(aliasesForDeleteItem) const deletedKeys = new Set() const failedKeys = new Set() + const failedAliases = new Set() const deletedItems: Array<{ file: string; key: string }> = [] const clearDeleting = (keys: Iterable) => { - setDeletingKeys((prev) => { - const next = new Set(prev) - for (const key of keys) { - next.delete(key) - } - return next - }) + setDeletingKeys((prev) => updateStringSet(prev, keys, false)) } const handleResult = (item: any) => { @@ -4587,29 +4659,27 @@ export default function FinishedDownloads({ selectedItem?.key || fileToKeyRef.current.get(file) || file + const aliases = rowKeyAliasesFor(key, file) if (item?.ok) { if (!deletedKeys.has(key)) { deletedKeys.add(key) deletedItems.push({ key, file }) markDeletedRowSuccess(key) - markBulkDeletedSuccess(key, true) + markBulkDeletedSuccessAliases(key, file, true) + clearDeleting(aliases) } return } failedKeys.add(key) - clearDeleting([key]) + for (const alias of aliases) failedAliases.add(alias) + markBulkDeletedSuccessAliases(key, file, false) + clearDeleting(aliases) } // Sofort Overlay anzeigen, noch bevor der Bulk-Request läuft. - setDeletingKeys((prev) => { - const next = new Set(prev) - for (const key of selectedDeleteKeys) { - next.add(key) - } - return next - }) + setDeletingKeys((prev) => updateStringSet(prev, selectedDeleteAliases, true)) setBulkBusy(true) @@ -4671,11 +4741,12 @@ export default function FinishedDownloads({ for (const item of selectedDeleteItems) { if (!deletedKeys.has(item.key) && !failedKeys.has(item.key)) { failedKeys.add(item.key) + for (const alias of aliasesForDeleteItem(item)) failedAliases.add(alias) } } if (failedKeys.size > 0) { - clearDeleting(failedKeys) + clearDeleting(failedAliases) } const deletedCount = deletedKeys.size @@ -4689,7 +4760,11 @@ export default function FinishedDownloads({ } } catch (e: any) { // Bei komplettem Fehler alle zuvor markierten Overlays wieder entfernen. - clearDeleting(selectedDeleteKeys.filter((key) => !deletedKeys.has(key))) + clearDeleting( + selectedDeleteItems + .filter((item) => !deletedKeys.has(item.key)) + .flatMap(aliasesForDeleteItem) + ) if (deletedKeys.size > 0) { clearSelection() @@ -4709,8 +4784,9 @@ export default function FinishedDownloads({ clearSelection, emitCountHint, markDeletedRowSuccess, - markBulkDeletedSuccess, + markBulkDeletedSuccessAliases, removeRowsTogether, + rowKeyAliasesFor, notify, ]) @@ -4737,19 +4813,16 @@ export default function FinishedDownloads({ if (selectedKeepItems.length === 0) return const selectedKeepFiles = selectedKeepItems.map((item) => item.file) - const selectedKeepKeys = selectedKeepItems.map((item) => item.key) + const aliasesForKeepItem = (item: { key: string; file: string }) => + Array.from(rowKeyAliasesFor(item.key, item.file)) + const selectedKeepAliases = selectedKeepItems.flatMap(aliasesForKeepItem) const keptKeys = new Set() const failedKeys = new Set() + const failedAliases = new Set() const keptItems: Array<{ file: string; key: string }> = [] const clearKeeping = (keys: Iterable) => { - setKeepingKeys((prev) => { - const next = new Set(prev) - for (const key of keys) { - next.delete(key) - } - return next - }) + setKeepingKeys((prev) => updateStringSet(prev, keys, false)) } const handleKeepSuccess = (file: string) => { @@ -4761,21 +4834,18 @@ export default function FinishedDownloads({ selectedItem?.key || fileToKeyRef.current.get(cleanFile) || cleanFile + const aliases = rowKeyAliasesFor(key, cleanFile) if (keptKeys.has(key)) return keptKeys.add(key) keptItems.push({ key, file: cleanFile }) markKeptRowSuccess(key) + markBulkKeptSuccessAliases(key, cleanFile, true) + clearKeeping(aliases) } - setKeepingKeys((prev) => { - const next = new Set(prev) - for (const key of selectedKeepKeys) { - next.add(key) - } - return next - }) + setKeepingKeys((prev) => updateStringSet(prev, selectedKeepAliases, true)) setBulkBusy(true) try { @@ -4809,7 +4879,9 @@ export default function FinishedDownloads({ } failedKeys.add(key) - clearKeeping([key]) + for (const alias of rowKeyAliasesFor(key, file)) failedAliases.add(alias) + markBulkKeptSuccessAliases(key, file, false) + clearKeeping(rowKeyAliasesFor(key, file)) } if (results.length === 0) { @@ -4826,11 +4898,12 @@ export default function FinishedDownloads({ for (const item of selectedKeepItems) { if (!keptKeys.has(item.key) && !failedKeys.has(item.key)) { failedKeys.add(item.key) + for (const alias of aliasesForKeepItem(item)) failedAliases.add(alias) } } if (failedKeys.size > 0) { - clearKeeping(failedKeys) + clearKeeping(failedAliases) } const keptCount = keptKeys.size @@ -4843,7 +4916,11 @@ export default function FinishedDownloads({ notify.success?.('Keep erfolgreich', `${keptCount} Downloads behalten.`) } } catch (e: any) { - clearKeeping(selectedKeepKeys.filter((key) => !keptKeys.has(key))) + clearKeeping( + selectedKeepItems + .filter((item) => !keptKeys.has(item.key)) + .flatMap(aliasesForKeepItem) + ) if (keptKeys.size > 0) { clearSelection() @@ -4861,10 +4938,12 @@ export default function FinishedDownloads({ selectedFiles, selectedJobsForBulk, markKeptRowSuccess, + markBulkKeptSuccessAliases, removeRowsTogether, clearSelection, emitCountHint, includeKeep, + rowKeyAliasesFor, notify, ]) @@ -6831,7 +6910,7 @@ export default function FinishedDownloads({ inlinePlay={inlinePlay} deletingKeys={deletingKeys} deletedSuccessKeys={visibleDeletedSuccessKeys} - keptSuccessKeys={keptSuccessKeys} + keptSuccessKeys={visibleKeptSuccessKeys} keepingKeys={keepingKeys} removingKeys={removingKeys} swipeRefs={swipeRefs} @@ -6914,7 +6993,7 @@ export default function FinishedDownloads({ assetNonceForJob={assetNonceForJob} deletingKeys={deletingKeys} deletedSuccessKeys={visibleDeletedSuccessKeys} - keptSuccessKeys={keptSuccessKeys} + keptSuccessKeys={visibleKeptSuccessKeys} keepingKeys={keepingKeys} removingKeys={removingKeys} modelsByKey={modelsByKey} @@ -6967,7 +7046,7 @@ export default function FinishedDownloads({ formatBytes={formatBytes} deletingKeys={deletingKeys} deletedSuccessKeys={visibleDeletedSuccessKeys} - keptSuccessKeys={keptSuccessKeys} + keptSuccessKeys={visibleKeptSuccessKeys} keepingKeys={keepingKeys} removingKeys={removingKeys} registerTeaserHost={registerTeaserHost} diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx index 6c34e7b..6b31ba6 100644 --- a/frontend/src/components/ui/TrainingTab.tsx +++ b/frontend/src/components/ui/TrainingTab.tsx @@ -1982,6 +1982,7 @@ function updatePosePersonQuality(person: TrainingPosePerson): TrainingPosePerson function predictionToCorrection(sample: TrainingSample | null): CorrectionState { const p = sample?.prediction + const sexPosition = normalizeSexPositionValue(p?.sexPosition) const boxes = (p?.boxes ?? []) .map((box) => ({ @@ -1995,25 +1996,30 @@ function predictionToCorrection(sample: TrainingSample | null): CorrectionState .filter((box) => box.label && box.w > 0 && box.h > 0) return { - sexPosition: normalizeSexPositionValue(p?.sexPosition), + sexPosition, peoplePresent: (p?.peoplePresent ?? []).map((x) => x.label), bodyPartsPresent: (p?.bodyPartsPresent ?? []).map((x) => x.label), objectsPresent: (p?.objectsPresent ?? []).map((x) => x.label), clothingPresent: (p?.clothingPresent ?? []).map((x) => x.label), boxes, - posePersons: clonePosePersons(p?.persons), + posePersons: isNoSexPositionValue(sexPosition) + ? [] + : clonePosePersons(p?.persons), } } function cloneCorrectionState(value: CorrectionState): CorrectionState { + const sexPosition = normalizeSexPositionValue(value.sexPosition) return { - sexPosition: value.sexPosition, + sexPosition, peoplePresent: [...value.peoplePresent], bodyPartsPresent: [...value.bodyPartsPresent], objectsPresent: [...value.objectsPresent], clothingPresent: [...value.clothingPresent], boxes: (value.boxes ?? []).map((box) => ({ ...box })), - posePersons: clonePosePersons(value.posePersons), + posePersons: isNoSexPositionValue(sexPosition) + ? [] + : clonePosePersons(value.posePersons), } } @@ -3969,10 +3975,13 @@ function annotationToCorrectionState(item: TrainingAnnotation): CorrectionState } if (item.correction) { + const sexPosition = normalizeSexPositionValue(item.correction.sexPosition) return { ...item.correction, - sexPosition: normalizeSexPositionValue(item.correction.sexPosition), - posePersons: clonePosePersons(item.correction.posePersons ?? item.prediction.persons), + sexPosition, + posePersons: isNoSexPositionValue(sexPosition) + ? [] + : clonePosePersons(item.correction.posePersons ?? item.prediction.persons), } } @@ -7218,11 +7227,16 @@ export default function TrainingTab(props: { const normalizedBoxes = (correction.boxes ?? []) .map(normalizeBox) .filter((box) => box.label && box.w > 0 && box.h > 0) + const normalizedSexPosition = normalizeSexPositionValue(correction.sexPosition) + const posePersonsForFeedback = isNoSexPositionValue(normalizedSexPosition) + ? [] + : clonePosePersons(correction.posePersons) const feedbackCorrection = { ...correction, + sexPosition: normalizedSexPosition, boxes: normalizedBoxes, - posePersons: clonePosePersons(correction.posePersons), + posePersons: posePersonsForFeedback, } const negative = options?.negative ?? @@ -7239,9 +7253,10 @@ export default function TrainingTab(props: { } : { ...correction, + sexPosition: normalizedSexPosition, peoplePresent: peopleLabelsFromBoxes(normalizedBoxes, labelsRef.current), boxes: normalizedBoxes, - posePersons: clonePosePersons(correction.posePersons), + posePersons: posePersonsForFeedback, } const effectiveAccepted = negative ? false : accepted setSavingOverlayText( @@ -12473,9 +12488,13 @@ export default function TrainingTab(props: { setHasManualCorrection(true) } + const nextSexPosition = normalizeSexPositionValue(value) return { ...p, - sexPosition: value, + sexPosition: nextSexPosition, + posePersons: isNoSexPositionValue(nextSexPosition) + ? [] + : p.posePersons, } }) } @@ -12677,9 +12696,13 @@ export default function TrainingTab(props: { setHasManualCorrection(true) } + const nextSexPosition = normalizeSexPositionValue(value) return { ...p, - sexPosition: value, + sexPosition: nextSexPosition, + posePersons: isNoSexPositionValue(nextSexPosition) + ? [] + : p.posePersons, } }) }