This commit is contained in:
Linrador 2026-07-13 11:49:46 +02:00
parent 4484fb978d
commit d538a664cf
20 changed files with 1873 additions and 507 deletions

View File

@ -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)

File diff suppressed because it is too large Load Diff

View File

@ -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,

View File

@ -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
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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
}

View File

@ -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)

View File

@ -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)
}
}
}
}()
}

View File

@ -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,
})
}

View File

@ -1210,6 +1210,7 @@ func main() {
startPostWorkStatusRefresher()
startEnrichStatusRefresher()
startCleanupTaskOnceOnStartup(appCtx)
startPostworkLeftoverScanOnStartup()
appGo("generated-garbage-collector", startGeneratedGarbageCollector)

View File

@ -90,8 +90,6 @@ func startPostworkLeftoverScanOnStartup() {
finishCleanupJob(jobID, summary, nil)
}
runPostworkLeftoverScan("startup")
ticker := time.NewTicker(postworkLeftoverScanInterval)
defer ticker.Stop()

View File

@ -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

View File

@ -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)

View File

@ -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{
{

View File

@ -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))
}

View File

@ -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({
<Button
size="sm"
variant="primary"
disabled={stopAllBusy || stoppableIds.length === 0}
disabled={stopAllBusy || stopAllTargetCount === 0}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void stopAll()
}}
className="w-full justify-center"
title={stoppableIds.length === 0 ? 'Nichts zu stoppen' : 'Alle laufenden stoppen'}
title={stopAllTargetCount === 0 ? 'Nichts zu stoppen' : 'Alle laufenden und wartenden stoppen'}
>
{stopAllBusy ? 'Stoppe…' : `Alle stoppen (${stoppableIds.length})`}
{stopAllBusy ? 'Stoppe…' : `Alle stoppen (${stopAllTargetCount})`}
</Button>
</div>
</div>
@ -1752,15 +1755,15 @@ export default function Downloads({
<Button
size="sm"
variant="primary"
disabled={stopAllBusy || stoppableIds.length === 0}
disabled={stopAllBusy || stopAllTargetCount === 0}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void stopAll()
}}
title={stoppableIds.length === 0 ? 'Nichts zu stoppen' : 'Alle laufenden stoppen'}
title={stopAllTargetCount === 0 ? 'Nichts zu stoppen' : 'Alle laufenden und wartenden stoppen'}
>
{stopAllBusy ? 'Stoppe alle…' : `Alle stoppen (${stoppableIds.length})`}
{stopAllBusy ? 'Stoppe alle…' : `Alle stoppen (${stopAllTargetCount})`}
</Button>
</div>
</div>

View File

@ -161,6 +161,31 @@ const keyFor = (j: RecordJob) => {
return stableFromOutput || String((j as any)?.output || '')
}
function updateStringSet(
prev: Set<string>,
values: Iterable<string>,
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<Set<string>>(() => new Set())
const [keepingKeys, setKeepingKeys] = useState<Set<string>>(() => new Set())
const [keptSuccessKeys, setKeptSuccessKeys] = useState<Set<string>>(() => new Set())
const [bulkKeptSuccessKeys, setBulkKeptSuccessKeys] = useState<Set<string>>(() => new Set())
const [removingKeys, setRemovingKeys] = useState<Set<string>>(() => new Set())
const [hiddenFiles, setHiddenFiles] = useState<Set<string>>(() => new Set())
const [restoredJobsByKey, setRestoredJobsByKey] = useState<Record<string, RecordJob>>({})
@ -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<string>()
const failedKeys = new Set<string>()
const failedAliases = new Set<string>()
const deletedItems: Array<{ file: string; key: string }> = []
const clearDeleting = (keys: Iterable<string>) => {
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<string>()
const failedKeys = new Set<string>()
const failedAliases = new Set<string>()
const keptItems: Array<{ file: string; key: string }> = []
const clearKeeping = (keys: Iterable<string>) => {
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}

View File

@ -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,
}
})
}