diff --git a/Icons/69.png b/Icons/69.png new file mode 100644 index 0000000..f69b615 Binary files /dev/null and b/Icons/69.png differ diff --git a/Icons/anus.png b/Icons/anus.png new file mode 100644 index 0000000..83a9ab4 Binary files /dev/null and b/Icons/anus.png differ diff --git a/Icons/bikini.png b/Icons/bikini.png new file mode 100644 index 0000000..039068c Binary files /dev/null and b/Icons/bikini.png differ diff --git a/Icons/blindfold.png b/Icons/blindfold.png new file mode 100644 index 0000000..5efef13 Binary files /dev/null and b/Icons/blindfold.png differ diff --git a/Icons/blowjob.png b/Icons/blowjob.png new file mode 100644 index 0000000..cec9072 Binary files /dev/null and b/Icons/blowjob.png differ diff --git a/Icons/bra.png b/Icons/bra.png new file mode 100644 index 0000000..51ae372 Binary files /dev/null and b/Icons/bra.png differ diff --git a/Icons/breasts.png b/Icons/breasts.png new file mode 100644 index 0000000..d03bb60 Binary files /dev/null and b/Icons/breasts.png differ diff --git a/Icons/buttocks.png b/Icons/buttocks.png new file mode 100644 index 0000000..9f3e515 Binary files /dev/null and b/Icons/buttocks.png differ diff --git a/Icons/buttplug.png b/Icons/buttplug.png new file mode 100644 index 0000000..4381a2e Binary files /dev/null and b/Icons/buttplug.png differ diff --git a/Icons/choker.png b/Icons/choker.png new file mode 100644 index 0000000..820a419 Binary files /dev/null and b/Icons/choker.png differ diff --git a/Icons/cowgirl.png b/Icons/cowgirl.png new file mode 100644 index 0000000..b893a64 Binary files /dev/null and b/Icons/cowgirl.png differ diff --git a/Icons/crop-top.png b/Icons/crop-top.png new file mode 100644 index 0000000..9d9342a Binary files /dev/null and b/Icons/crop-top.png differ diff --git a/Icons/cunnilingus.png b/Icons/cunnilingus.png new file mode 100644 index 0000000..93ecb1f Binary files /dev/null and b/Icons/cunnilingus.png differ diff --git a/Icons/dildo.png b/Icons/dildo.png new file mode 100644 index 0000000..749f4ff Binary files /dev/null and b/Icons/dildo.png differ diff --git a/Icons/doggy.png b/Icons/doggy.png new file mode 100644 index 0000000..05565f9 Binary files /dev/null and b/Icons/doggy.png differ diff --git a/Icons/dress.png b/Icons/dress.png new file mode 100644 index 0000000..f31f067 Binary files /dev/null and b/Icons/dress.png differ diff --git a/Icons/female.png b/Icons/female.png new file mode 100644 index 0000000..7cd5163 Binary files /dev/null and b/Icons/female.png differ diff --git a/Icons/fingering.png b/Icons/fingering.png new file mode 100644 index 0000000..1706a38 Binary files /dev/null and b/Icons/fingering.png differ diff --git a/Icons/hand-job.png b/Icons/hand-job.png new file mode 100644 index 0000000..42f628a Binary files /dev/null and b/Icons/hand-job.png differ diff --git a/Icons/handcuffs.png b/Icons/handcuffs.png new file mode 100644 index 0000000..cbd310c Binary files /dev/null and b/Icons/handcuffs.png differ diff --git a/Icons/high-heels.png b/Icons/high-heels.png new file mode 100644 index 0000000..f687732 Binary files /dev/null and b/Icons/high-heels.png differ diff --git a/Icons/hotpants.png b/Icons/hotpants.png new file mode 100644 index 0000000..9541588 Binary files /dev/null and b/Icons/hotpants.png differ diff --git a/Icons/lingerie.png b/Icons/lingerie.png new file mode 100644 index 0000000..cfd9056 Binary files /dev/null and b/Icons/lingerie.png differ diff --git a/Icons/male.png b/Icons/male.png new file mode 100644 index 0000000..ac8e571 Binary files /dev/null and b/Icons/male.png differ diff --git a/Icons/missionary.png b/Icons/missionary.png new file mode 100644 index 0000000..4003d15 Binary files /dev/null and b/Icons/missionary.png differ diff --git a/Icons/other.png b/Icons/other.png new file mode 100644 index 0000000..a76b77d Binary files /dev/null and b/Icons/other.png differ diff --git a/Icons/panties.png b/Icons/panties.png new file mode 100644 index 0000000..2655c72 Binary files /dev/null and b/Icons/panties.png differ diff --git a/Icons/penis.png b/Icons/penis.png new file mode 100644 index 0000000..3c4b4f4 Binary files /dev/null and b/Icons/penis.png differ diff --git a/Icons/prone-bone.png b/Icons/prone-bone.png new file mode 100644 index 0000000..da81597 Binary files /dev/null and b/Icons/prone-bone.png differ diff --git a/Icons/reverse-cowgirl.png b/Icons/reverse-cowgirl.png new file mode 100644 index 0000000..6a63c87 Binary files /dev/null and b/Icons/reverse-cowgirl.png differ diff --git a/Icons/shower.png b/Icons/shower.png new file mode 100644 index 0000000..d2cb98d Binary files /dev/null and b/Icons/shower.png differ diff --git a/Icons/sitting.png b/Icons/sitting.png new file mode 100644 index 0000000..3770748 Binary files /dev/null and b/Icons/sitting.png differ diff --git a/Icons/skirt.png b/Icons/skirt.png new file mode 100644 index 0000000..2857ec4 Binary files /dev/null and b/Icons/skirt.png differ diff --git a/Icons/standing-doggy.png b/Icons/standing-doggy.png new file mode 100644 index 0000000..88b9c9a Binary files /dev/null and b/Icons/standing-doggy.png differ diff --git a/Icons/standing.png b/Icons/standing.png new file mode 100644 index 0000000..42f13c6 Binary files /dev/null and b/Icons/standing.png differ diff --git a/Icons/stockings.png b/Icons/stockings.png new file mode 100644 index 0000000..ed9937d Binary files /dev/null and b/Icons/stockings.png differ diff --git a/Icons/strap-on.png b/Icons/strap-on.png new file mode 100644 index 0000000..17371a6 Binary files /dev/null and b/Icons/strap-on.png differ diff --git a/Icons/tongue.png b/Icons/tongue.png new file mode 100644 index 0000000..5f66fc2 Binary files /dev/null and b/Icons/tongue.png differ diff --git a/Icons/towel.png b/Icons/towel.png new file mode 100644 index 0000000..eda5cc8 Binary files /dev/null and b/Icons/towel.png differ diff --git a/Icons/toy.png b/Icons/toy.png new file mode 100644 index 0000000..919d20d Binary files /dev/null and b/Icons/toy.png differ diff --git a/Icons/unknown.png b/Icons/unknown.png new file mode 100644 index 0000000..e71773a Binary files /dev/null and b/Icons/unknown.png differ diff --git a/Icons/vagina.png b/Icons/vagina.png new file mode 100644 index 0000000..f4339a6 Binary files /dev/null and b/Icons/vagina.png differ diff --git a/backend/__pycache__/ai_server.cpython-314.pyc b/backend/__pycache__/ai_server.cpython-314.pyc new file mode 100644 index 0000000..aa8d7bc Binary files /dev/null and b/backend/__pycache__/ai_server.cpython-314.pyc differ diff --git a/backend/ai_server.py b/backend/ai_server.py new file mode 100644 index 0000000..d5fd22b --- /dev/null +++ b/backend/ai_server.py @@ -0,0 +1,244 @@ +# backend\ai_server.py + +import os +from typing import List, Optional + +from fastapi import FastAPI +from pydantic import BaseModel +from ultralytics import YOLO + + +BODY_LABELS = { + "anus", + "ass", + "breasts", + "penis", + "tongue", + "pussy", +} + +OBJECT_LABELS = { + "blindfold", + "buttplug", + "collar", + "dildo", + "handcuffs", + "shower", + "strapon", + "towel", + "vibrator", +} + +CLOTHING_LABELS = { + "bikini", + "bra", + "dress", + "heels", + "hotpants", + "lingerie", + "panties", + "skirt", + "stockings", + "croptop", +} + +POSITION_LABELS = { + "missionary", + "doggy", + "cowgirl", + "reverse_cowgirl", + "cunnilingus", + "prone_bone", + "standing", + "standing_doggy", + "spooning", + "sitting", + "facesitting", + "handjob", + "blowjob", + "toy_play", + "fingering", + "69", + "other", +} + + +class PredictBatchRequest(BaseModel): + paths: List[str] + detectorOnly: bool = False + imageSize: int = 640 + model: Optional[str] = None + + +app = FastAPI() + +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +DEFAULT_MODEL_PATH = BASE_DIR / "generated" / "training" / "detector" / "model" / "best.pt" + +def resolve_model_path() -> str: + env_path = os.environ.get("YOLO_MODEL", "").strip() + if env_path: + p = Path(env_path) + if p.exists(): + return str(p) + raise RuntimeError(f"YOLO_MODEL not found: {p}") + + default_path = DEFAULT_MODEL_PATH + if default_path.exists(): + return str(default_path) + + raise RuntimeError(f"YOLO model not found: {default_path}") + + +_MODEL_PATH = resolve_model_path() +_DEVICE = os.environ.get("YOLO_DEVICE", "") +_CONF = float(os.environ.get("YOLO_CONF", "0.25")) +_BATCH = int(os.environ.get("YOLO_BATCH", "16")) +_IMGSZ = int(os.environ.get("YOLO_IMGSZ", "640")) +_HALF = os.environ.get("YOLO_HALF", "0").lower() in {"1", "true", "yes", "on"} + +model = YOLO(_MODEL_PATH) + + +def scored(label: str, score: float) -> dict: + return { + "label": label, + "score": float(score), + } + + +def best_score(items: list[dict], label: str, score: float) -> None: + for item in items: + if item["label"] == label: + if score > item["score"]: + item["score"] = float(score) + return + + items.append(scored(label, score)) + + +def prediction_from_result(result) -> dict: + names = result.names or {} + + boxes_out = [] + body_parts = [] + objects = [] + clothing = [] + + sex_position = "unknown" + sex_position_score = 0.0 + + if result.boxes is not None: + xywhn = result.boxes.xywhn.cpu().tolist() + cls_values = result.boxes.cls.cpu().tolist() + conf_values = result.boxes.conf.cpu().tolist() + + for box_xywhn, cls_id, conf in zip(xywhn, cls_values, conf_values): + label = str(names.get(int(cls_id), int(cls_id))).strip().lower() + score = float(conf) + + if not label: + continue + + cx, cy, w, h = [float(v) for v in box_xywhn] + x = max(0.0, min(1.0, cx - w / 2.0)) + y = max(0.0, min(1.0, cy - h / 2.0)) + w = max(0.0, min(1.0 - x, w)) + h = max(0.0, min(1.0 - y, h)) + + boxes_out.append({ + "label": label, + "score": score, + "x": x, + "y": y, + "w": w, + "h": h, + }) + + if label in BODY_LABELS: + best_score(body_parts, label, score) + + if label in OBJECT_LABELS: + best_score(objects, label, score) + + if label in CLOTHING_LABELS: + best_score(clothing, label, score) + + if label in POSITION_LABELS and score > sex_position_score: + sex_position = label + sex_position_score = score + + people_count = sum( + 1 for box in boxes_out + if box["label"] in {"person", "person_male", "person_female", "person_unknown"} + ) + male_count = sum(1 for box in boxes_out if box["label"] in {"person_male", "male_person"}) + female_count = sum(1 for box in boxes_out if box["label"] in {"person_female", "female_person"}) + unknown_count = max(0, people_count - male_count - female_count) + + return { + "modelAvailable": True, + "source": f"yolo-server:{Path(_MODEL_PATH).name}", + "peopleCount": people_count, + "maleCount": male_count, + "femaleCount": female_count, + "unknownCount": unknown_count, + "sexPosition": sex_position, + "sexPositionScore": sex_position_score, + "bodyPartsPresent": body_parts, + "objectsPresent": objects, + "clothingPresent": clothing, + "boxes": boxes_out, + } + + +@app.post("/predict-batch") +def predict_batch(req: PredictBatchRequest): + paths = [str(path).strip() for path in req.paths if str(path).strip()] + if not paths: + return { + "ok": False, + "predictions": [], + "error": "no paths supplied", + } + + imgsz = int(req.imageSize or _IMGSZ or 640) + + try: + results = model.predict( + source=paths, + imgsz=imgsz, + conf=_CONF, + batch=_BATCH, + device=_DEVICE or None, + half=_HALF, + verbose=False, + ) + + predictions = [prediction_from_result(result) for result in results] + + return { + "ok": True, + "predictions": predictions, + } + + except Exception as exc: + return { + "ok": False, + "predictions": [], + "error": str(exc), + } + + +@app.get("/health") +def health(): + names = getattr(model, "names", {}) or {} + + return { + "ok": True, + "model": _MODEL_PATH, + "classCount": len(names), + "classes": list(names.values())[:80] if isinstance(names, dict) else names, + } \ No newline at end of file diff --git a/backend/analyze.go b/backend/analyze.go index 440c7ba..4eb0a38 100644 --- a/backend/analyze.go +++ b/backend/analyze.go @@ -3,11 +3,13 @@ package main import ( + "bytes" "context" "encoding/json" "fmt" "image" "image/draw" + "image/jpeg" "math" "net/http" "os" @@ -22,7 +24,7 @@ import ( type analyzeVideoReq struct { JobID string `json:"jobId"` Output string `json:"output"` - Mode string `json:"mode"` // "sprite" | "video" + Mode string `json:"mode"` // "video" | "sprite" Goal string `json:"goal"` // "highlights" | "nsfw" } @@ -49,26 +51,98 @@ type spriteFrameCandidate struct { Time float64 } +type videoFrameSample struct { + Index int + Time float64 + Path string +} + const ( analyzeSegmentMergeGapSeconds = 8.0 nsfwThresholdModerate = 0.35 nsfwThresholdStrong = 0.60 + + // Sprite-Modus ist aktuell deaktiviert. Analyse läuft über Video-Frames. + analyzeMaxSpriteCandidates = 24 + + // Video-Modus: schnelle Vorschau. Für bessere Trefferquote später 24. + // neu, falls du später alle N Sekunden willst + analyzeVideoFrameIntervalSeconds = 3 + + // AI-Server nicht mit tausenden Pfaden auf einmal fluten. + analyzeFramePredictBatchSize = 32 + + // 640 ist für YOLO meist deutlich schneller als 960/1280. + analyzeVideoFrameWidth = 640 + + // Lokaler optionaler Python-Inference-Server. + // Kann per Environment überschrieben werden: + // AI_SERVER_URL=http://127.0.0.1:8765 + analyzeAIServerDefaultURL = "http://127.0.0.1:8765" ) var autoSelectedAILabels = map[string]struct{}{ - "anus_exposed": {}, - "female_genitalia_exposed": {}, - "male_genitalia_exposed": {}, - "female_breast_exposed": {}, - "buttocks_exposed": {}, + // bodyParts aus detecton_labels.json + "anus": {}, + "ass": {}, + "breasts": {}, + "penis": {}, + "tongue": {}, + "pussy": {}, + + // objects aus detecton_labels.json + "blindfold": {}, + "buttplug": {}, + "collar": {}, + "dildo": {}, + "handcuffs": {}, + "shower": {}, + "strapon": {}, + "towel": {}, + "vibrator": {}, + + // clothing aus detecton_labels.json + "bikini": {}, + "bra": {}, + "dress": {}, + "heels": {}, + "hotpants": {}, + "lingerie": {}, + "panties": {}, + "skirt": {}, + "stockings": {}, + "croptop": {}, + + // sexPositions aus detecton_labels.json + "missionary": {}, + "doggy": {}, + "cowgirl": {}, + "reverse_cowgirl": {}, + "cunnilingus": {}, + "prone_bone": {}, + "standing": {}, + "standing_doggy": {}, + "spooning": {}, + "sitting": {}, + "facesitting": {}, + "handjob": {}, + "blowjob": {}, + "toy_play": {}, + "fingering": {}, + "69": {}, + "other": {}, } var nsfwIgnoredLabels = map[string]struct{}{ - "face_female": {}, - "face_male": {}, - "belly_covered": {}, - "armpits_covered": {}, - "anus_covered": {}, + // Personen sollen nicht als interessante Segmente auftauchen. + "person": {}, + "person_male": {}, + "person_female": {}, + "person_unknown": {}, + + // Falls dein Detector irgendwann diese Varianten liefert: + "people_male": {}, + "people_female": {}, } func shouldAutoSelectAnalyzeHit(label string) bool { @@ -97,13 +171,13 @@ func extractSpriteFrames(spritePath string, ps previewSpriteMetaFileInfo) ([]ima b := img.Bounds() if ps.Cols <= 0 || ps.Rows <= 0 { - return nil, fmt.Errorf("sprite cols/rows fehlen") + return nil, appErrorf("sprite cols/rows fehlen") } cellW := b.Dx() / ps.Cols cellH := b.Dy() / ps.Rows if cellW <= 0 || cellH <= 0 { - return nil, fmt.Errorf("ungültige sprite cell size") + return nil, appErrorf("ungültige sprite cell size") } count := ps.Count @@ -149,50 +223,498 @@ func classifyFrameNSFW(ctx context.Context, img image.Image) (*NsfwImageResponse }, nil } +func addTrainingAnalyzeResult(best map[string]float64, label string, score float64) { + label = strings.ToLower(strings.TrimSpace(label)) + if label == "" { + return + } + + if score <= 0 { + score = 1 + } + + if old, ok := best[label]; !ok || score > old { + best[label] = score + } +} + +func trainingPredictionToNSFWResults(pred TrainingPrediction) []NsfwFrameResult { + best := map[string]float64{} + + // Für NSFW/AI-Segmente nur echte Boxen verwenden. + // BodyPartsPresent/ObjectsPresent/ClothingPresent sind daraus abgeleitete Übersichten + // und können sonst Labels doppelt oder zu breit einbringen. + for _, box := range pred.Boxes { + label := strings.ToLower(strings.TrimSpace(box.Label)) + if label == "" { + continue + } + + if isIgnoredNSFWLabel(label) { + continue + } + + // Nur Labels zulassen, die für Analyse/Rating relevant sind. + // Dadurch erzeugen neue YOLO-Klassen nur dann Segmente, + // wenn du sie bewusst in autoSelectedAILabels einträgst. + if !shouldAutoSelectAnalyzeHit(label) { + continue + } + + score := box.Score + if score <= 0 { + score = 1 + } + + if old, ok := best[label]; !ok || score > old { + best[label] = score + } + } + + out := make([]NsfwFrameResult, 0, len(best)) + + for label, score := range best { + out = append(out, NsfwFrameResult{ + Label: label, + Score: score, + }) + } + + sort.Slice(out, func(i, j int) bool { + if out[i].Score == out[j].Score { + return out[i].Label < out[j].Label + } + return out[i].Score > out[j].Score + }) + + return out +} + +func addHighlightResult(best map[string]float64, label string, score float64) { + label = strings.ToLower(strings.TrimSpace(label)) + if label == "" || label == "unknown" { + return + } + + if score <= 0 { + score = 1 + } + + if old, ok := best[label]; !ok || score > old { + best[label] = score + } +} + +func addScoredHighlightLabels(best map[string]float64, prefix string, items []TrainingScoredLabel) { + prefix = strings.ToLower(strings.TrimSpace(prefix)) + if prefix == "" { + return + } + + for _, item := range items { + label := strings.ToLower(strings.TrimSpace(item.Label)) + if label == "" || label == "unknown" { + continue + } + + addHighlightResult(best, prefix+":"+label, item.Score) + } +} + +func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameResult { + best := map[string]float64{} + + sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition)) + if sexPosition != "" && sexPosition != "unknown" { + addHighlightResult(best, "position:"+sexPosition, pred.SexPositionScore) + } + + addScoredHighlightLabels(best, "body", pred.BodyPartsPresent) + addScoredHighlightLabels(best, "object", pred.ObjectsPresent) + addScoredHighlightLabels(best, "clothing", pred.ClothingPresent) + + for _, box := range pred.Boxes { + label := strings.ToLower(strings.TrimSpace(box.Label)) + if label == "" || label == "unknown" { + continue + } + + if isIgnoredNSFWLabel(label) { + continue + } + + // Wichtig: + // Keine beliebigen YOLO-/COCO-Labels als Highlights übernehmen. + // Nur bewusst erlaubte Analyse-Labels anzeigen. + if !shouldAutoSelectAnalyzeHit(label) { + continue + } + + addHighlightResult(best, "detector:"+label, box.Score) + } + + // Kombis nur erzeugen, wenn wirklich Position + Zusatz vorhanden ist. + if sexPosition != "" && sexPosition != "unknown" { + positionScore := pred.SexPositionScore + if positionScore <= 0 { + positionScore = 1 + } + + addCombo := func(prefix string, items []TrainingScoredLabel) { + for _, item := range items { + label := strings.ToLower(strings.TrimSpace(item.Label)) + if label == "" || label == "unknown" { + continue + } + + score := item.Score + if score <= 0 { + score = 1 + } + + comboScore := math.Min(positionScore, score) + addHighlightResult(best, "combo:"+sexPosition+"+"+prefix+":"+label, comboScore) + } + } + + addCombo("body", pred.BodyPartsPresent) + addCombo("object", pred.ObjectsPresent) + addCombo("clothing", pred.ClothingPresent) + } + + out := make([]NsfwFrameResult, 0, len(best)) + + for label, score := range best { + out = append(out, NsfwFrameResult{ + Label: label, + Score: score, + }) + } + + sort.Slice(out, func(i, j int) bool { + if out[i].Score == out[j].Score { + return out[i].Label < out[j].Label + } + return out[i].Score > out[j].Score + }) + + return out +} + +func pickHighlightResults(results []NsfwFrameResult) []NsfwFrameResult { + out := make([]NsfwFrameResult, 0, len(results)) + + for _, r := range results { + label := strings.ToLower(strings.TrimSpace(r.Label)) + if label == "" || label == "unknown" { + continue + } + + score := r.Score + if score <= 0 { + score = 1 + } + + // Schwellen kannst du später pro Gruppe anders machen. + switch { + case strings.HasPrefix(label, "combo:"): + if score < 0.35 { + continue + } + case strings.HasPrefix(label, "position:"): + if score < 0.30 { + continue + } + case strings.HasPrefix(label, "object:"): + if score < 0.30 { + continue + } + case strings.HasPrefix(label, "clothing:"): + if score < 0.30 { + continue + } + case strings.HasPrefix(label, "body:"): + if score < 0.30 { + continue + } + case strings.HasPrefix(label, "detector:"): + raw := strings.TrimPrefix(label, "detector:") + if !shouldAutoSelectAnalyzeHit(raw) { + continue + } + if score < 0.40 { + continue + } + } + + out = append(out, NsfwFrameResult{ + Label: label, + Score: score, + }) + } + + sort.Slice(out, func(i, j int) bool { + if out[i].Score == out[j].Score { + return out[i].Label < out[j].Label + } + return out[i].Score > out[j].Score + }) + + return out +} + +func classifyFrameForAnalyze(ctx context.Context, img image.Image) (*NsfwImageResponse, error) { + _ = ctx + + if !trainingRecognitionEnabled() { + return &NsfwImageResponse{ + Ok: true, + Results: []NsfwFrameResult{}, + }, nil + } + + tmp, err := os.CreateTemp("", "training-analyze-frame-*.jpg") + if err != nil { + return nil, err + } + + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + if err := jpeg.Encode(tmp, img, &jpeg.Options{Quality: 92}); err != nil { + _ = tmp.Close() + return nil, err + } + + if err := tmp.Close(); err != nil { + return nil, err + } + + pred := trainingPredictFrameDetectorOnly(tmpPath) + + // Wichtig: kein Fallback mehr auf altes ONNX-NSFW-Modell. + if !pred.ModelAvailable { + return &NsfwImageResponse{ + Ok: true, + Results: []NsfwFrameResult{}, + }, nil + } + + results := trainingPredictionToNSFWResults(pred) + + return &NsfwImageResponse{ + Ok: true, + Results: results, + }, nil +} + +func predictFrameForAnalyze(ctx context.Context, img image.Image) TrainingPrediction { + _ = ctx + + if !trainingRecognitionEnabled() { + return trainingEmptyPrediction("recognition_disabled") + } + + // Fallback für alte Sprite-/Image-Pfade: + // Wenn kein echter Video-Frame-Pfad vorhanden ist, schreiben wir NICHT mehr in os.TempDir(), + // sondern in generated/training/analyze-temp, damit die Bilder auffindbar bleiben. + root, err := trainingRootDir() + if err != nil { + return trainingEmptyPrediction("root_failed") + } + + tmpDir := filepath.Join(root, "analyze-temp") + if err := os.MkdirAll(tmpDir, 0755); err != nil { + return trainingEmptyPrediction("mkdir_failed") + } + + tmp, err := os.CreateTemp(tmpDir, "training-highlight-frame-*.jpg") + if err != nil { + return trainingEmptyPrediction("temp_failed") + } + + tmpPath := tmp.Name() + + // Wichtig: NICHT löschen, damit du die Bilder kontrollieren kannst. + // defer os.Remove(tmpPath) + + if err := jpeg.Encode(tmp, img, &jpeg.Options{Quality: 92}); err != nil { + _ = tmp.Close() + return trainingEmptyPrediction("encode_failed") + } + + if err := tmp.Close(); err != nil { + return trainingEmptyPrediction("close_failed") + } + + return trainingPredictFrame(tmpPath) +} + +func classifyFramePathForAnalyze(ctx context.Context, framePath string) (*NsfwImageResponse, error) { + _ = ctx + + if !trainingRecognitionEnabled() { + return &NsfwImageResponse{ + Ok: true, + Results: []NsfwFrameResult{}, + }, nil + } + + pred := trainingPredictFrameDetectorOnly(framePath) + + if !pred.ModelAvailable { + return &NsfwImageResponse{ + Ok: true, + Results: []NsfwFrameResult{}, + }, nil + } + + results := trainingPredictionToNSFWResults(pred) + + return &NsfwImageResponse{ + Ok: true, + Results: results, + }, nil +} + +func predictFramePathForAnalyze(ctx context.Context, framePath string) TrainingPrediction { + _ = ctx + + if !trainingRecognitionEnabled() { + return trainingEmptyPrediction("recognition_disabled") + } + + return trainingPredictFrame(framePath) +} + +type analyzeBatchPredictReq struct { + Paths []string `json:"paths"` + DetectorOnly bool `json:"detectorOnly"` + ImageSize int `json:"imageSize"` + Model string `json:"model,omitempty"` +} + +type analyzeBatchPredictResp struct { + OK bool `json:"ok"` + Predictions []TrainingPrediction `json:"predictions"` + Error string `json:"error,omitempty"` +} + +func analyzeAIServerURL() string { + raw := strings.TrimSpace(os.Getenv("AI_SERVER_URL")) + if raw == "" { + raw = analyzeAIServerDefaultURL + } + + return strings.TrimRight(raw, "/") +} + +func trainingPredictFramePathsBatchForAnalyze( + ctx context.Context, + paths []string, + detectorOnly bool, +) ([]TrainingPrediction, error) { + cleanPaths := make([]string, 0, len(paths)) + + for _, path := range paths { + path = strings.TrimSpace(path) + if path == "" { + continue + } + + cleanPaths = append(cleanPaths, path) + } + + if len(cleanPaths) == 0 { + return nil, appErrorf("keine frame-pfade für batch prediction") + } + + if !trainingRecognitionEnabled() { + out := make([]TrainingPrediction, 0, len(cleanPaths)) + for range cleanPaths { + out = append(out, trainingEmptyPrediction("recognition_disabled")) + } + return out, nil + } + + payload := analyzeBatchPredictReq{ + Paths: cleanPaths, + DetectorOnly: detectorOnly, + ImageSize: analyzeVideoFrameWidth, + } + + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + url := analyzeAIServerURL() + "/predict-batch" + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + url, + bytes.NewReader(body), + ) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{ + Timeout: 120 * time.Second, + } + + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + var parsed analyzeBatchPredictResp + if err := json.NewDecoder(res.Body).Decode(&parsed); err != nil { + return nil, err + } + + if res.StatusCode < 200 || res.StatusCode >= 300 || !parsed.OK { + msg := strings.TrimSpace(parsed.Error) + if msg == "" { + msg = fmt.Sprintf("AI server HTTP %d", res.StatusCode) + } + return nil, appErrorf("%s", msg) + } + + if len(parsed.Predictions) == 0 { + return nil, appErrorf("AI server lieferte keine predictions") + } + + return parsed.Predictions, nil +} + func nsfwLabelPriority(label string) int { label = strings.ToLower(strings.TrimSpace(label)) switch label { - case "female_genitalia_exposed": + case "vulva", "pussy": return 1000 - case "female_breast_exposed": + case "penis": + return 950 + + case "anus": return 900 - case "anus_exposed": + case "breasts": return 800 - case "buttocks_exposed": + case "buttocks", "ass": return 700 - case "male_genitalia_exposed": - return 600 - - case - "female_genitalia_covered", - "female_breast_covered", - "male_genitalia_covered", - "buttocks_covered", - "male_breast_exposed", - "male_breast_covered": - return 200 - - case - "belly_exposed", - "armpits_exposed", - "feet_exposed", - "feet_covered": - return 100 - - case - "face_female", - "face_male", - "belly_covered", - "armpits_covered", - "anus_covered": - return 10 - default: + if shouldAutoSelectAnalyzeHit(label) { + return 500 + } return 0 } } @@ -251,6 +773,7 @@ func extractVideoFrameAt(ctx context.Context, outPath string, atSec float64) (im "-ss", fmt.Sprintf("%.3f", atSec), "-i", outPath, "-frames:v", "1", + "-vf", fmt.Sprintf("scale=%d:-2:flags=fast_bilinear", analyzeVideoFrameWidth), "-q:v", "2", "-y", tmpPath, @@ -261,7 +784,7 @@ func extractVideoFrameAt(ctx context.Context, outPath string, atSec float64) (im } if out, err := cmd.CombinedOutput(); err != nil { - return nil, fmt.Errorf("ffmpeg fehlgeschlagen: %v: %s", err, strings.TrimSpace(string(out))) + return nil, appErrorf("ffmpeg fehlgeschlagen: %v: %s", err, strings.TrimSpace(string(out))) } f, err := os.Open(tmpPath) @@ -278,6 +801,258 @@ func extractVideoFrameAt(ctx context.Context, outPath string, atSec float64) (im return img, nil } +func analyzeFramesDirForOutput(outPath string) (string, error) { + id := strings.TrimSpace(videoIDFromOutputPath(outPath)) + if id == "" { + return "", appErrorf("konnte keine video-id aus output ableiten") + } + + metaPath, err := generatedMetaFile(id) + if err != nil || strings.TrimSpace(metaPath) == "" { + return "", appErrorf("meta.json nicht gefunden") + } + + return filepath.Join(filepath.Dir(metaPath), "frames"), nil +} + +func cleanupAnalyzeFramesDirForOutput(outPath string) error { + framesDir, err := analyzeFramesDirForOutput(outPath) + if err != nil { + return err + } + + // Sicherheitscheck: niemals versehentlich etwas anderes löschen. + if filepath.Base(framesDir) != "frames" { + return appErrorf("cleanup abgebrochen: unerwarteter frames-ordner: %s", framesDir) + } + + // Erst alle JPGs im frames-Ordner löschen. + patterns := []string{ + filepath.Join(framesDir, "*.jpg"), + filepath.Join(framesDir, "*.jpeg"), + } + + for _, pattern := range patterns { + files, globErr := filepath.Glob(pattern) + if globErr != nil { + return globErr + } + + for _, file := range files { + if removeErr := os.Remove(file); removeErr != nil && !os.IsNotExist(removeErr) { + return removeErr + } + } + } + + // Danach den Ordner selbst löschen. + // Das klappt nur, wenn er leer ist. Falls dort andere Dateien liegen, + // bleibt der Ordner absichtlich bestehen. + if err := os.Remove(framesDir); err != nil && !os.IsNotExist(err) { + return err + } + + return nil +} + +func extractVideoFramesBatch( + ctx context.Context, + outPath string, + durationSec float64, + intervalSeconds int, + onExtracted func(current int, expected int), +) ([]videoFrameSample, func(), error) { + if durationSec <= 0 { + return nil, nil, appErrorf("videolänge fehlt") + } + + if intervalSeconds <= 0 { + intervalSeconds = 1 + } + + framesDir, err := analyzeFramesDirForOutput(outPath) + if err != nil { + return nil, nil, err + } + + if err := os.MkdirAll(framesDir, 0755); err != nil { + return nil, nil, err + } + + // Alte Analyse-Frames entfernen, damit keine stale Frames mitgelesen werden. + oldFrames, _ := filepath.Glob(filepath.Join(framesDir, "analyze-frame-*.jpg")) + for _, oldFrame := range oldFrames { + _ = os.Remove(oldFrame) + } + + cleanup := func() { + if err := cleanupAnalyzeFramesDirForOutput(outPath); err != nil { + appLogln("⚠️ frames cleanup:", err) + } + } + + ffmpegPath := strings.TrimSpace(getSettings().FFmpegPath) + if ffmpegPath == "" { + ffmpegPath = "ffmpeg" + } + + pattern := filepath.Join(framesDir, "analyze-frame-%06d.jpg") + + // fps=1/ bedeutet: + // intervalSeconds=1 -> 1 Frame pro Sekunde + // intervalSeconds=2 -> 1 Frame alle 2 Sekunden + // intervalSeconds=5 -> 1 Frame alle 5 Sekunden + vf := fmt.Sprintf( + "fps=1/%d,scale=%d:-2:flags=fast_bilinear", + intervalSeconds, + analyzeVideoFrameWidth, + ) + + cmd := exec.CommandContext( + ctx, + ffmpegPath, + "-hide_banner", + "-loglevel", "error", + "-i", outPath, + "-vf", vf, + "-q:v", "4", + "-fps_mode", "vfr", + "-y", + pattern, + ) + + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, + } + + expectedFrames := int(math.Ceil(durationSec / float64(intervalSeconds))) + if expectedFrames < 1 { + expectedFrames = 1 + } + + emitExtractProgress := func(current int) { + if onExtracted == nil { + return + } + + if current < 0 { + current = 0 + } + if current > expectedFrames { + current = expectedFrames + } + + onExtracted(current, expectedFrames) + } + + countExtractedFrames := func() int { + files, err := filepath.Glob(filepath.Join(framesDir, "analyze-frame-*.jpg")) + if err != nil { + return 0 + } + return len(files) + } + + emitExtractProgress(0) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + return nil, nil, appErrorf( + "ffmpeg frames extrahieren konnte nicht gestartet werden: %w", + err, + ) + } + + done := make(chan error, 1) + go func() { + done <- cmd.Wait() + }() + + lastCount := -1 + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + + waiting := true + for waiting { + select { + case <-ctx.Done(): + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + + select { + case <-done: + case <-time.After(2 * time.Second): + } + + return nil, nil, ctx.Err() + + case waitErr := <-done: + waiting = false + + count := countExtractedFrames() + if count != lastCount { + lastCount = count + emitExtractProgress(count) + } + + if waitErr != nil { + return nil, nil, appErrorf( + "ffmpeg frames extrahieren fehlgeschlagen: %v: %s", + waitErr, + strings.TrimSpace(stderr.String()), + ) + } + + case <-ticker.C: + count := countExtractedFrames() + if count != lastCount { + lastCount = count + emitExtractProgress(count) + } + } + } + + files, err := filepath.Glob(filepath.Join(framesDir, "analyze-frame-*.jpg")) + if err != nil { + return nil, nil, err + } + + sort.Strings(files) + + if len(files) == 0 { + return nil, nil, appErrorf("ffmpeg hat keine frames erzeugt") + } + + // Nach ffmpeg-Ende noch einmal den echten Endstand melden. + // Das sorgt dafür, dass die Extraktionshälfte zuverlässig bei 50% landen kann. + emitExtractProgress(len(files)) + + out := make([]videoFrameSample, 0, len(files)) + + for i, path := range files { + t := float64(i * intervalSeconds) + + if t < 0 { + t = 0 + } + if durationSec > 0 && t > durationSec { + t = durationSec + } + + out = append(out, videoFrameSample{ + Index: i, + Time: t, + Path: path, + }) + } + + return out, cleanup, nil +} + func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { if !mustMethod(w, r, http.MethodPost) { return @@ -289,22 +1064,15 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { return } - req.Mode = strings.ToLower(strings.TrimSpace(req.Mode)) + req.Mode = "video" req.Goal = strings.ToLower(strings.TrimSpace(req.Goal)) - if req.Mode == "" { - req.Mode = "sprite" - } if req.Goal == "" { req.Goal = "highlights" } - switch req.Mode { - case "sprite", "video": - default: - http.Error(w, "mode muss 'sprite' oder 'video' sein", http.StatusBadRequest) - return - } + // Sprite-Modus ist deaktiviert, weil kein predict_sprite_batch.py vorhanden ist. + // Analyse läuft immer über den Video-Frame-Batch-Pfad. switch req.Goal { case "highlights", "nsfw": @@ -325,17 +1093,10 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { return } - ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute) defer cancel() - var hits []analyzeHit - - switch req.Mode { - case "sprite": - hits, err = analyzeVideoFromSprite(ctx, outPath, req.Goal) - case "video": - hits, err = analyzeVideoFromFrames(ctx, outPath, req.Goal) - } + hits, err := analyzeVideoFromFrames(ctx, outPath, req.Goal) if err != nil { respondJSON(w, analyzeVideoResp{ @@ -349,10 +1110,10 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { } durationSec, _ := durationSecondsForAnalyze(ctx, outPath) - segments := buildSegmentsFromAnalyzeHits(hits, durationSec) + segments := buildAnalyzeSegmentsForGoal(hits, durationSec, req.Goal) var rating *aiRatingMeta - if req.Goal == "nsfw" { + if req.Goal == "nsfw" || req.Goal == "highlights" { rating = computeNSFWRating(segments, durationSec) } @@ -366,7 +1127,7 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { } if err := writeVideoAIForFile(ctx, outPath, "", ai); err != nil { - fmt.Println("⚠️ writeVideoAIForFile:", err) + appLogln("⚠️ writeVideoAIForFile:", err) } respondJSON(w, analyzeVideoResp{ @@ -379,136 +1140,950 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { }) } -func analyzeVideoFromSprite(ctx context.Context, outPath, goal string) ([]analyzeHit, error) { +func analyzeVideoFromSpriteAllGoals(ctx context.Context, outPath string) (nsfwHits []analyzeHit, highlightHits []analyzeHit, err error) { id := strings.TrimSpace(videoIDFromOutputPath(outPath)) if id == "" { - return nil, fmt.Errorf("konnte keine video-id aus output ableiten") + return nil, nil, appErrorf("konnte keine video-id aus output ableiten") } metaPath, err := generatedMetaFile(id) if err != nil || strings.TrimSpace(metaPath) == "" { - return nil, fmt.Errorf("meta.json nicht gefunden") + return nil, nil, appErrorf("meta.json nicht gefunden") } ps, ok := readPreviewSpriteMetaFromMetaFile(metaPath) if !ok { - return nil, fmt.Errorf("previewSprite meta fehlt") + return nil, nil, appErrorf("previewSprite meta fehlt") } if ps.Count <= 0 { - return nil, fmt.Errorf("previewSprite count fehlt") + return nil, nil, appErrorf("previewSprite count fehlt") } spritePath := filepath.Join(filepath.Dir(metaPath), "preview-sprite.jpg") if fi, err := os.Stat(spritePath); err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { - return nil, fmt.Errorf("preview-sprite.jpg nicht gefunden") + return nil, nil, appErrorf("preview-sprite.jpg nicht gefunden") } - durationSec, _ := durationSecondsForAnalyze(ctx, outPath) + durationSec := ps.StepSeconds * math.Max(1, float64(ps.Count-1)) + if durationSec <= 0 { + durationSec, _ = durationSecondsForAnalyze(ctx, outPath) + } candidates := buildSpriteFrameCandidates(ps.Count, ps.StepSeconds, durationSec) + candidates = limitSpriteFrameCandidates(candidates, analyzeMaxSpriteCandidates) + if len(candidates) == 0 { - return nil, fmt.Errorf("keine sprite-kandidaten vorhanden") + return nil, nil, appErrorf("keine sprite-kandidaten vorhanden") } - // ---------------------------------------------------------------- - // HIER ist der Hook für echte AI/Vision-Analyse. - // - // Aktuell: - // - erzeugen wir brauchbare Zeitpunkte aus den Preview-Frames - // - gruppieren sie zu Treffern - // - // Später kannst du hier: - // - spritePath + frame indices an ein Vision-Modell geben - // - pro Frame Labels / Scores zurückbekommen - // - daraus Trefferbereiche bilden - // ---------------------------------------------------------------- - frameHits, err := analyzeSpriteCandidatesWithAI(ctx, spritePath, ps, candidates, goal) + // 1) Schneller Pfad: Python-Batch. + results, batchErr := trainingPredictSpriteBatch(ctx, spritePath, ps, candidates) + if batchErr == nil { + for _, item := range results { + pred := item.Prediction + if !pred.ModelAvailable { + continue + } + + t := item.Time + + nsfwResults := trainingPredictionToNSFWResults(pred) + bestLabel, bestScore := pickBestNSFWResult(nsfwResults) + if bestLabel != "" && bestScore >= nsfwThresholdForLabel(bestLabel) { + nsfwHits = append(nsfwHits, analyzeHit{ + Time: t, + Label: bestLabel, + Score: bestScore, + Start: t, + End: t, + }) + } + + highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, t) + } + + return mergeAnalyzeHits(nsfwHits), mergeAnalyzeHits(highlightHits), nil + } + + // 2) Fallback: alte langsame Methode, damit Analyse nicht komplett fehlschlägt. + appLogln("⚠️ sprite batch analyse fehlgeschlagen, fallback auf langsame Analyse:", batchErr) + + frames, err := extractSpriteFrames(spritePath, ps) if err != nil { - return nil, err + return nil, nil, appErrorf("sprite frames extrahieren fehlgeschlagen: %w", err) } - return mergeAnalyzeHits(frameHits), nil + for _, c := range candidates { + if c.Index < 0 || c.Index >= len(frames) { + continue + } + + pred := predictFrameForAnalyze(ctx, frames[c.Index]) + if !pred.ModelAvailable { + continue + } + + t := c.Time + + nsfwResults := trainingPredictionToNSFWResults(pred) + bestLabel, bestScore := pickBestNSFWResult(nsfwResults) + if bestLabel != "" && bestScore >= nsfwThresholdForLabel(bestLabel) { + nsfwHits = append(nsfwHits, analyzeHit{ + Time: t, + Label: bestLabel, + Score: bestScore, + Start: t, + End: t, + }) + } + + highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, t) + } + + return mergeAnalyzeHits(nsfwHits), mergeAnalyzeHits(highlightHits), nil } func nsfwThresholdForLabel(label string) float64 { label = strings.ToLower(strings.TrimSpace(label)) switch label { - case - "anus_exposed", - "female_genitalia_exposed", - "male_genitalia_exposed", - "female_breast_exposed", - "buttocks_exposed": + case "vulva", "penis", "anus": return nsfwThresholdStrong - case - "female_breast_covered", - "male_breast_exposed", - "male_breast_covered", - "buttocks_covered", - "female_genitalia_covered", - "male_genitalia_covered", - "belly_exposed", - "armpits_exposed", - "feet_exposed", - "feet_covered": + case "pussy", "breasts", "buttocks", "ass": return nsfwThresholdModerate default: + if shouldAutoSelectAnalyzeHit(label) { + return 0.40 + } return 0.50 } } +func appendNSFWHitFromPrediction( + hits []analyzeHit, + pred TrainingPrediction, + t float64, +) []analyzeHit { + if !pred.ModelAvailable { + return hits + } + + nsfwResults := trainingPredictionToNSFWResults(pred) + bestLabel, bestScore := pickBestNSFWResult(nsfwResults) + if bestLabel == "" { + return hits + } + + if bestScore < nsfwThresholdForLabel(bestLabel) { + return hits + } + + return append(hits, analyzeHit{ + Time: t, + Label: bestLabel, + Score: bestScore, + Start: t, + End: t, + }) +} + +type highlightSignal struct { + Label string + Score float64 + Group string +} + +func normalizeHighlightSignalLabel(label string) string { + label = strings.ToLower(strings.TrimSpace(label)) + if label == "" || label == "unknown" { + return "" + } + + switch { + case strings.HasPrefix(label, "combo:"): + // Bestehende alte Combos hier nicht weiterverwenden, + // weil wir ab jetzt selbst saubere Kombis bauen. + return "" + + case strings.HasPrefix(label, "detector:"): + raw := strings.TrimPrefix(label, "detector:") + if !shouldAutoSelectAnalyzeHit(raw) { + return "" + } + return "detector:" + raw + + case strings.HasPrefix(label, "body:"): + raw := strings.TrimPrefix(label, "body:") + if raw == "" || raw == "unknown" { + return "" + } + return "body:" + raw + + case strings.HasPrefix(label, "object:"): + raw := strings.TrimPrefix(label, "object:") + if raw == "" || raw == "unknown" { + return "" + } + return "object:" + raw + + case strings.HasPrefix(label, "clothing:"): + raw := strings.TrimPrefix(label, "clothing:") + if raw == "" || raw == "unknown" { + return "" + } + return "clothing:" + raw + + case strings.HasPrefix(label, "position:"): + raw := strings.TrimPrefix(label, "position:") + if raw == "" || raw == "unknown" || !isKnownPositionLabel(raw) { + return "" + } + return "position:" + raw + + default: + if isIgnoredNSFWLabel(label) { + return "" + } + + if isKnownPositionLabel(label) { + return "position:" + label + } + + if shouldAutoSelectAnalyzeHit(label) { + return "detector:" + label + } + + return "" + } +} + +func highlightSignalGroup(label string) string { + label = strings.ToLower(strings.TrimSpace(label)) + + switch { + case strings.HasPrefix(label, "position:"): + return "position" + case strings.HasPrefix(label, "body:"): + return "body" + case strings.HasPrefix(label, "object:"): + return "object" + case strings.HasPrefix(label, "clothing:"): + return "clothing" + case strings.HasPrefix(label, "detector:"): + raw := strings.TrimPrefix(label, "detector:") + switch { + case bodyPartSeverityWeight(raw) >= 0.65: + return "body" + case objectSeverityWeight(raw) >= 0.55: + return "object" + case clothingSeverityWeight(raw) >= 0.50: + return "clothing" + default: + return "detector" + } + default: + return "other" + } +} + +func highlightSignalInterestingEnough(label string, score float64) bool { + label = normalizeHighlightSignalLabel(label) + if label == "" { + return false + } + + if score <= 0 { + score = 1 + } + + switch { + case strings.HasPrefix(label, "position:"): + // Position alleine ist nicht interessant genug, aber als Kombi-Kontext okay. + return score >= 0.35 + + case strings.HasPrefix(label, "body:"): + return score >= 0.35 && segmentSeverityWeight(label) >= 0.65 + + case strings.HasPrefix(label, "object:"): + return score >= 0.35 && segmentSeverityWeight(label) >= 0.50 + + case strings.HasPrefix(label, "clothing:"): + // Kleidung nur anzeigen, wenn sie als Kombi-Kontext dient. + return score >= 0.45 && segmentSeverityWeight(label) >= 0.50 + + case strings.HasPrefix(label, "detector:"): + return score >= 0.45 && segmentSeverityWeight(label) >= 0.60 + + default: + return false + } +} + +func addHighlightSignal(best map[string]highlightSignal, label string, score float64) { + label = normalizeHighlightSignalLabel(label) + if label == "" { + return + } + + if !highlightSignalInterestingEnough(label, score) { + return + } + + if score <= 0 { + score = 1 + } + + key := normalizeSegmentLabel(label) + if key == "" { + return + } + + sig := highlightSignal{ + Label: label, + Score: score, + Group: highlightSignalGroup(label), + } + + if old, ok := best[key]; !ok || sig.Score > old.Score { + best[key] = sig + } +} + +func addHighlightSignalsFromScoredLabels( + best map[string]highlightSignal, + prefix string, + items []TrainingScoredLabel, +) { + prefix = strings.ToLower(strings.TrimSpace(prefix)) + if prefix == "" { + return + } + + for _, item := range items { + label := strings.ToLower(strings.TrimSpace(item.Label)) + if label == "" || label == "unknown" { + continue + } + + addHighlightSignal(best, prefix+":"+label, item.Score) + } +} + +func highlightComboPartOrder(label string) int { + label = strings.ToLower(strings.TrimSpace(label)) + + switch { + case strings.HasPrefix(label, "position:"): + return 0 + case strings.HasPrefix(label, "body:"): + return 1 + case strings.HasPrefix(label, "object:"): + return 2 + case strings.HasPrefix(label, "clothing:"): + return 3 + case strings.HasPrefix(label, "detector:"): + return 4 + default: + return 9 + } +} + +func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64) (analyzeHit, bool) { + if !pred.ModelAvailable { + return analyzeHit{}, false + } + + best := map[string]highlightSignal{} + + sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition)) + if sexPosition != "" && sexPosition != "unknown" { + addHighlightSignal(best, "position:"+sexPosition, pred.SexPositionScore) + } + + addHighlightSignalsFromScoredLabels(best, "body", pred.BodyPartsPresent) + addHighlightSignalsFromScoredLabels(best, "object", pred.ObjectsPresent) + addHighlightSignalsFromScoredLabels(best, "clothing", pred.ClothingPresent) + + for _, box := range pred.Boxes { + label := strings.ToLower(strings.TrimSpace(box.Label)) + if label == "" || label == "unknown" { + continue + } + if isIgnoredNSFWLabel(label) { + continue + } + if !shouldAutoSelectAnalyzeHit(label) { + continue + } + + addHighlightSignal(best, "detector:"+label, box.Score) + } + + if len(best) < 2 { + return analyzeHit{}, false + } + + signals := make([]highlightSignal, 0, len(best)) + groupSeen := map[string]bool{} + nonPositionCount := 0 + hasPosition := false + + for _, sig := range best { + if sig.Label == "" { + continue + } + + if sig.Group == "position" { + hasPosition = true + } else { + nonPositionCount++ + } + + groupSeen[sig.Group] = true + signals = append(signals, sig) + } + + // Nur echte interessante Kombis: + // - Position + mindestens ein weiteres Signal + // - oder mindestens zwei Nicht-Positions-Signale + // - oder mindestens zwei unterschiedliche Signalgruppen + if len(signals) < 2 { + return analyzeHit{}, false + } + if hasPosition && nonPositionCount < 1 { + return analyzeHit{}, false + } + if !hasPosition && nonPositionCount < 2 { + return analyzeHit{}, false + } + if len(groupSeen) < 2 && len(signals) < 3 { + return analyzeHit{}, false + } + + sort.SliceStable(signals, func(i, j int) bool { + oi := highlightComboPartOrder(signals[i].Label) + oj := highlightComboPartOrder(signals[j].Label) + if oi != oj { + return oi < oj + } + + wi := segmentSeverityWeight(signals[i].Label) * signals[i].Score + wj := segmentSeverityWeight(signals[j].Label) * signals[j].Score + if wi != wj { + return wi > wj + } + + return signals[i].Label < signals[j].Label + }) + + // Nicht zu lange Titel bauen. + if len(signals) > 4 { + signals = signals[:4] + } + + parts := make([]string, 0, len(signals)) + var scoreSum float64 + var scoreWeightSum float64 + var maxWeighted float64 + + for _, sig := range signals { + parts = append(parts, sig.Label) + + sev := segmentSeverityWeight(sig.Label) + if sev <= 0 { + sev = 0.5 + } + + weighted := sig.Score * sev + scoreSum += sig.Score * sev + scoreWeightSum += sev + + if weighted > maxWeighted { + maxWeighted = weighted + } + } + + if len(parts) < 2 || scoreWeightSum <= 0 { + return analyzeHit{}, false + } + + avgScore := scoreSum / scoreWeightSum + + // Kombi-Score: stärkstes Signal + Durchschnitt. + score := 0.65*maxWeighted + 0.35*avgScore + if score > 1 { + score = 1 + } + if score <= 0 { + score = avgScore + } + + // Noch einmal Mindestqualität prüfen. + if score < 0.42 { + return analyzeHit{}, false + } + + return analyzeHit{ + Time: t, + Label: "combo:" + strings.Join(parts, "+"), + Score: score, + Start: t, + End: t, + }, true +} + +func appendHighlightHitsFromPrediction( + hits []analyzeHit, + pred TrainingPrediction, + t float64, +) []analyzeHit { + hit, ok := buildCombinedHighlightHitFromPrediction(pred, t) + if !ok { + return hits + } + + return append(hits, hit) +} + func analyzeVideoFromFrames(ctx context.Context, outPath, goal string) ([]analyzeHit, error) { - if goal != "nsfw" { + goal = strings.ToLower(strings.TrimSpace(goal)) + + nsfwHits, highlightHits, err := analyzeVideoFromFramesForGoal(ctx, outPath, goal) + if err != nil { + return nil, err + } + + switch goal { + case "nsfw": + return nsfwHits, nil + case "highlights": + return highlightHits, nil + default: return []analyzeHit{}, nil } +} + +const analyzeProgressTotal = 1000 + +func publishAnalyzeExtractProgress( + startedAtMs int64, + file string, + 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( + startedAtMs, + current, + analyzeProgressTotal, + file, + message, + ) +} + +func analyzePercentMessage(currentFrame int, totalFrames int) string { + if totalFrames <= 0 { + totalFrames = 1 + } + + ratio := float64(currentFrame) / float64(totalFrames) + ratio = math.Max(0, math.Min(1, ratio)) + + percent := int(math.Round(ratio * 100)) + if percent < 0 { + percent = 0 + } + if percent > 100 { + percent = 100 + } + + return fmt.Sprintf("Analyse %d%%", percent) +} + +func publishAnalyzeInferenceProgress( + startedAtMs int64, + file string, + currentFrame int, + totalFrames int, + message string, +) { + if totalFrames <= 0 { + totalFrames = 1 + } + + 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( + startedAtMs, + current, + analyzeProgressTotal, + file, + message, + ) +} + +func analyzeGlobalPercentFromCurrent(current int, total int) int { + if total <= 0 { + total = analyzeProgressTotal + } + + ratio := float64(current) / float64(total) + ratio = math.Max(0, math.Min(1, ratio)) + + percent := int(math.Round(ratio * 100)) + if percent < 0 { + return 0 + } + if percent > 100 { + return 100 + } + return percent +} + +func analyzeGlobalPercentMessageFromCurrent(current int, total int) string { + return fmt.Sprintf( + "Analyse %d%%", + analyzeGlobalPercentFromCurrent(current, total), + ) +} + +func analyzeVideoFromFramesForGoal( + ctx context.Context, + outPath string, + goal string, +) (nsfwHits []analyzeHit, highlightHits []analyzeHit, err error) { + goal = strings.ToLower(strings.TrimSpace(goal)) + if goal == "" { + goal = "all" + } + + file := filepath.Base(strings.TrimSpace(outPath)) + startedAtMs := publishAnalysisStarted(file, analyzeProgressTotal, "Analyse 0%") + + publishAnalyzeExtractProgress( + startedAtMs, + file, + 0, + "Analyse 0%", + ) durationSec, _ := durationSecondsForAnalyze(ctx, outPath) if durationSec <= 0 { - return nil, fmt.Errorf("videolänge konnte nicht bestimmt werden") + err := appErrorf("videolänge konnte nicht bestimmt werden") + publishAnalysisError(startedAtMs, file, "Analyse fehlgeschlagen", err) + return nil, nil, err } - sampleTimes := buildVideoSampleTimes(durationSec, 24) - if len(sampleTimes) == 0 { - return nil, fmt.Errorf("keine frame-samples berechnet") + // Hilfsfunktion: garantiert, dass keine Prozentpunkte übersprungen werden. + // Beispiel: letzter Stand 12, neuer Stand 17 -> sendet 13,14,15,16,17. + publishPercentRange := func(lastPercent *int, nextPercent int, current int, total int, extractPhase bool) { + if total <= 0 { + total = 1 + } + + if nextPercent < 0 { + nextPercent = 0 + } + if nextPercent > 100 { + nextPercent = 100 + } + + if nextPercent <= *lastPercent { + return + } + + for p := *lastPercent + 1; p <= nextPercent; p++ { + label := fmt.Sprintf("Analyse %d%%", p) + + if extractPhase { + // p läuft global 0..50, publishAnalyzeExtractProgress erwartet aber 0..1. + ratio := float64(p) / 50.0 + if ratio < 0 { + ratio = 0 + } + if ratio > 1 { + ratio = 1 + } + + publishAnalyzeExtractProgress( + startedAtMs, + file, + ratio, + label, + ) + } else { + // Für die Inferenzphase current/total so wählen, dass der globale + // Fortschritt in publishAnalyzeInferenceProgress wieder exakt p ergibt: + // current/total = (p - 50) / 50 + inferenceCurrent := current + inferenceTotal := total + + if p >= 50 { + inferenceTotal = 50 + inferenceCurrent = p - 50 + } + + publishAnalyzeInferenceProgress( + startedAtMs, + file, + inferenceCurrent, + inferenceTotal, + label, + ) + } + } + + *lastPercent = nextPercent } - hits := make([]analyzeHit, 0, len(sampleTimes)) + lastExtractPercent := 0 - for _, t := range sampleTimes { - img, err := extractVideoFrameAt(ctx, outPath, t) - if err != nil { - return nil, fmt.Errorf("frame extraktion bei %.3fs fehlgeschlagen: %w", t, err) - } + samples, cleanup, err := extractVideoFramesBatch( + ctx, + outPath, + durationSec, + analyzeVideoFrameIntervalSeconds, + func(current int, expected int) { + if expected <= 0 { + expected = 1 + } - res, err := classifyFrameNSFW(ctx, img) - if err != nil { - continue - } + if current < 0 { + current = 0 + } + if current > expected { + current = expected + } - bestLabel, bestScore := pickBestNSFWResult(res.Results) - if bestLabel == "" { - continue - } + ratio := float64(current) / float64(expected) + ratio = math.Max(0, math.Min(1, ratio)) - threshold := nsfwThresholdForLabel(bestLabel) + globalPercent := int(math.Round(ratio * 50)) + publishPercentRange( + &lastExtractPercent, + globalPercent, + current, + expected, + true, + ) + }, + ) - if bestScore < threshold { - continue - } - - hits = append(hits, analyzeHit{ - Time: t, - Label: bestLabel, - Score: bestScore, - Start: t, - End: t, - }) + if err != nil { + publishAnalysisError(startedAtMs, file, "Frames konnten nicht extrahiert werden", err) + return nil, nil, err } - return mergeAnalyzeHits(hits), nil + if len(samples) == 0 { + err := appErrorf("keine frame-samples vorhanden") + publishAnalysisError(startedAtMs, file, "Keine Frames vorhanden", err) + return nil, nil, err + } + + total := len(samples) + + if lastExtractPercent < 50 { + publishPercentRange( + &lastExtractPercent, + 50, + total, + total, + true, + ) + } + + paths := make([]string, 0, len(samples)) + for _, sample := range samples { + paths = append(paths, sample.Path) + } + + lastInferencePercent := 50 + + publishAnalyzeInferenceProgress( + startedAtMs, + file, + 0, + total, + "Analyse 50%", + ) + + // Schneller AI-Server-Batch-Pfad für nsfw, highlights und all. + // Wichtig: ensureAnalyzeAllGoalsForVideoCtx ruft goal="all" auf. + // Ohne diesen Block fällt "all" auf die sehr langsame Einzelbild-Analyse zurück. + if goal == "nsfw" || goal == "highlights" || goal == "all" { + batchOK := true + + // Für nsfw könnte detectorOnly=true reichen. + // Dein ai_server.py liefert aber ohnehin alle Felder aus YOLO-Resultaten, + // daher ist false für alle Goals okay und vermeidet Sonderlogik. + detectorOnly := false + + for startIdx := 0; startIdx < len(samples); startIdx += analyzeFramePredictBatchSize { + endIdx := startIdx + analyzeFramePredictBatchSize + if endIdx > len(samples) { + endIdx = len(samples) + } + + predictions, batchErr := trainingPredictFramePathsBatchForAnalyze( + ctx, + paths[startIdx:endIdx], + detectorOnly, + ) + + if batchErr != nil || len(predictions) < endIdx-startIdx { + appLogln("⚠️ video batch analyse fehlgeschlagen, fallback auf einzelbild-analyse:", batchErr) + + batchOK = false + nsfwHits = nil + highlightHits = nil + lastInferencePercent = 50 + + publishAnalyzeInferenceProgress( + startedAtMs, + file, + 0, + total, + "Analyse 50%", + ) + + break + } + + for i := 0; i < endIdx-startIdx; i++ { + sample := samples[startIdx+i] + pred := predictions[i] + + switch goal { + case "nsfw": + nsfwHits = appendNSFWHitFromPrediction(nsfwHits, pred, sample.Time) + + case "highlights": + highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, sample.Time) + + default: + nsfwHits = appendNSFWHitFromPrediction(nsfwHits, pred, sample.Time) + highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, sample.Time) + } + } + + globalPercent := 50 + int(math.Round((float64(endIdx)/float64(total))*50)) + if globalPercent > 100 { + globalPercent = 100 + } + + publishPercentRange( + &lastInferencePercent, + globalPercent, + endIdx, + total, + false, + ) + } + + if batchOK { + if lastInferencePercent < 100 { + publishPercentRange( + &lastInferencePercent, + 100, + total, + total, + false, + ) + } + + cleanNSFWHits := mergeAnalyzeHits(nsfwHits) + cleanHighlightHits := mergeAnalyzeHits(highlightHits) + + cleanup() + + publishAnalysisFinished(startedAtMs, total, file, "Analyse abgeschlossen") + return cleanNSFWHits, cleanHighlightHits, nil + } + } + + // Fallback: langsame Einzelbild-Analyse. + // Dieser Pfad sollte nur laufen, wenn der AI-Server-Batch fehlschlägt. + for i, sample := range samples { + t := sample.Time + + switch goal { + case "nsfw": + res, err := classifyFramePathForAnalyze(ctx, sample.Path) + if err == nil { + bestLabel, bestScore := pickBestNSFWResult(res.Results) + if bestLabel != "" && bestScore >= nsfwThresholdForLabel(bestLabel) { + nsfwHits = append(nsfwHits, analyzeHit{ + Time: t, + Label: bestLabel, + Score: bestScore, + Start: t, + End: t, + }) + } + } + + case "highlights": + pred := predictFramePathForAnalyze(ctx, sample.Path) + highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, t) + + default: + pred := predictFramePathForAnalyze(ctx, sample.Path) + nsfwHits = appendNSFWHitFromPrediction(nsfwHits, pred, t) + highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, t) + } + + current := i + 1 + + globalPercent := 50 + int(math.Round((float64(current)/float64(total))*50)) + if globalPercent > 100 { + globalPercent = 100 + } + + publishPercentRange( + &lastInferencePercent, + globalPercent, + current, + total, + false, + ) + } + + if lastInferencePercent < 100 { + publishPercentRange( + &lastInferencePercent, + 100, + total, + total, + false, + ) + } + + cleanNSFWHits := mergeAnalyzeHits(nsfwHits) + cleanHighlightHits := mergeAnalyzeHits(highlightHits) + + cleanup() + + publishAnalysisFinished(startedAtMs, total, file, "Analyse abgeschlossen") + + return cleanNSFWHits, cleanHighlightHits, nil } func analyzeSpriteCandidatesWithAI( @@ -518,13 +2093,9 @@ func analyzeSpriteCandidatesWithAI( candidates []spriteFrameCandidate, goal string, ) ([]analyzeHit, error) { - if goal != "nsfw" { - return []analyzeHit{}, nil - } - frames, err := extractSpriteFrames(spritePath, ps) if err != nil { - return nil, fmt.Errorf("sprite frames extrahieren fehlgeschlagen: %w", err) + return nil, appErrorf("sprite frames extrahieren fehlgeschlagen: %w", err) } hits := make([]analyzeHit, 0, len(candidates)) @@ -534,34 +2105,154 @@ func analyzeSpriteCandidatesWithAI( continue } - res, err := classifyFrameNSFW(ctx, frames[c.Index]) - if err != nil { - continue + img := frames[c.Index] + + switch goal { + case "nsfw": + res, err := classifyFrameForAnalyze(ctx, img) + if err != nil { + continue + } + + bestLabel, bestScore := pickBestNSFWResult(res.Results) + if bestLabel == "" { + continue + } + + threshold := nsfwThresholdForLabel(bestLabel) + if bestScore < threshold { + continue + } + + hits = append(hits, analyzeHit{ + Time: c.Time, + Label: bestLabel, + Score: bestScore, + Start: c.Time, + End: c.Time, + }) + + case "highlights": + pred := predictFrameForAnalyze(ctx, img) + if !pred.ModelAvailable { + continue + } + + hits = appendHighlightHitsFromPrediction(hits, pred, c.Time) } - - bestLabel, bestScore := pickBestNSFWResult(res.Results) - if bestLabel == "" { - continue - } - - threshold := nsfwThresholdForLabel(bestLabel) - - if bestScore < threshold { - continue - } - - hits = append(hits, analyzeHit{ - Time: c.Time, - Label: bestLabel, - Score: bestScore, - Start: c.Time, - End: c.Time, - }) } return hits, nil } +func sameAnalyzeComboLabel(a, b string) bool { + a = strings.ToLower(strings.TrimSpace(a)) + b = strings.ToLower(strings.TrimSpace(b)) + + if !strings.HasPrefix(a, "combo:") || !strings.HasPrefix(b, "combo:") { + return false + } + + parse := func(label string) (position string, parts map[string]bool) { + raw := strings.TrimPrefix(label, "combo:") + parts = map[string]bool{} + + for _, part := range strings.Split(raw, "+") { + part = strings.ToLower(strings.TrimSpace(part)) + if part == "" { + continue + } + + if strings.HasPrefix(part, "position:") { + position = strings.TrimPrefix(part, "position:") + continue + } + + normalized := normalizeSegmentLabel(part) + if normalized != "" { + parts[normalized] = true + } + } + + return position, parts + } + + posA, partsA := parse(a) + posB, partsB := parse(b) + + // Unterschiedliche klare Hauptpositionen nicht zusammenführen. + // Beispiel: doggy != missionary + if posA != "" && posB != "" && posA != posB { + return false + } + + // Wenn beide keine gemeinsame Kontext-Komponente haben, nicht mergen. + // Beispiel: + // combo:position:doggy+object:dildo + // combo:position:doggy+clothing:lingerie + // => kein gemeinsames Nicht-Positionssignal, also getrennt lassen. + for part := range partsA { + if partsB[part] { + return true + } + } + + return false +} + +func sameAnalyzeSegmentLabel(a, b string) bool { + a = strings.ToLower(strings.TrimSpace(a)) + b = strings.ToLower(strings.TrimSpace(b)) + + if strings.HasPrefix(a, "combo:") || strings.HasPrefix(b, "combo:") { + return sameAnalyzeComboLabel(a, b) + } + + return normalizeSegmentLabel(a) == normalizeSegmentLabel(b) +} + +func preferAnalyzeSegmentLabel(a, b string) string { + a = strings.ToLower(strings.TrimSpace(a)) + b = strings.ToLower(strings.TrimSpace(b)) + + if a == "" { + return b + } + if b == "" { + return a + } + + // body: ist meist semantisch sauberer als detector: + if strings.HasPrefix(a, "body:") && !strings.HasPrefix(b, "body:") { + return a + } + if strings.HasPrefix(b, "body:") && !strings.HasPrefix(a, "body:") { + return b + } + + // object:/clothing:/position: ebenfalls sauberer als detector: + preferredPrefix := func(s string) bool { + return strings.HasPrefix(s, "object:") || + strings.HasPrefix(s, "clothing:") || + strings.HasPrefix(s, "position:") || + strings.HasPrefix(s, "combo:") + } + + if preferredPrefix(a) && strings.HasPrefix(b, "detector:") { + return a + } + if preferredPrefix(b) && strings.HasPrefix(a, "detector:") { + return b + } + + // Sonst kürzeres Label behalten, z. B. breasts statt detector:breasts. + if len(b) < len(a) { + return b + } + + return a +} + func mergeAnalyzeHits(in []analyzeHit) []analyzeHit { if len(in) == 0 { return []analyzeHit{} @@ -620,10 +2311,11 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit { // Direkt aufeinanderfolgende Treffer mit gleichem Label immer zusammenfassen. // Sobald ein anderes Label dazwischen liegt, wird automatisch nicht gemergt. - sameLabel := strings.EqualFold(cur.Label, n.Label) + sameLabel := sameAnalyzeSegmentLabel(cur.Label, n.Label) gap := n.Start - cur.End - if sameLabel && gap <= analyzeSegmentMergeGapSeconds { + if sameLabel && gap >= -0.25 && gap <= analyzeSegmentMergeGapSeconds { + cur.Label = preferAnalyzeSegmentLabel(cur.Label, n.Label) if n.Start < cur.Start { cur.Start = n.Start } @@ -844,7 +2536,8 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme gap = 0 } - if strings.EqualFold(cur.Label, n.Label) && gap <= analyzeSegmentMergeGapSeconds { + if sameAnalyzeSegmentLabel(cur.Label, n.Label) && gap <= analyzeSegmentMergeGapSeconds { + cur.Label = preferAnalyzeSegmentLabel(cur.Label, n.Label) if n.StartSeconds < cur.StartSeconds { cur.StartSeconds = n.StartSeconds } @@ -864,7 +2557,139 @@ func buildSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegme } merged = append(merged, cur) - return merged + return mergeAdjacentAISegments(merged, analyzeSegmentMergeGapSeconds) +} + +func buildHighlightSegmentsFromAnalyzeHits(hits []analyzeHit, duration float64) []aiSegmentMeta { + if len(hits) == 0 || duration <= 0 { + return []aiSegmentMeta{} + } + + pointSpan := inferAnalyzePointSpanSeconds(hits, duration) + + out := make([]aiSegmentMeta, 0, len(hits)) + + for _, hit := range hits { + label := strings.ToLower(strings.TrimSpace(hit.Label)) + if label == "" || label == "unknown" { + continue + } + + if isIgnoredNSFWLabel(label) { + continue + } + + start := hit.Start + end := hit.End + + if start < 0 && end < 0 { + start = hit.Time + end = hit.Time + } else { + if start < 0 { + start = hit.Time + } + if end < 0 { + end = hit.Time + } + } + + if start > end { + start, end = end, start + } + + start = math.Max(0, math.Min(start, duration)) + end = math.Max(0, math.Min(end, duration)) + + if end <= start { + marker := hit.Time + if marker < 0 { + marker = start + } + + start, end = expandAnalyzePointToSpan(marker, pointSpan, duration) + } + + if end <= start { + continue + } + + out = append(out, aiSegmentMeta{ + Label: label, + StartSeconds: start, + EndSeconds: end, + DurationSeconds: end - start, + Score: hit.Score, + AutoSelected: true, + }) + } + + if len(out) == 0 { + return []aiSegmentMeta{} + } + + sort.Slice(out, func(i, j int) bool { + if out[i].StartSeconds != out[j].StartSeconds { + return out[i].StartSeconds < out[j].StartSeconds + } + if out[i].EndSeconds != out[j].EndSeconds { + return out[i].EndSeconds < out[j].EndSeconds + } + return out[i].Label < out[j].Label + }) + + merged := make([]aiSegmentMeta, 0, len(out)) + cur := out[0] + + for i := 1; i < len(out); i++ { + n := out[i] + + gap := n.StartSeconds - cur.EndSeconds + if gap < 0 { + gap = 0 + } + + if sameAnalyzeSegmentLabel(cur.Label, n.Label) && gap <= analyzeSegmentMergeGapSeconds { + cur.Label = preferAnalyzeSegmentLabel(cur.Label, n.Label) + if n.StartSeconds < cur.StartSeconds { + cur.StartSeconds = n.StartSeconds + } + if n.EndSeconds > cur.EndSeconds { + cur.EndSeconds = n.EndSeconds + } + cur.DurationSeconds = cur.EndSeconds - cur.StartSeconds + if n.Score > cur.Score { + cur.Score = n.Score + } + cur.AutoSelected = cur.AutoSelected || n.AutoSelected + continue + } + + merged = append(merged, cur) + cur = n + } + + merged = append(merged, cur) + return mergeAdjacentAISegments(merged, analyzeSegmentMergeGapSeconds) +} + +func buildAnalyzeSegmentsForGoal( + hits []analyzeHit, + duration float64, + goal string, +) []aiSegmentMeta { + goal = strings.ToLower(strings.TrimSpace(goal)) + + switch goal { + case "highlights": + return buildHighlightSegmentsFromAnalyzeHits(hits, duration) + + case "nsfw": + return buildSegmentsFromAnalyzeHits(hits, duration) + + default: + return []aiSegmentMeta{} + } } func buildSpriteFrameCandidates(count int, stepSeconds, durationSec float64) []spriteFrameCandidate { @@ -902,20 +2727,82 @@ func buildSpriteFrameCandidates(count int, stepSeconds, durationSec float64) []s return out } +func limitSpriteFrameCandidates(in []spriteFrameCandidate, max int) []spriteFrameCandidate { + if max <= 0 || len(in) <= max { + return in + } + + out := make([]spriteFrameCandidate, 0, max) + seen := map[int]bool{} + + if max == 1 { + return []spriteFrameCandidate{in[len(in)/2]} + } + + for i := 0; i < max; i++ { + ratio := float64(i) / float64(max-1) + idx := int(math.Round(ratio * float64(len(in)-1))) + + if idx < 0 { + idx = 0 + } + if idx >= len(in) { + idx = len(in) - 1 + } + + if seen[idx] { + continue + } + seen[idx] = true + + out = append(out, in[idx]) + } + + if len(out) == 0 { + return in + } + + return out +} + func buildVideoSampleTimes(durationSec float64, sampleCount int) []float64 { if durationSec <= 0 || sampleCount <= 0 { return nil } + + // Nicht exakt bei 0.0s und nicht exakt am Videoende sampeln. + // Anfang/Ende sind häufiger schwarz, unscharf oder ffmpeg schlägt am Ende fehl. + startPad := math.Min(1.0, durationSec*0.05) + endPad := math.Min(1.0, durationSec*0.05) + + start := startPad + end := durationSec - endPad + + if end <= start { + start = 0 + end = durationSec + } + if sampleCount == 1 { - return []float64{0} + return []float64{(start + end) / 2} } out := make([]float64, 0, sampleCount) + for i := 0; i < sampleCount; i++ { ratio := float64(i) / float64(sampleCount-1) - t := ratio * durationSec + t := start + ratio*(end-start) + + if t < 0 { + t = 0 + } + if t > durationSec { + t = durationSec + } + out = append(out, t) } + return out } diff --git a/backend/assets_generate.go b/backend/assets_generate.go index c792fdc..e83cb19 100644 --- a/backend/assets_generate.go +++ b/backend/assets_generate.go @@ -83,12 +83,12 @@ func postWorkSortForVideoPath(videoPath string) (bucket int, sortName string) { func assetPathsForID(id string) (assetDir, thumbPath, previewPath, spritePath, metaPath string, err error) { id = strings.TrimSpace(id) if id == "" { - return "", "", "", "", "", fmt.Errorf("empty id") + return "", "", "", "", "", appErrorf("empty id") } assetDir, err = ensureGeneratedDir(id) if err != nil || strings.TrimSpace(assetDir) == "" { - return "", "", "", "", "", fmt.Errorf("generated dir: %v", err) + return "", "", "", "", "", appErrorf("generated dir: %v", err) } thumbPath = filepath.Join(assetDir, "preview.jpg") @@ -103,6 +103,115 @@ func assetPathsForID(id string) (assetDir, thumbPath, previewPath, spritePath, m return assetDir, thumbPath, previewPath, spritePath, metaPath, nil } +func requiredAnalyzeGoals() []string { + return []string{"nsfw", "highlights"} +} + +func hasAIResultsForAllOutputGoals(outPath string, goals []string) bool { + // Der Name bleibt für Kompatibilität, + // aber semantisch heißt das jetzt: + // "Sind alle Analysen gespeichert?" + return hasAIAnalysisForAllOutputGoals(outPath, goals) +} + +func ensureAnalyzeAllGoalsForVideoCtx( + ctx context.Context, + videoPath string, + sourceURL string, +) (bool, error) { + return ensureAnalyzeAllGoalsForVideoCtxForce(ctx, videoPath, sourceURL, false) +} + +func ensureAnalyzeAllGoalsForVideoCtxForce( + ctx context.Context, + videoPath string, + sourceURL string, + force bool, +) (bool, error) { + videoPath = strings.TrimSpace(videoPath) + if videoPath == "" { + return false, nil + } + + // Normaler Asset-/Postwork-Pfad: + // vorhandene Analyse behalten. + if !force && hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals()) { + return false, nil + } + + fi, err := os.Stat(videoPath) + if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { + return false, nil + } + + id := assetIDFromVideoPath(videoPath) + if id == "" { + return false, nil + } + + _, _, _, _, metaPath, perr := assetPathsForID(id) + if perr != nil { + return false, perr + } + + meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) + + actx, cancel := context.WithTimeout(ctx, 30*time.Minute) + defer cancel() + + durationSec := meta.durSec + if durationSec <= 0 { + durationSec, _ = durationSecondsForAnalyze(actx, videoPath) + } + if durationSec <= 0 { + return false, appErrorf("videolänge konnte nicht bestimmt werden") + } + + // NSFW + Highlights in EINEM Frame-Extraktionslauf analysieren. + nsfwHits, highlightHits, err := analyzeVideoFromFramesForGoal( + actx, + videoPath, + "all", + ) + if err != nil { + return false, err + } + + nsfwSegments := buildAnalyzeSegmentsForGoal(nsfwHits, durationSec, "nsfw") + nsfwRating := computeNSFWRating(nsfwSegments, durationSec) + + nsfwAI := &aiAnalysisMeta{ + Goal: "nsfw", + Mode: "video", + Hits: nsfwHits, + Segments: nsfwSegments, + Rating: nsfwRating, + AnalyzedAtUnix: time.Now().Unix(), + } + + if err := writeVideoAIForFile(actx, videoPath, meta.sourceURL, nsfwAI); err != nil { + return false, err + } + + highlightSegments := buildAnalyzeSegmentsForGoal(highlightHits, durationSec, "highlights") + highlightRating := computeNSFWRating(highlightSegments, durationSec) + + highlightAI := &aiAnalysisMeta{ + Goal: "highlights", + Mode: "video", + Hits: highlightHits, + Segments: highlightSegments, + Rating: highlightRating, + AnalyzedAtUnix: time.Now().Unix(), + } + + if err := writeVideoAIForFile(actx, videoPath, meta.sourceURL, highlightAI); err != nil { + return false, err + } + + return hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals()), nil +} + func assetsTruthForVideo(videoPath string) finishedPhaseTruth { videoPath = strings.TrimSpace(videoPath) @@ -132,8 +241,8 @@ func assetsTruthForVideo(videoPath string) finishedPhaseTruth { truth.TeaserReady = fileExistsNonEmpty(previewPath) truth.SpritesReady = fileExistsNonEmpty(spritePath) - // Analyse weiterhin inhaltlich prüfen - truth.AnalyzeReady = hasAIResultsForOutput(videoPath) + // Analyse für Standard-Postwork: NSFW-Rating + truth.AnalyzeReady = truth.SpritesReady && hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals()) return truth } @@ -365,7 +474,7 @@ func ensurePrimaryAssetsForVideoWithProgressCtx( if err := atomicWriteFile(thumbPath, img); err == nil { out.ThumbGenerated = true } else { - fmt.Println("⚠️ thumb write:", err) + appLogln("⚠️ thumb write:", err) } } }() @@ -628,14 +737,14 @@ func ensureTeaserForVideoCtx( nil, ); err != nil { if isFFmpegInputInvalidError(err) { - return false, fmt.Errorf("teaser ffmpeg input ungültig für %s: %w", videoPath, err) + return false, appErrorf("teaser ffmpeg input ungültig für %s: %w", videoPath, err) } - return false, fmt.Errorf("teaser generation fehlgeschlagen für %s: %w", videoPath, err) + return false, appErrorf("teaser generation fehlgeschlagen für %s: %w", videoPath, err) } // Zusätzliche Sicherheitsprüfung: Datei muss nach der Generierung wirklich existieren if pfi, err := os.Stat(previewPath); err != nil || pfi == nil || pfi.IsDir() || pfi.Size() <= 0 { - return false, fmt.Errorf("teaser wurde ohne gültige preview.mp4 beendet: %s", previewPath) + return false, appErrorf("teaser wurde ohne gültige preview.mp4 beendet: %s", previewPath) } var computedPreviewClips []previewClip @@ -781,7 +890,12 @@ func ensureAnalyzeForVideoCtx( return false, nil } - if hasAIResultsForOutput(videoPath) { + goal = normalizeAIGoal(goal) + if goal == "" { + goal = "nsfw" + } + + if hasAIAnalysisForOutputGoal(videoPath, goal) { return false, nil } @@ -795,55 +909,20 @@ func ensureAnalyzeForVideoCtx( return false, nil } - _, _, _, spritePath, metaPath, perr := assetPathsForID(id) + _, _, _, _, _, perr := assetPathsForID(id) if perr != nil { return false, perr } - meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) - - if sfi, err := os.Stat(spritePath); err != nil || sfi == nil || sfi.IsDir() || sfi.Size() <= 0 { - return false, nil + // Analyse läuft jetzt frame-basiert und braucht kein preview-sprite.jpg mehr. + if _, err := ensureAnalyzeAllGoalsForVideoCtx(ctx, videoPath, sourceURL); err != nil { + return false, err } - goal = strings.ToLower(strings.TrimSpace(goal)) - if goal == "" { - goal = "nsfw" - } - - actx, cancel := context.WithTimeout(ctx, 90*time.Second) - defer cancel() - - durationSec, _ := durationSecondsForAnalyze(actx, videoPath) - hits, aerr := analyzeVideoFromSprite(actx, videoPath, goal) - if aerr != nil { - return false, nil - } - - segments := buildSegmentsFromAnalyzeHits(hits, durationSec) - - var rating *aiRatingMeta - if goal == "nsfw" { - rating = computeNSFWRating(segments, durationSec) - } - - ai := &aiAnalysisMeta{ - Goal: goal, - Mode: "sprite", - Hits: hits, - Segments: segments, - Rating: rating, - AnalyzedAtUnix: time.Now().Unix(), - } - - if werr := writeVideoAIForFile(actx, videoPath, meta.sourceURL, ai); werr != nil { - return false, nil - } - - return true, nil + return hasAIAnalysisForOutputGoal(videoPath, goal), nil } -func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string) error { +func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string, goal string) error { videoPath = strings.TrimSpace(videoPath) if videoPath == "" { return nil @@ -859,26 +938,23 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string) return nil } - // NUR noch langsame Background-Schritte: - // 1) Sprite + // Sprite weiterhin best-effort erzeugen. if _, err := ensureSpritesForVideoCtx(ctx, videoPath, sourceURL); err != nil { - return fmt.Errorf("deferred sprite failed for %s: %w", videoPath, err) + appLogln("⚠️ deferred sprite failed:", err) } - spriteTruth := assetsTruthForVideo(videoPath) - if !spriteTruth.SpritesReady { - return fmt.Errorf("preview-sprite.jpg fehlt nach deferred generation: %s", id) + // Analyse genau einmal für alle Standard-Ziele. + if _, err := ensureAnalyzeAllGoalsForVideoCtx(ctx, videoPath, sourceURL); err != nil { + return appErrorf("deferred analyze failed for %s: %w", videoPath, err) } - // 2) Analyse - analyzeReady, err := ensureAnalyzeForVideoCtx(ctx, videoPath, sourceURL, "nsfw") - if err != nil { - return fmt.Errorf("deferred analyze failed for %s: %w", videoPath, err) + goal = normalizeAIGoal(goal) + if goal == "" { + goal = "highlights" } - analyzeTruth := assetsTruthForVideo(videoPath) - if !analyzeReady && !analyzeTruth.AnalyzeReady { - return fmt.Errorf("Analyse fehlt nach deferred analyze: %s", id) + if !hasAIAnalysisForOutputGoal(videoPath, goal) { + return appErrorf("Analyse fehlt nach deferred analyze: %s", id) } return nil @@ -896,13 +972,22 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string return false } + requiredGoals := requiredAnalyzeGoals() + truth := assetsTruthForVideo(outPath) needMeta := !truth.MetaReady needThumb := !truth.ThumbReady needTeaser := !truth.TeaserReady needSprites := !truth.SpritesReady - needAnalyze := !truth.AnalyzeReady + + needAnalyze := false + for _, goal := range requiredGoals { + if !hasAIAnalysisForOutputGoal(outPath, goal) { + needAnalyze = true + break + } + } // Nichts zu tun -> erfolgreich if !needMeta && !needThumb && !needTeaser && !needSprites && !needAnalyze { @@ -923,7 +1008,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string publishDeferredPhase(fileName, assetID, "postwork", "sprites", "queued", "Sprites warten", nil) } if needAnalyze { - publishDeferredPhase(fileName, assetID, "enrich", "analyze", "queued", "Analyse wartet", nil) + publishDeferredPhase(fileName, assetID, "enrich", "analyze", "queued", "Analysen warten", nil) } sortBucket, sortName := postWorkSortForVideoPath(outPath) @@ -945,7 +1030,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string return true } - enqueueErr := fmt.Errorf("enqueue failed") + enqueueErr := appErrorf("enqueue failed") // queued-Zustände auf error drehen, aber nur für wirklich fehlende Phasen if needMeta { @@ -961,7 +1046,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string publishDeferredPhase(fileName, assetID, "postwork", "sprites", "error", "Sprites konnten nicht eingeplant werden", enqueueErr) } if needAnalyze { - publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "Analyse konnte nicht eingeplant werden", enqueueErr) + publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "Analysen konnten nicht eingeplant werden", enqueueErr) } return false @@ -1056,7 +1141,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU metaHasSprite = true } - metaHasAI := hasAIResultsForOutput(videoPath) + metaHasAI := hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals()) // Nur dann wirklich komplett "fertig", wenn auch AI vorhanden ist if thumbBefore && previewBefore && spriteBefore && meta.ok && metaHasSprite && metaHasAI { @@ -1120,7 +1205,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU if err := atomicWriteFile(thumbPath, img); err == nil { out.ThumbGenerated = true } else { - fmt.Println("⚠️ thumb write:", err) + appLogln("⚠️ thumb write:", err) } } }() @@ -1296,7 +1381,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU return out, nil } -func hasAIResultsForOutput(outPath string) bool { +func hasAIResultsForOutputGoal(outPath string, goal string) bool { outPath = strings.TrimSpace(outPath) if outPath == "" { return false @@ -1312,7 +1397,9 @@ func hasAIResultsForOutput(outPath string) bool { return false } - aiMap, ok := readVideoMetaAI(metaPath) + goal = normalizeAIGoal(goal) + + aiMap, ok := readVideoMetaAIForGoal(metaPath, goal) if !ok || aiMap == nil { return false } @@ -1323,6 +1410,74 @@ func hasAIResultsForOutput(outPath string) bool { return (hasHits && len(rawHits) > 0) || (hasSegs && len(rawSegs) > 0) } +func hasAIAnalysisForOutputGoal(outPath string, goal string) bool { + outPath = strings.TrimSpace(outPath) + if outPath == "" { + return false + } + + id := assetIDFromVideoPath(outPath) + if id == "" { + return false + } + + metaPath, err := generatedMetaFile(id) + if err != nil || strings.TrimSpace(metaPath) == "" { + return false + } + + goal = normalizeAIGoal(goal) + + aiMap, ok := readVideoMetaAIForGoal(metaPath, goal) + if !ok || aiMap == nil { + return false + } + + // Wichtig: + // Auch eine Analyse ohne Treffer ist fertig, solange sie gespeichert wurde. + if rawGoal, ok := aiMap["goal"].(string); ok && normalizeAIGoal(rawGoal) == goal { + return true + } + + if rawMode, ok := aiMap["mode"].(string); ok && strings.TrimSpace(rawMode) != "" { + return true + } + + if _, ok := aiMap["hits"]; ok { + return true + } + + if _, ok := aiMap["segments"]; ok { + return true + } + + if _, ok := aiMap["analyzedAtUnix"]; ok { + return true + } + + return false +} + +func hasAIAnalysisForAllOutputGoals(outPath string, goals []string) bool { + outPath = strings.TrimSpace(outPath) + if outPath == "" { + return false + } + + if len(goals) == 0 { + goals = requiredAnalyzeGoals() + } + + for _, goal := range goals { + goal = normalizeAIGoal(goal) + if !hasAIAnalysisForOutputGoal(outPath, goal) { + return false + } + } + + return true +} + type PrepareSplitResult struct { AssetsReady bool AnalyzeReady bool @@ -1335,12 +1490,12 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string videoPath = strings.TrimSpace(videoPath) if videoPath == "" { - return out, fmt.Errorf("empty videoPath") + return out, appErrorf("empty videoPath") } fi, err := os.Stat(videoPath) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { - return out, fmt.Errorf("video datei nicht gefunden") + return out, appErrorf("video datei nicht gefunden") } // 1) Nur schnelle / primäre Assets sicherstellen: @@ -1356,7 +1511,7 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string id := assetIDFromVideoPath(videoPath) if id == "" { - return out, fmt.Errorf("konnte asset id nicht ableiten") + return out, appErrorf("konnte asset id nicht ableiten") } _, thumbPath, previewPath, spritePath, _, perr := assetPathsForID(id) @@ -1382,15 +1537,15 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string goal = strings.ToLower(strings.TrimSpace(goal)) if goal == "" { - goal = "nsfw" + goal = "highlights" } // 2) Für Split brauchen wir zusätzlich Sprite + AI. // Deshalb die langsamen Deferred-Schritte hier bei Bedarf synchron nachziehen. // (Im normalen Record-Flow laufen sie separat im Hintergrund.) - if err := ensureDeferredAssetsAndAI(ctx, videoPath, sourceURL); err != nil { + if err := ensureDeferredAssetsAndAI(ctx, videoPath, sourceURL, goal); err != nil { // best effort: kein harter Fehler, Status unten nochmal aus Dateisystem/meta ableiten - fmt.Println("⚠️ prepareVideoForSplit deferred enrich:", err) + appLogln("⚠️ prepareVideoForSplit deferred enrich:", err) } spriteExists := func() bool { @@ -1403,27 +1558,22 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string out.SpriteReady = spriteExists // 3) AI-Segmente prüfen - if hasAIResultsForOutput(videoPath) { + if hasAIAnalysisForOutputGoal(videoPath, goal) { out.AnalyzeReady = true return out, nil } - // 4) Falls trotz Deferred-Step noch kein Sprite da ist, können wir AI nicht ausführen - if !spriteExists { - return out, nil - } - - // 5) Letzter Fallback: AI jetzt direkt berechnen + // 4) Fallback: AI direkt berechnen durationSec, _ := durationSecondsForAnalyze(ctx, videoPath) - hits, aerr := analyzeVideoFromSprite(ctx, videoPath, goal) + hits, aerr := analyzeVideoFromFrames(ctx, videoPath, goal) if aerr != nil { return out, nil } - segments := buildSegmentsFromAnalyzeHits(hits, durationSec) + segments := buildAnalyzeSegmentsForGoal(hits, durationSec, goal) var rating *aiRatingMeta - if goal == "nsfw" { + if goal == "nsfw" || goal == "highlights" { rating = computeNSFWRating(segments, durationSec) } @@ -1440,6 +1590,6 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string return out, nil } - out.AnalyzeReady = len(segments) > 0 + out.AnalyzeReady = hasAIAnalysisForOutputGoal(videoPath, goal) return out, nil } diff --git a/backend/assets_sprite.go b/backend/assets_sprite.go index 7f1305f..9d2f8d8 100644 --- a/backend/assets_sprite.go +++ b/backend/assets_sprite.go @@ -14,11 +14,20 @@ import ( ) const ( + // Weniger Frames als vorher, aber Qualität pro Frame bleibt gut genug + // für Analyse + UI. previewSpriteCols = 20 previewSpriteRows = 12 previewSpriteFrameCount = previewSpriteCols * previewSpriteRows - previewSpriteCellW = 320 - previewSpriteCellH = 180 + + // Nicht kleiner machen, solange die Analyse dieselben Sprites nutzt. + // 320x180 ist für Positionen/Body/Object-Erkennung deutlich sicherer + // als 240x135. + previewSpriteCellW = 320 + previewSpriteCellH = 180 + + // 4 ist kleiner als 3, aber noch gut genug für Analyse. + previewSpriteJpegQ = "4" ) func fixedPreviewSpriteLayout() (cols, rows, count, cellW, cellH int) { @@ -66,23 +75,23 @@ func generatePreviewSpriteJPG( outPath = strings.TrimSpace(outPath) if videoPath == "" { - return fmt.Errorf("generatePreviewSpriteJPG: empty videoPath") + return appErrorf("generatePreviewSpriteJPG: empty videoPath") } if outPath == "" { - return fmt.Errorf("generatePreviewSpriteJPG: empty outPath") + return appErrorf("generatePreviewSpriteJPG: empty outPath") } if cols <= 0 || rows <= 0 { - return fmt.Errorf("generatePreviewSpriteJPG: invalid grid %dx%d", cols, rows) + return appErrorf("generatePreviewSpriteJPG: invalid grid %dx%d", cols, rows) } if stepSec <= 0 { - return fmt.Errorf("generatePreviewSpriteJPG: invalid stepSec %.3f", stepSec) + return appErrorf("generatePreviewSpriteJPG: invalid stepSec %.3f", stepSec) } if cellW <= 0 || cellH <= 0 { - return fmt.Errorf("generatePreviewSpriteJPG: invalid cell size %dx%d", cellW, cellH) + return appErrorf("generatePreviewSpriteJPG: invalid cell size %dx%d", cellW, cellH) } if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { - return fmt.Errorf("mkdir sprite dir: %w", err) + return appErrorf("mkdir sprite dir: %w", err) } ext := filepath.Ext(outPath) @@ -111,7 +120,7 @@ func generatePreviewSpriteJPG( // robustere Filterkette vf := fmt.Sprintf( "fps=fps=1/%f:start_time=0:round=up,"+ - "scale=%d:%d:force_original_aspect_ratio=decrease:flags=bilinear,"+ + "scale=%d:%d:force_original_aspect_ratio=decrease:flags=fast_bilinear,"+ "pad=%d:%d:(ow-iw)/2:(oh-ih)/2:black,"+ "setsar=1,"+ "tile=%dx%d:margin=0:padding=0", @@ -135,7 +144,7 @@ func generatePreviewSpriteJPG( "-frames:v", "1", "-update", "1", "-c:v", "mjpeg", - "-q:v", "3", + "-q:v", previewSpriteJpegQ, tmpPath, ) @@ -164,7 +173,7 @@ func generatePreviewSpriteJPG( msgErr = "(kein stderr)" } - return fmt.Errorf( + return appErrorf( "ffmpeg sprite failed for %q -> %q: %w | args=%q | stderr=%s", videoPath, tmpPath, @@ -176,15 +185,15 @@ func generatePreviewSpriteJPG( fi, err := os.Stat(tmpPath) if err != nil { - return fmt.Errorf("sprite temp stat failed: %w", err) + return appErrorf("sprite temp stat failed: %w", err) } if fi.IsDir() || fi.Size() <= 0 { - return fmt.Errorf("sprite temp file invalid/empty") + return appErrorf("sprite temp file invalid/empty") } _ = os.Remove(outPath) if err := os.Rename(tmpPath, outPath); err != nil { - return fmt.Errorf("sprite rename failed: %w", err) + return appErrorf("sprite rename failed: %w", err) } renameOK = true diff --git a/backend/auth.go b/backend/auth.go index c6bea37..c915974 100644 --- a/backend/auth.go +++ b/backend/auth.go @@ -6,7 +6,6 @@ import ( "encoding/base64" "encoding/json" "errors" - "fmt" "net/http" "net/url" "os" @@ -98,11 +97,11 @@ func (am *AuthManager) loadFromFileIfExists() error { // ✅ Datei fehlt: bootstrap erzeugen + Passwort ausgeben return am.bootstrapAuthFile(abs) } - return fmt.Errorf("read auth config: %w", err) + return appErrorf("read auth config: %w", err) } var c authConfig if err := json.Unmarshal(b, &c); err != nil { - return fmt.Errorf("parse auth config: %w", err) + return appErrorf("parse auth config: %w", err) } am.confMu.Lock() am.conf = c @@ -191,13 +190,13 @@ func (am *AuthManager) bootstrapAuthFile(abs string) error { if passHash == "" { p, err := generateRandomPassword() if err != nil { - return fmt.Errorf("generate password: %w", err) + return appErrorf("generate password: %w", err) } plain = p h, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) if err != nil { - return fmt.Errorf("bcrypt: %w", err) + return appErrorf("bcrypt: %w", err) } passHash = string(h) } @@ -216,31 +215,31 @@ func (am *AuthManager) bootstrapAuthFile(abs string) error { // Datei schreiben if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { - return fmt.Errorf("mkdir auth dir: %w", err) + return appErrorf("mkdir auth dir: %w", err) } b, err := json.MarshalIndent(c, "", " ") if err != nil { - return fmt.Errorf("marshal auth config: %w", err) + return appErrorf("marshal auth config: %w", err) } b = append(b, '\n') if err := atomicWriteFileCompat(abs, b); err != nil { - return fmt.Errorf("write auth config: %w", err) + return appErrorf("write auth config: %w", err) } // Passwort in Konsole anzeigen (nur wenn wir es generiert haben) if plain != "" { - fmt.Println("🔐 auth.json wurde neu erstellt:") - fmt.Println(" Datei: ", abs) - fmt.Println(" Username:", user) - fmt.Println(" Passwort:", plain) - fmt.Println(" Hinweis: Bitte sofort sichern/ändern – das Passwort wird nur beim Erstellen angezeigt.") + appLogln("🔐 auth.json wurde neu erstellt:") + appLogln(" Datei: ", abs) + appLogln(" Username:", user) + appLogln(" Passwort:", plain) + appLogln(" Hinweis: Bitte sofort sichern/ändern – das Passwort wird nur beim Erstellen angezeigt.") } else { - fmt.Println("🔐 auth.json wurde neu erstellt (aus ENV AUTH_PASS_HASH):") - fmt.Println(" Datei: ", abs) - fmt.Println(" Username:", user) - fmt.Println(" Hinweis: Klartext-Passwort ist unbekannt (AUTH_PASS_HASH war gesetzt).") + appLogln("🔐 auth.json wurde neu erstellt (aus ENV AUTH_PASS_HASH):") + appLogln(" Datei: ", abs) + appLogln(" Username:", user) + appLogln(" Hinweis: Klartext-Passwort ist unbekannt (AUTH_PASS_HASH war gesetzt).") } return nil diff --git a/backend/chaturbate_autostart.go b/backend/chaturbate_autostart.go index 1af82b3..98d180b 100644 --- a/backend/chaturbate_autostart.go +++ b/backend/chaturbate_autostart.go @@ -298,7 +298,7 @@ func clearAllPendingAutoStartOnStartup() error { func startChaturbateAutoStartWorker(store *ModelStore) { if store == nil { if verboseLogs() { - fmt.Println("⚠️ [autostart] model store is nil") + appLogln("⚠️ [autostart] model store is nil") } return } @@ -359,9 +359,9 @@ func startChaturbateAutoStartWorker(store *ModelStore) { if !fresh { if verboseLogs() { if fetchedAt.IsZero() { - fmt.Println("⚠️ [autostart] chaturbate snapshot missing") + appLogln("⚠️ [autostart] chaturbate snapshot missing") } else { - fmt.Println("⚠️ [autostart] chaturbate snapshot stale:", time.Since(fetchedAt).Round(time.Second)) + appLogln("⚠️ [autostart] chaturbate snapshot stale:", time.Since(fetchedAt).Round(time.Second)) } } continue @@ -384,7 +384,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) { pendingAutoStartMu.Unlock() if err != nil { if verboseLogs() { - fmt.Println("⚠️ [autostart] load manual chaturbate pending failed:", err) + appLogln("⚠️ [autostart] load manual chaturbate pending failed:", err) } continue } @@ -394,7 +394,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) { pendingAutoStartMu.Unlock() if err != nil { if verboseLogs() { - fmt.Println("⚠️ [autostart] load resume chaturbate pending failed:", err) + appLogln("⚠️ [autostart] load resume chaturbate pending failed:", err) } continue } @@ -643,7 +643,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) { resumePendingThisScan[user] = true if verboseLogs() { - fmt.Println("⏸️ [autostart] stopped because model is no longer public:", user, show) + appLogln("⏸️ [autostart] stopped because model is no longer public:", user, show) } stopJobsInternal([]*RecordJob{runningJob}) @@ -953,16 +953,16 @@ func startChaturbateAutoStartWorker(store *ModelStore) { }) if err != nil { if verboseLogs() { - fmt.Println("❌ [autostart] start failed:", it.url, err) + appLogln("❌ [autostart] start failed:", it.url, err) } continue } if verboseLogs() { if it.source == "resume" { - fmt.Println("▶️ [autostart] resumed:", it.url) + appLogln("▶️ [autostart] resumed:", it.url) } else { - fmt.Println("▶️ [autostart] started:", it.url) + appLogln("▶️ [autostart] started:", it.url) } } @@ -1016,13 +1016,13 @@ func startChaturbateAutoStartWorker(store *ModelStore) { }) if err != nil || job == nil { if verboseLogs() { - fmt.Println("❌ [autostart] blind start failed:", it.url, err) + appLogln("❌ [autostart] blind start failed:", it.url, err) } continue } if verboseLogs() { - fmt.Println("▶️ [autostart] blind try started:", it.url) + appLogln("▶️ [autostart] blind try started:", it.url) } if it.source == "manual" { diff --git a/backend/chaturbate_online.go b/backend/chaturbate_online.go index 710d74a..a0543df 100644 --- a/backend/chaturbate_online.go +++ b/backend/chaturbate_online.go @@ -183,7 +183,7 @@ func fetchChaturbateOnlineRooms(ctx context.Context) ([]ChaturbateRoom, error) { if resp.StatusCode != 200 { b, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) - return nil, fmt.Errorf("chaturbate online rooms: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) + return nil, appErrorf("chaturbate online rooms: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) } dec := json.NewDecoder(resp.Body) @@ -194,7 +194,7 @@ func fetchChaturbateOnlineRooms(ctx context.Context) ([]ChaturbateRoom, error) { return nil, err } if d, ok := tok.(json.Delim); !ok || d != '[' { - return nil, fmt.Errorf("chaturbate online rooms: expected JSON array") + return nil, appErrorf("chaturbate online rooms: expected JSON array") } rooms := make([]ChaturbateRoom, 0, 4096) @@ -365,7 +365,7 @@ func selectBestRoomImageURL(rm ChaturbateRoom) string { func fetchProfileImageBytes(ctx context.Context, rawURL string) (mime string, data []byte, err error) { u := strings.TrimSpace(rawURL) if u == "" { - return "", nil, fmt.Errorf("empty image url") + return "", nil, appErrorf("empty image url") } req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) @@ -383,7 +383,7 @@ func fetchProfileImageBytes(ctx context.Context, rawURL string) (mime string, da if resp.StatusCode < 200 || resp.StatusCode >= 300 { b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) - return "", nil, fmt.Errorf("image fetch HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) + return "", nil, appErrorf("image fetch HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) } // Sicherheitslimit (Profilbilder sind klein) @@ -393,10 +393,10 @@ func fetchProfileImageBytes(ctx context.Context, rawURL string) (mime string, da return "", nil, err } if len(b) == 0 { - return "", nil, fmt.Errorf("empty image body") + return "", nil, appErrorf("empty image body") } if len(b) > maxImageBytes { - return "", nil, fmt.Errorf("image too large") + return "", nil, appErrorf("image too large") } ct := strings.TrimSpace(strings.ToLower(resp.Header.Get("Content-Type"))) @@ -598,7 +598,7 @@ func startChaturbateOnlinePoller(store *ModelStore) { // wiederholt loggen, nicht nur bei geändertem Fehlertext if msg != lastLoggedErr || time.Since(lastAttempt) > 30*time.Second { if prevFetchedAt.IsZero() { - fmt.Println("❌ [chaturbate] online rooms fetch failed (no snapshot yet):", msg) + appLogln("❌ [chaturbate] online rooms fetch failed (no snapshot yet):", msg) } else { fmt.Printf( "⚠️ [chaturbate] refresh failed, keeping snapshot from %s (%d rooms): %s\n", @@ -634,7 +634,7 @@ func startChaturbateOnlinePoller(store *ModelStore) { } if lastLoggedErr != "" { - fmt.Println("✅ [chaturbate] online rooms fetch recovered") + appLogln("✅ [chaturbate] online rooms fetch recovered") lastLoggedErr = "" } diff --git a/backend/cookies_crypto.go b/backend/cookies_crypto.go index 249a645..a01c94f 100644 --- a/backend/cookies_crypto.go +++ b/backend/cookies_crypto.go @@ -6,7 +6,6 @@ import ( "crypto/rand" "encoding/base64" "encoding/json" - "fmt" "io" "os" "path/filepath" @@ -137,7 +136,7 @@ func decryptBase64ToBytes(blob string) ([]byte, error) { } ns := gcm.NonceSize() if len(buf) < ns { - return nil, fmt.Errorf("encrypted cookies: invalid length") + return nil, appErrorf("encrypted cookies: invalid length") } nonce := buf[:ns] ciphertext := buf[ns:] diff --git a/backend/disk_guard.go b/backend/disk_guard.go index 65412d9..411f07c 100644 --- a/backend/disk_guard.go +++ b/backend/disk_guard.go @@ -460,7 +460,7 @@ func startDiskSpaceGuard() { func ensureDiskGuardAllowsStart() error { if atomic.LoadInt32(&diskEmergency) == 1 { - return fmt.Errorf("Speicherplatz-Notbremse aktiv: neuer Download wird blockiert") + return appErrorf("Speicherplatz-Notbremse aktiv: neuer Download wird blockiert") } s := getSettings() @@ -480,7 +480,7 @@ func ensureDiskGuardAllowsStart() error { stopped := stopAllStoppableJobs() - return fmt.Errorf( + return appErrorf( "Speicherplatz-Notbremse aktiv: frei=%s, benötigt=%s, pause=%dGB, resume=%dGB, reserve=%s, gestoppt=%d, pfad=%s", formatBytesSI(u64ToI64(free)), formatBytesSI(u64ToI64(pauseNeed)), diff --git a/backend/ml_embed.go b/backend/embed.go similarity index 61% rename from backend/ml_embed.go rename to backend/embed.go index 3f83dfa..a8bdd18 100644 --- a/backend/ml_embed.go +++ b/backend/embed.go @@ -1,3 +1,5 @@ +// backend\embed.go + package main import ( @@ -6,7 +8,7 @@ import ( "path/filepath" ) -//go:embed ml/*.py ml/*.json +//go:embed ml/*.py ml/*.json ai_server.py var embeddedMLFiles embed.FS func trainingEmbeddedMLDir() (string, error) { @@ -40,3 +42,23 @@ func trainingEmbeddedMLDir() (string, error) { return dir, nil } + +func embeddedAIServerDir() (string, error) { + dir := filepath.Join(os.TempDir(), "nsfwapp-ai-server") + + if err := os.MkdirAll(dir, 0755); err != nil { + return "", err + } + + b, err := embeddedMLFiles.ReadFile("ai_server.py") + if err != nil { + return "", err + } + + dstPath := filepath.Join(dir, "ai_server.py") + if err := os.WriteFile(dstPath, b, 0644); err != nil { + return "", err + } + + return dir, nil +} diff --git a/backend/frontend.go b/backend/frontend.go index 4700b40..ddf6d36 100644 --- a/backend/frontend.go +++ b/backend/frontend.go @@ -4,7 +4,6 @@ package main import ( "embed" - "fmt" "io/fs" "net/http" "path" @@ -20,13 +19,13 @@ var embeddedFrontend embed.FS func makeFrontendHandler() (http.Handler, bool) { distFS, err := fs.Sub(embeddedFrontend, "web/dist") if err != nil { - fmt.Println("⚠️ Frontend dist nicht im Binary gefunden – API läuft trotzdem:", err) + appLogln("⚠️ Frontend dist nicht im Binary gefunden – API läuft trotzdem:", err) return nil, false } // Prüfen, ob index.html vorhanden ist if _, err := fs.Stat(distFS, "index.html"); err != nil { - fmt.Println("⚠️ Frontend index.html nicht im Binary gefunden – API läuft trotzdem:", err) + appLogln("⚠️ Frontend index.html nicht im Binary gefunden – API läuft trotzdem:", err) return nil, false } diff --git a/backend/live.go b/backend/live.go index 159ffc3..d29f82a 100644 --- a/backend/live.go +++ b/backend/live.go @@ -67,7 +67,7 @@ func shouldRefreshHLS(userLower string, minInterval time.Duration) bool { func fetchCurrentBestHLS(ctx context.Context, username string, cookie string, userAgent string) (string, error) { u := strings.TrimSpace(username) if u == "" { - return "", fmt.Errorf("empty username") + return "", appErrorf("empty username") } if strings.TrimSpace(userAgent) == "" { @@ -89,7 +89,7 @@ func fetchCurrentBestHLS(ctx context.Context, username string, cookie string, us master = strings.TrimSpace(master) if master == "" { - return "", fmt.Errorf("empty parsed hls url") + return "", appErrorf("empty parsed hls url") } return master, nil @@ -161,7 +161,7 @@ func stopPreview(job *RecordJob) { func refreshPreviewLiveSourceForJob(r *http.Request, job *RecordJob) error { if job == nil { - return fmt.Errorf("job nil") + return appErrorf("job nil") } jobsMu.RLock() @@ -189,7 +189,7 @@ func refreshPreviewLiveSourceForJob(r *http.Request, job *RecordJob) error { username := strings.TrimSpace(extractUsername(sourceURL)) if username == "" { - return fmt.Errorf("username not found") + return appErrorf("username not found") } // ✅ wichtig: doppelten Refresh verhindern @@ -206,7 +206,7 @@ func refreshPreviewLiveSourceForJob(r *http.Request, job *RecordJob) error { } newHls = strings.TrimSpace(newHls) if newHls == "" { - return fmt.Errorf("empty hls") + return appErrorf("empty hls") } var oldHls string @@ -501,7 +501,7 @@ func classifyPreviewFFmpegStderr(stderr string) (state string, httpStatus int) { // It also starts your existing live-thumb loop: startLiveThumbJPGLoop(ctx, job). func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, httpCookie, userAgent string) error { if strings.TrimSpace(ffmpegPath) == "" { - return fmt.Errorf("kein ffmpeg gefunden – setze FFMPEG_PATH oder lege ffmpeg(.exe) neben das Backend") + return appErrorf("kein ffmpeg gefunden – setze FFMPEG_PATH oder lege ffmpeg(.exe) neben das Backend") } if err := os.MkdirAll(previewDir, 0o755); err != nil { return err diff --git a/backend/log.go b/backend/log.go new file mode 100644 index 0000000..dd888ba --- /dev/null +++ b/backend/log.go @@ -0,0 +1,266 @@ +// backend\log.go + +package main + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +var ( + appLogMu sync.Mutex + appLogFile *os.File +) + +func appLogPath() string { + exePath, _ := os.Executable() + exeDir := "" + + if exePath != "" { + exeDir = filepath.Dir(exePath) + } + + if exeDir == "" { + exeDir, _ = os.Getwd() + } + + return filepath.Join(exeDir, "recorder.log") +} + +func openAppLogFile() (*os.File, error) { + path := appLogPath() + + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return nil, err + } + + return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) +} + +func initAppLog() { + appLogMu.Lock() + defer appLogMu.Unlock() + + if appLogFile != nil { + return + } + + f, err := openAppLogFile() + if err != nil { + // Wichtig: hier NICHT appLogln verwenden, weil appLogMu schon gelockt ist. + fmt.Println("⚠️ recorder log konnte nicht geöffnet werden:", err) + return + } + + appLogFile = f + + _, _ = fmt.Fprintf( + appLogFile, + "\n\n--- Recorder Start %s ---\nlog=%s\n", + time.Now().Format(time.RFC3339), + appLogPath(), + ) + _ = appLogFile.Sync() +} + +func closeAppLog() { + appLogMu.Lock() + defer appLogMu.Unlock() + + if appLogFile == nil { + return + } + + _, _ = fmt.Fprintf( + appLogFile, + "--- Recorder Stop %s ---\n", + time.Now().Format(time.RFC3339), + ) + + _ = appLogFile.Sync() + _ = appLogFile.Close() + appLogFile = nil +} + +func appLogWriter() io.Writer { + initAppLog() + + appLogMu.Lock() + defer appLogMu.Unlock() + + if appLogFile != nil { + return appLogFile + } + + return os.Stderr +} + +func appLogWriteLine(line string) { + line = strings.TrimRight(line, "\r\n") + + ts := time.Now().Format("2006-01-02 15:04:05") + out := fmt.Sprintf("[%s] %s\n", ts, line) + + appLogMu.Lock() + defer appLogMu.Unlock() + + fmt.Print(out) + + if appLogFile != nil { + _, _ = appLogFile.WriteString(out) + _ = appLogFile.Sync() + } +} + +func appLogln(args ...any) { + appLogWriteLine(fmt.Sprintln(args...)) +} + +func appLogf(format string, args ...any) { + appLogWriteLine(fmt.Sprintf(format, args...)) +} + +func appErrorf(format string, args ...any) error { + err := fmt.Errorf(format, args...) + appLogln("❌", err) + return err +} + +// Nur zentraler Ersatz für fmt.Sprintf. +// Loggt absichtlich NICHT, weil Sprintf oft nur UI-/Command-Strings baut. +func appSprintf(format string, args ...any) string { + return fmt.Sprintf(format, args...) +} + +// Zentraler Ersatz für fmt.Sprint. +func appSprint(args ...any) string { + return fmt.Sprint(args...) +} + +func appLogHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) + return + } + + path := appLogPath() + + maxBytes := int64(64 * 1024) + switch strings.TrimSpace(r.URL.Query().Get("maxBytes")) { + case "16384": + maxBytes = 16 * 1024 + case "32768": + maxBytes = 32 * 1024 + case "131072": + maxBytes = 128 * 1024 + case "262144": + maxBytes = 256 * 1024 + } + + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + writeJSON(w, http.StatusOK, map[string]any{ + "ok": false, + "path": path, + "exists": false, + "log": "", + "error": "Logdatei existiert noch nicht.", + }) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": false, + "path": path, + "exists": false, + "log": "", + "error": err.Error(), + }) + return + } + + if info.IsDir() { + writeJSON(w, http.StatusOK, map[string]any{ + "ok": false, + "path": path, + "exists": true, + "log": "", + "error": "Logpfad ist ein Ordner.", + }) + return + } + + f, err := os.Open(path) + if err != nil { + writeJSON(w, http.StatusOK, map[string]any{ + "ok": false, + "path": path, + "exists": true, + "log": "", + "error": err.Error(), + }) + return + } + defer f.Close() + + size := info.Size() + offset := int64(0) + if size > maxBytes { + offset = size - maxBytes + } + + buf := make([]byte, size-offset) + if len(buf) > 0 { + if _, err := f.ReadAt(buf, offset); err != nil && err != io.EOF { + writeJSON(w, http.StatusOK, map[string]any{ + "ok": false, + "path": path, + "exists": true, + "log": "", + "error": err.Error(), + }) + return + } + } + + logText := string(buf) + if offset > 0 { + logText = "… Log gekürzt, zeige die letzten " + fmt.Sprint(maxBytes/1024) + " KB …\n\n" + logText + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "path": path, + "exists": true, + "sizeBytes": size, + "log": logText, + }) +} + +func clearAppLog() { + appLogMu.Lock() + defer appLogMu.Unlock() + + if appLogFile != nil { + _ = appLogFile.Close() + appLogFile = nil + } + + path := appLogPath() + + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + fmt.Println("⚠️ recorder log dir konnte nicht erstellt werden:", err) + return + } + + if err := os.WriteFile(path, []byte{}, 0644); err != nil { + fmt.Println("⚠️ recorder log konnte nicht geleert werden:", err) + } +} diff --git a/backend/main.go b/backend/main.go index f18a5bf..d67bb33 100644 --- a/backend/main.go +++ b/backend/main.go @@ -249,7 +249,7 @@ func parseFFRate(s string) float64 { func probeVideoProps(ctx context.Context, filePath string) (w int, h int, fps float64, err error) { filePath = strings.TrimSpace(filePath) if filePath == "" { - return 0, 0, 0, fmt.Errorf("empty path") + return 0, 0, 0, appErrorf("empty path") } cmd := exec.CommandContext(ctx, ffprobePath, @@ -274,7 +274,7 @@ func probeVideoProps(ctx context.Context, filePath string) (w int, h int, fps fl return 0, 0, 0, err } if len(info.Streams) == 0 { - return 0, 0, 0, fmt.Errorf("no video stream") + return 0, 0, 0, appErrorf("no video stream") } s := info.Streams[0] @@ -512,14 +512,14 @@ func durationSecondsCached(ctx context.Context, path string) (float64, error) { re := regexp.MustCompile(`Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)`) m := re.FindStringSubmatch(text) if len(m) != 4 { - return 0, fmt.Errorf("duration not found") + return 0, appErrorf("duration not found") } hh, _ := strconv.ParseFloat(m[1], 64) mm, _ := strconv.ParseFloat(m[2], 64) ss, _ := strconv.ParseFloat(m[3], 64) sec := hh*3600 + mm*60 + ss if sec <= 0 { - return 0, fmt.Errorf("invalid duration") + return 0, appErrorf("invalid duration") } durCache.mu.Lock() @@ -816,7 +816,7 @@ func (h *HTTPClient) FetchPage(ctx context.Context, url, cookieStr string) (stri } if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("HTTP %d beim Laden von %s", resp.StatusCode, url) + return "", appErrorf("HTTP %d beim Laden von %s", resp.StatusCode, url) } return body, nil @@ -839,7 +839,7 @@ func remuxTSToMP4(tsPath, mp4Path string) error { var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - return fmt.Errorf("ffmpeg remux failed: %v (%s)", err, stderr.String()) + return appErrorf("ffmpeg remux failed: %v (%s)", err, stderr.String()) } return nil } @@ -996,7 +996,7 @@ func remuxTSToMP4WithProgress( } if err := cmd.Wait(); err != nil { - return fmt.Errorf("ffmpeg remux failed: %v (%s)", err, strings.TrimSpace(stderr.String())) + return appErrorf("ffmpeg remux failed: %v (%s)", err, strings.TrimSpace(stderr.String())) } return nil } @@ -1094,18 +1094,18 @@ func ensureFastStartMP4(path string) error { cmd.Stderr = &stderr if err := cmd.Run(); err != nil { _ = os.Remove(tmp) - return fmt.Errorf("ffmpeg faststart failed: %v (%s)", err, strings.TrimSpace(stderr.String())) + return appErrorf("ffmpeg faststart failed: %v (%s)", err, strings.TrimSpace(stderr.String())) } // atomar austauschen if err := os.Rename(path, bak); err != nil { _ = os.Remove(tmp) - return fmt.Errorf("rename original to bak failed: %w", err) + return appErrorf("rename original to bak failed: %w", err) } if err := os.Rename(tmp, path); err != nil { _ = os.Rename(bak, path) _ = os.Remove(tmp) - return fmt.Errorf("rename tmp to original failed: %w", err) + return appErrorf("rename tmp to original failed: %w", err) } _ = os.Remove(bak) return nil @@ -1133,19 +1133,19 @@ func generatedMetaRoot() (string, error) { func generatedThumbJPGFile(assetID string) (string, error) { assetID = stripHotPrefix(strings.TrimSpace(assetID)) if assetID == "" { - return "", fmt.Errorf("empty assetID") + return "", appErrorf("empty assetID") } // gleiche Normalisierung wie bei Ordnernamen var err error assetID, err = sanitizeID(assetID) if err != nil || assetID == "" { - return "", fmt.Errorf("invalid assetID: %w", err) + return "", appErrorf("invalid assetID: %w", err) } dir, err := ensureGeneratedDir(assetID) if err != nil || strings.TrimSpace(dir) == "" { - return "", fmt.Errorf("ensureGeneratedDir: %w", err) + return "", appErrorf("ensureGeneratedDir: %w", err) } return filepath.Join(dir, "preview.jpg"), nil @@ -1204,7 +1204,7 @@ func uniqueDestPath(dstDir, file string) (string, error) { return cand, nil } } - return "", fmt.Errorf("too many duplicates for %s", file) + return "", appErrorf("too many duplicates for %s", file) } else if err != nil && !os.IsNotExist(err) { return "", err } @@ -1300,7 +1300,7 @@ func fixKeepRootFilesIntoModelSubdirs() { if os.IsNotExist(err) { return } - fmt.Println("⚠️ keep scan failed:", err) + appLogln("⚠️ keep scan failed:", err) return } @@ -1341,7 +1341,7 @@ func fixKeepRootFilesIntoModelSubdirs() { dstDir := filepath.Join(keepRoot, modelKey) if err := os.MkdirAll(dstDir, 0o755); err != nil { - fmt.Println("⚠️ keep mkdir failed:", err) + appLogln("⚠️ keep mkdir failed:", err) skipped++ continue } @@ -1364,19 +1364,19 @@ func fixKeepRootFilesIntoModelSubdirs() { } } if !found { - fmt.Println("⚠️ keep fix: too many duplicates, skip:", name) + appLogln("⚠️ keep fix: too many duplicates, skip:", name) skipped++ continue } } else if err != nil && !os.IsNotExist(err) { - fmt.Println("⚠️ keep stat dst failed:", err) + appLogln("⚠️ keep stat dst failed:", err) skipped++ continue } // Verschieben (Windows-lock robust) if err := renameWithRetry(src, dst); err != nil { - fmt.Println("⚠️ keep fix rename failed:", err) + appLogln("⚠️ keep fix rename failed:", err) skipped++ continue } @@ -1396,7 +1396,7 @@ func findFinishedFileByID(id string) (string, error) { base := stripHotPrefix(strings.TrimSpace(id)) if base == "" { - return "", fmt.Errorf("not found") + return "", appErrorf("not found") } names := []string{ @@ -1419,7 +1419,7 @@ func findFinishedFileByID(id string) (string, error) { } } - return "", fmt.Errorf("not found") + return "", appErrorf("not found") } const maxInt64 = int64(^uint64(0) >> 1) @@ -1486,22 +1486,22 @@ func FetchPlaylist(ctx context.Context, hc *HTTPClient, hlsSource, httpCookie st req, err := hc.NewRequest(ctx, http.MethodGet, hlsSource, httpCookie) if err != nil { - return nil, fmt.Errorf("Fehler beim Erstellen der Playlist-Request: %w", err) + return nil, appErrorf("Fehler beim Erstellen der Playlist-Request: %w", err) } resp, err := hc.client.Do(req) if err != nil { - return nil, fmt.Errorf("Fehler beim Laden der Playlist: %w", err) + return nil, appErrorf("Fehler beim Laden der Playlist: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("master playlist bad status: %d %s", resp.StatusCode, hlsSource) + return nil, appErrorf("master playlist bad status: %d %s", resp.StatusCode, hlsSource) } playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) if err != nil { - return nil, fmt.Errorf("master playlist decode failed: %w", err) + return nil, appErrorf("master playlist decode failed: %w", err) } if listType != m3u8.MASTER { return nil, errors.New("keine gültige Master-Playlist") @@ -1514,7 +1514,7 @@ func FetchPlaylist(ctx context.Context, hc *HTTPClient, hlsSource, httpCookie st baseURL, err := url.Parse(hlsSource) if err != nil { - return nil, fmt.Errorf("master url parse failed: %w", err) + return nil, appErrorf("master url parse failed: %w", err) } var bestVariant *m3u8.Variant @@ -1587,14 +1587,14 @@ func FetchPlaylist(ctx context.Context, hc *HTTPClient, hlsSource, httpCookie st variantRef, err := url.Parse(strings.TrimSpace(bestVariant.URI)) if err != nil { - return nil, fmt.Errorf("variant uri parse failed: %w", err) + return nil, appErrorf("variant uri parse failed: %w", err) } playlistURL := baseURL.ResolveReference(variantRef).String() playlistParsed, err := url.Parse(playlistURL) if err != nil { - return nil, fmt.Errorf("playlist url parse failed: %w", err) + return nil, appErrorf("playlist url parse failed: %w", err) } rootParsed := *playlistParsed @@ -1630,7 +1630,7 @@ func fileExists(path string) bool { func validateVideoDecodesNearEnd(ctx context.Context, path string) error { path = strings.TrimSpace(path) if path == "" { - return fmt.Errorf("empty path") + return appErrorf("empty path") } dur, err := durationSecondsCached(ctx, path) @@ -1645,10 +1645,10 @@ func validateVideoDecodesNearEnd(ctx context.Context, path string) error { img, err := extractFrameAtTimeJPG(path, t) if err != nil { - return fmt.Errorf("decode near end failed at %.3fs: %w", t, err) + return appErrorf("decode near end failed at %.3fs: %w", t, err) } if len(img) == 0 { - return fmt.Errorf("decode near end returned empty frame at %.3fs", t) + return appErrorf("decode near end returned empty frame at %.3fs", t) } return nil @@ -1715,7 +1715,7 @@ func reencodeTSToMP4(ctx context.Context, tsPath, mp4Path string) error { if err := cmd.Run(); err != nil { _ = os.Remove(tmp) - return fmt.Errorf("ffmpeg reencode failed: %v (%s)", err, strings.TrimSpace(stderr.String())) + return appErrorf("ffmpeg reencode failed: %v (%s)", err, strings.TrimSpace(stderr.String())) } if err := requireNonEmptyRegularFile(tmp, "reencode tmp result"); err != nil { @@ -1727,7 +1727,7 @@ func reencodeTSToMP4(ctx context.Context, tsPath, mp4Path string) error { if err := os.Rename(tmp, mp4Path); err != nil { _ = os.Remove(tmp) - return fmt.Errorf("rename reencode tmp -> target failed: %w", err) + return appErrorf("rename reencode tmp -> target failed: %w", err) } return nil diff --git a/backend/meta.go b/backend/meta.go index 93900f2..0941306 100644 --- a/backend/meta.go +++ b/backend/meta.go @@ -61,7 +61,9 @@ type previewMeta struct { } type analysisMeta struct { - AI *aiAnalysisMeta `json:"ai,omitempty"` + AI *aiAnalysisMeta `json:"ai,omitempty"` + NSFW *aiAnalysisMeta `json:"nsfw,omitempty"` + Highlights *aiAnalysisMeta `json:"highlights,omitempty"` } type postworkHistoryEntry struct { @@ -141,7 +143,7 @@ func metaJSONPathForAssetID(assetID string) (string, error) { return "", err } if strings.TrimSpace(root) == "" { - return "", fmt.Errorf("generated/meta root leer") + return "", appErrorf("generated/meta root leer") } return filepath.Join(root, assetID, "meta.json"), nil } @@ -393,6 +395,39 @@ func hasAIAnalysisMeta(ai *aiAnalysisMeta) bool { return false } +func normalizeAIGoal(goal string) string { + goal = strings.ToLower(strings.TrimSpace(goal)) + if goal == "" { + return "nsfw" + } + return goal +} + +func pickAnalysisAIForGoal(a analysisMeta, goal string) *aiAnalysisMeta { + goal = normalizeAIGoal(goal) + + switch goal { + case "nsfw": + if hasAIAnalysisMeta(a.NSFW) { + return a.NSFW + } + case "highlights": + if hasAIAnalysisMeta(a.Highlights) { + return a.Highlights + } + } + + // Legacy-Fallback: alte meta.json hatte nur analysis.ai + if hasAIAnalysisMeta(a.AI) { + storedGoal := normalizeAIGoal(a.AI.Goal) + if storedGoal == goal { + return a.AI + } + } + + return nil +} + func readVideoMetaSourceURL(metaPath string, fi os.FileInfo) (string, bool) { m, ok := readVideoMetaIfValid(metaPath, fi) if !ok || m == nil { @@ -626,12 +661,12 @@ func writeVideoMetaDuration(metaPath string, fi os.FileInfo, dur float64, source func generatedMetaFile(assetID string) (string, error) { assetID = stripHotPrefix(strings.TrimSpace(assetID)) if assetID == "" { - return "", fmt.Errorf("empty assetID") + return "", appErrorf("empty assetID") } id, err := sanitizeID(assetID) if err != nil || id == "" { - return "", fmt.Errorf("invalid assetID: %w", err) + return "", appErrorf("invalid assetID: %w", err) } return metaJSONPathForAssetID(id) @@ -647,7 +682,7 @@ func generatedDirForID(id string) (string, error) { return "", err } if strings.TrimSpace(root) == "" { - return "", fmt.Errorf("generated meta root ist leer") + return "", appErrorf("generated meta root ist leer") } return filepath.Join(root, id), nil } @@ -721,7 +756,7 @@ func ensureGeneratedDirs() error { return err } if strings.TrimSpace(root) == "" { - return fmt.Errorf("generated meta root ist leer") + return appErrorf("generated meta root ist leer") } return os.MkdirAll(root, 0o755) } @@ -729,10 +764,10 @@ func ensureGeneratedDirs() error { func sanitizeID(id string) (string, error) { id = strings.TrimSpace(id) if id == "" { - return "", fmt.Errorf("id fehlt") + return "", appErrorf("id fehlt") } if strings.ContainsAny(id, `/\`) { - return "", fmt.Errorf("ungültige id") + return "", appErrorf("ungültige id") } return id, nil } @@ -773,18 +808,13 @@ func writeVideoMetaAI( Resolution: formatResolution(w, h), }, }, - Analysis: analysisMeta{ - AI: ai, - }, } if existing != nil { m.Preview = existing.Preview + m.Analysis = existing.Analysis m.Postwork = existing.Postwork - - if !hasAIAnalysisMeta(ai) && hasAIAnalysisMeta(existing.Analysis.AI) { - m.Analysis = existing.Analysis - } + m.Validation = existing.Validation if m.Media.Video.Width <= 0 { m.Media.Video.Width = existing.Media.Video.Width @@ -806,6 +836,31 @@ func writeVideoMetaAI( } } + if hasAIAnalysisMeta(ai) { + goal := normalizeAIGoal(ai.Goal) + ai.Goal = goal + + switch goal { + case "nsfw": + m.Analysis.NSFW = ai + + // Legacy/default für bestehende UI: RatingOverlay liest aktuell analysis.ai. + m.Analysis.AI = ai + + case "highlights": + m.Analysis.Highlights = ai + + // Wichtig: analysis.ai NICHT mit highlights überschreiben, + // sonst verschwindet dein NSFW-Rating/Stern. + if !hasAIAnalysisMeta(m.Analysis.AI) { + m.Analysis.AI = ai + } + + default: + m.Analysis.AI = ai + } + } + buf, err := json.MarshalIndent(m, "", " ") if err != nil { return err @@ -827,23 +882,23 @@ func writeVideoAIForFile( fi, err := os.Stat(fullPath) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { - return fmt.Errorf("datei nicht gefunden") + return appErrorf("datei nicht gefunden") } m, ok := ensureVideoMetaForFileBestEffort(ctx, fullPath, sourceURL) if !ok || m == nil { - return fmt.Errorf("meta konnte nicht erzeugt werden") + return appErrorf("meta konnte nicht erzeugt werden") } stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath)) assetID := stripHotPrefix(strings.TrimSpace(stem)) if assetID == "" { - return fmt.Errorf("asset id fehlt") + return appErrorf("asset id fehlt") } assetID, err = sanitizeID(assetID) if err != nil || assetID == "" { - return fmt.Errorf("asset id ungültig: %w", err) + return appErrorf("asset id ungültig: %w", err) } metaPath, err := metaJSONPathForAssetID(assetID) @@ -851,6 +906,10 @@ func writeVideoAIForFile( return err } + if strings.TrimSpace(sourceURL) == "" { + sourceURL = m.File.SourceURL + } + return writeVideoMetaAI( metaPath, fi, @@ -951,7 +1010,7 @@ func writeVideoMetaPostworkEntry( return atomicWriteFile(metaPath, buf) } -func readVideoMetaAI(metaPath string) (map[string]any, bool) { +func readVideoMetaAIForGoal(metaPath string, goal string) (map[string]any, bool) { b, err := os.ReadFile(metaPath) if err != nil || len(b) == 0 { return nil, false @@ -969,12 +1028,29 @@ func readVideoMetaAI(metaPath string) (map[string]any, bool) { return nil, false } - ai, ok := rawAnalysis["ai"].(map[string]any) - if !ok || ai == nil { - return nil, false + goal = normalizeAIGoal(goal) + + keys := []string{goal} + + // Legacy-Fallback: alte meta.json hatte nur analysis.ai. + // Aber nur akzeptieren, wenn goal wirklich passt. + keys = append(keys, "ai") + + for _, key := range keys { + ai, ok := rawAnalysis[key].(map[string]any) + if !ok || ai == nil { + continue + } + + storedGoal := normalizeAIGoal(fmt.Sprint(ai["goal"])) + if storedGoal != goal { + continue + } + + return ai, true } - return ai, true + return nil, false } func writePostworkEntryForFile( @@ -990,18 +1066,18 @@ func writePostworkEntryForFile( ) error { fullPath = strings.TrimSpace(fullPath) if fullPath == "" { - return fmt.Errorf("fullPath fehlt") + return appErrorf("fullPath fehlt") } fi, err := os.Stat(fullPath) if err != nil || fi == nil || fi.IsDir() { - return fmt.Errorf("datei nicht gefunden") + return appErrorf("datei nicht gefunden") } stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath)) assetID := stripHotPrefix(strings.TrimSpace(stem)) if assetID == "" { - return fmt.Errorf("asset id fehlt") + return appErrorf("asset id fehlt") } assetID, err = sanitizeID(assetID) @@ -1016,7 +1092,7 @@ func writePostworkEntryForFile( // sorgt dafür, dass meta.json grundsätzlich existiert if _, ok := ensureVideoMetaForFileBestEffort(context.Background(), fullPath, ""); !ok { - return fmt.Errorf("meta konnte nicht erzeugt werden") + return appErrorf("meta konnte nicht erzeugt werden") } return writeVideoMetaPostworkEntry(metaPath, fi, postworkHistoryEntry{ diff --git a/backend/ml/detection_labels.json b/backend/ml/detection_labels.json index aad8492..9ffa529 100644 --- a/backend/ml/detection_labels.json +++ b/backend/ml/detection_labels.json @@ -10,10 +10,10 @@ "cowgirl", "reverse_cowgirl", "cunnilingus", + "prone_bone", "standing", "standing_doggy", "spooning", - "sideways", "sitting", "facesitting", "handjob", @@ -46,8 +46,6 @@ "bikini", "bra", "dress", - "fishnet", - "harness", "heels", "hotpants", "lingerie", diff --git a/backend/models_store.go b/backend/models_store.go index 558beb9..b8f7bd9 100644 --- a/backend/models_store.go +++ b/backend/models_store.go @@ -6,7 +6,6 @@ import ( "database/sql" "encoding/json" "errors" - "fmt" "net/http" "net/url" "strings" @@ -1035,10 +1034,10 @@ ORDER BY updated_at DESC; rows, err := s.db.Query(q1) if err != nil { // ✅ genau dein Fall: "Spalte existiert nicht" -> fallback - fmt.Println("models List query err (q1):", err) + appLogln("models List query err (q1):", err) rows, err = s.db.Query(q2) if err != nil { - fmt.Println("models List query err (q2):", err) + appLogln("models List query err (q2):", err) return []StoredModel{} } } diff --git a/backend/myfreecams_autostart.go b/backend/myfreecams_autostart.go index 273f6d9..36a552d 100644 --- a/backend/myfreecams_autostart.go +++ b/backend/myfreecams_autostart.go @@ -137,7 +137,7 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) { manualItems, err := loadManualPendingAutoStartItemsForProvider("mfc") pendingAutoStartMu.Unlock() if err != nil { - fmt.Println("⚠️ [mfc autostart] load manual pending failed:", err) + appLogln("⚠️ [mfc autostart] load manual pending failed:", err) continue } diff --git a/backend/nsfw_assets.go b/backend/nsfw_assets.go index 0f937d2..60616ce 100644 --- a/backend/nsfw_assets.go +++ b/backend/nsfw_assets.go @@ -4,7 +4,6 @@ package main import ( "embed" - "fmt" "io" "io/fs" "os" @@ -27,7 +26,7 @@ func nsfwRuntimeBaseDir() (string, error) { base = strings.TrimSpace(os.Getenv("TEMP")) } if base == "" { - return "", fmt.Errorf("LOCALAPPDATA/TEMP nicht gefunden") + return "", appErrorf("LOCALAPPDATA/TEMP nicht gefunden") } return filepath.Join(base, appName, nsfwRuntimeVersion), nil } @@ -51,22 +50,22 @@ func ensureNSFWAssetsExtracted() (string, error) { } if _, err := fs.Stat(embeddedNSFWAssets, "assets/nsfw"); err != nil { - return "", fmt.Errorf("embedded assets/nsfw nicht gefunden: %w", err) + return "", appErrorf("embedded assets/nsfw nicht gefunden: %w", err) } if err := os.RemoveAll(root); err != nil { - return "", fmt.Errorf("runtime-ordner konnte nicht bereinigt werden: %w", err) + return "", appErrorf("runtime-ordner konnte nicht bereinigt werden: %w", err) } if err := os.MkdirAll(root, 0o755); err != nil { - return "", fmt.Errorf("runtime-ordner konnte nicht erstellt werden: %w", err) + return "", appErrorf("runtime-ordner konnte nicht erstellt werden: %w", err) } if err := extractEmbeddedDir("assets/nsfw", root); err != nil { - return "", fmt.Errorf("nsfw-assets extrahieren fehlgeschlagen: %w", err) + return "", appErrorf("nsfw-assets extrahieren fehlgeschlagen: %w", err) } if err := os.WriteFile(marker, []byte("ok"), 0o644); err != nil { - return "", fmt.Errorf("markerdatei konnte nicht geschrieben werden: %w", err) + return "", appErrorf("markerdatei konnte nicht geschrieben werden: %w", err) } return root, nil diff --git a/backend/nsfw_detector.go b/backend/nsfw_detector.go index f272093..b5dbce0 100644 --- a/backend/nsfw_detector.go +++ b/backend/nsfw_detector.go @@ -5,7 +5,6 @@ package main import ( "bytes" "encoding/base64" - "fmt" "image" _ "image/jpeg" _ "image/png" @@ -94,22 +93,22 @@ func initNSFWDetector() error { case "darwin": libName = "libonnxruntime.dylib" default: - return fmt.Errorf("onnxruntime für OS %s nicht unterstützt", runtime.GOOS) + return appErrorf("onnxruntime für OS %s nicht unterstützt", runtime.GOOS) } dllPath := filepath.Join(root, libName) modelPath := filepath.Join(root, "320n.onnx") if _, err := os.Stat(dllPath); err != nil { - return fmt.Errorf("onnxruntime.dll nicht gefunden: %w", err) + return appErrorf("onnxruntime.dll nicht gefunden: %w", err) } if _, err := os.Stat(modelPath); err != nil { - return fmt.Errorf("320n.onnx nicht gefunden: %w", err) + return appErrorf("320n.onnx nicht gefunden: %w", err) } ort.SetSharedLibraryPath(dllPath) if err := ort.InitializeEnvironment(); err != nil { - return fmt.Errorf("onnxruntime init fehlgeschlagen: %w", err) + return appErrorf("onnxruntime init fehlgeschlagen: %w", err) } inputShape := ort.NewShape(1, 3, nsfwInputSize, nsfwInputSize) @@ -117,7 +116,7 @@ func initNSFWDetector() error { inputTensor, err := ort.NewTensor(inputShape, inputData) if err != nil { ort.DestroyEnvironment() - return fmt.Errorf("input tensor fehlgeschlagen: %w", err) + return appErrorf("input tensor fehlgeschlagen: %w", err) } outputShape := ort.NewShape(1, 4+nsfwNumClasses, nsfwNumAnchors) @@ -125,7 +124,7 @@ func initNSFWDetector() error { if err != nil { inputTensor.Destroy() ort.DestroyEnvironment() - return fmt.Errorf("output tensor fehlgeschlagen: %w", err) + return appErrorf("output tensor fehlgeschlagen: %w", err) } session, err := ort.NewAdvancedSession( @@ -140,7 +139,7 @@ func initNSFWDetector() error { outputTensor.Destroy() inputTensor.Destroy() ort.DestroyEnvironment() - return fmt.Errorf("onnx session fehlgeschlagen: %w", err) + return appErrorf("onnx session fehlgeschlagen: %w", err) } globalNSFW.runtimeRoot = root @@ -159,13 +158,13 @@ func detectNSFWFromImage(img image.Image) ([]NsfwFrameResult, error) { defer globalNSFW.mu.Unlock() if !globalNSFW.initialized || globalNSFW.session == nil { - return nil, fmt.Errorf("nsfw detector nicht initialisiert") + return nil, appErrorf("nsfw detector nicht initialisiert") } fillInputTensor(globalNSFW.inputTensor.GetData(), img) if err := globalNSFW.session.Run(); err != nil { - return nil, fmt.Errorf("onnx run fehlgeschlagen: %w", err) + return nil, appErrorf("onnx run fehlgeschlagen: %w", err) } raw := globalNSFW.outputTensor.GetData() @@ -237,11 +236,11 @@ func detectNSFWFromBase64(imageB64 string) ([]NsfwFrameResult, error) { func decodeBase64Image(imageB64 string) (image.Image, error) { raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(imageB64)) if err != nil { - return nil, fmt.Errorf("base64 decode fehlgeschlagen: %w", err) + return nil, appErrorf("base64 decode fehlgeschlagen: %w", err) } img, _, err := image.Decode(bytes.NewReader(raw)) if err != nil { - return nil, fmt.Errorf("bild decode fehlgeschlagen: %w", err) + return nil, appErrorf("bild decode fehlgeschlagen: %w", err) } return img, nil } diff --git a/backend/preview.go b/backend/preview.go index df257df..e9305c3 100644 --- a/backend/preview.go +++ b/backend/preview.go @@ -106,7 +106,7 @@ func ensureCoversDir() (string, error) { return "", err } if strings.TrimSpace(root) == "" { - return "", fmt.Errorf("covers root ist leer") + return "", appErrorf("covers root ist leer") } if err := os.MkdirAll(root, 0o755); err != nil { return "", err @@ -303,7 +303,7 @@ type coverModel struct { func listModelsForCovers() ([]coverModel, error) { if coverModelStore == nil { - return nil, fmt.Errorf("model store not set") + return nil, appErrorf("model store not set") } ms := coverModelStore.List() @@ -321,7 +321,7 @@ func listModelsForCovers() ([]coverModel, error) { func pickRandomThumbForCategory(ctx context.Context, category string) (thumbPath string, err error) { category = strings.TrimSpace(category) if category == "" { - return "", fmt.Errorf("category empty") + return "", appErrorf("category empty") } select { @@ -346,7 +346,7 @@ func pickRandomThumbForCategory(ctx context.Context, category string) (thumbPath } } if len(cands) == 0 { - return "", fmt.Errorf("no model with tag") + return "", appErrorf("no model with tag") } rand.Shuffle(len(cands), func(i, j int) { cands[i], cands[j] = cands[j], cands[i] }) @@ -354,7 +354,7 @@ func pickRandomThumbForCategory(ctx context.Context, category string) (thumbPath s := getSettings() doneAbs, derr := resolvePathRelativeToApp(s.DoneDir) if derr != nil || strings.TrimSpace(doneAbs) == "" { - return "", fmt.Errorf("doneDir resolve failed: %v", derr) + return "", appErrorf("doneDir resolve failed: %v", derr) } type candFile struct { @@ -424,7 +424,7 @@ func pickRandomThumbForCategory(ctx context.Context, category string) (thumbPath } } - return "", fmt.Errorf("no downloads/thumbs for category") + return "", appErrorf("no downloads/thumbs for category") } var coverKeyRe = regexp.MustCompile(`[^a-z0-9._-]+`) @@ -436,13 +436,13 @@ func sanitizeCoverKey(category string) (string, error) { c = "tag_" + hex.EncodeToString(sum[:8]) } if c == "" { - return "", fmt.Errorf("category fehlt") + return "", appErrorf("category fehlt") } c = strings.ReplaceAll(c, " ", "_") c = coverKeyRe.ReplaceAllString(c, "_") c = strings.Trim(c, "._-") if c == "" { - return "", fmt.Errorf("category ungültig") + return "", appErrorf("category ungültig") } if len(c) > 120 { c = c[:120] @@ -481,7 +481,7 @@ func coverPathForCategory(key string, ext string) (string, error) { return "", err } if strings.TrimSpace(root) == "" { - return "", fmt.Errorf("covers root ist leer") + return "", appErrorf("covers root ist leer") } if ext == "" { ext = ".jpg" @@ -507,37 +507,37 @@ func findExistingCoverFile(key string) (string, os.FileInfo, bool) { func downloadBytes(ctx context.Context, rawURL string, ua string) ([]byte, string, error) { rawURL = strings.TrimSpace(rawURL) if rawURL == "" { - return nil, "", fmt.Errorf("src fehlt") + return nil, "", appErrorf("src fehlt") } // local: only /generated/... if strings.HasPrefix(rawURL, "/") { clean := path.Clean(rawURL) if !strings.HasPrefix(clean, "/generated/") { - return nil, "", fmt.Errorf("src ungültig") + return nil, "", appErrorf("src ungültig") } if strings.Contains(clean, "..") { - return nil, "", fmt.Errorf("src ungültig") + return nil, "", appErrorf("src ungültig") } rel := strings.TrimPrefix(clean, "/") abs, err := resolvePathRelativeToApp(rel) if err != nil || strings.TrimSpace(abs) == "" { - return nil, "", fmt.Errorf("src ungültig") + return nil, "", appErrorf("src ungültig") } f, err := os.Open(abs) if err != nil { - return nil, "", fmt.Errorf("download failed: %v", err) + return nil, "", appErrorf("download failed: %v", err) } defer f.Close() b, err := io.ReadAll(io.LimitReader(f, 10*1024*1024)) if err != nil { - return nil, "", fmt.Errorf("download failed: %v", err) + return nil, "", appErrorf("download failed: %v", err) } if len(b) == 0 { - return nil, "", fmt.Errorf("download empty") + return nil, "", appErrorf("download empty") } ext := strings.ToLower(filepath.Ext(abs)) @@ -556,10 +556,10 @@ func downloadBytes(ctx context.Context, rawURL string, ua string) ([]byte, strin u, err := url.Parse(rawURL) if err != nil || u.Scheme == "" || u.Host == "" { - return nil, "", fmt.Errorf("src ungültig") + return nil, "", appErrorf("src ungültig") } if u.Scheme != "http" && u.Scheme != "https" { - return nil, "", fmt.Errorf("src schema nicht erlaubt") + return nil, "", appErrorf("src schema nicht erlaubt") } req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) @@ -580,7 +580,7 @@ func downloadBytes(ctx context.Context, rawURL string, ua string) ([]byte, strin defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, "", fmt.Errorf("download failed: HTTP %d", resp.StatusCode) + return nil, "", appErrorf("download failed: HTTP %d", resp.StatusCode) } b, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) @@ -588,7 +588,7 @@ func downloadBytes(ctx context.Context, rawURL string, ua string) ([]byte, strin return nil, "", err } if len(b) == 0 { - return nil, "", fmt.Errorf("download empty") + return nil, "", appErrorf("download empty") } return b, resp.Header.Get("Content-Type"), nil @@ -1205,14 +1205,14 @@ func extractLastFrameJPG(path string) ([]byte, error) { if err := cmd.Run(); err != nil { if ctx.Err() == context.DeadlineExceeded { - return nil, fmt.Errorf("ffmpeg last-frame jpg: timeout") + return nil, appErrorf("ffmpeg last-frame jpg: timeout") } - return nil, fmt.Errorf("ffmpeg last-frame jpg: %w (%s)", err, strings.TrimSpace(stderr.String())) + return nil, appErrorf("ffmpeg last-frame jpg: %w (%s)", err, strings.TrimSpace(stderr.String())) } b := out.Bytes() if len(b) == 0 { - return nil, fmt.Errorf("ffmpeg last-frame jpg: empty output") + return nil, appErrorf("ffmpeg last-frame jpg: empty output") } return b, nil @@ -1246,11 +1246,11 @@ func extractFrameAtTimeJPG(path string, seconds float64) ([]byte, error) { cmd.Stdout = &out cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("ffmpeg frame-at-time jpg: %w (%s)", err, strings.TrimSpace(stderr.String())) + return nil, appErrorf("ffmpeg frame-at-time jpg: %w (%s)", err, strings.TrimSpace(stderr.String())) } b := out.Bytes() if len(b) == 0 { - return nil, fmt.Errorf("ffmpeg frame-at-time jpg: empty output") + return nil, appErrorf("ffmpeg frame-at-time jpg: empty output") } return b, nil } @@ -1294,11 +1294,11 @@ func extractLastFrameJPGScaled(path string, width int, quality int) ([]byte, err cmd.Stdout = &out cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("ffmpeg last-frame scaled jpg: %w (%s)", err, strings.TrimSpace(stderr.String())) + return nil, appErrorf("ffmpeg last-frame scaled jpg: %w (%s)", err, strings.TrimSpace(stderr.String())) } b := out.Bytes() if len(b) == 0 { - return nil, fmt.Errorf("ffmpeg last-frame scaled jpg: empty output") + return nil, appErrorf("ffmpeg last-frame scaled jpg: empty output") } return b, nil } @@ -1333,11 +1333,11 @@ func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, er cmd.Stdout = &out cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("ffmpeg first-frame scaled jpg: %w (%s)", err, strings.TrimSpace(stderr.String())) + return nil, appErrorf("ffmpeg first-frame scaled jpg: %w (%s)", err, strings.TrimSpace(stderr.String())) } b := out.Bytes() if len(b) == 0 { - return nil, fmt.Errorf("ffmpeg first-frame scaled jpg: empty output") + return nil, appErrorf("ffmpeg first-frame scaled jpg: empty output") } return b, nil } @@ -1345,7 +1345,7 @@ func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, er func extractLiveFrameFromM3U8ThumbJPG(ctx context.Context, m3u8URL, cookie, userAgent string) ([]byte, error) { m3u8URL = strings.TrimSpace(m3u8URL) if m3u8URL == "" { - return nil, fmt.Errorf("m3u8 leer") + return nil, appErrorf("m3u8 leer") } if strings.TrimSpace(userAgent) == "" { @@ -1387,12 +1387,12 @@ func extractLiveFrameFromM3U8ThumbJPG(ctx context.Context, m3u8URL, cookie, user cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("ffmpeg live m3u8 jpg failed: %w (%s)", err, strings.TrimSpace(stderr.String())) + return nil, appErrorf("ffmpeg live m3u8 jpg failed: %w (%s)", err, strings.TrimSpace(stderr.String())) } b := out.Bytes() if len(b) == 0 { - return nil, fmt.Errorf("ffmpeg live m3u8 jpg: empty output") + return nil, appErrorf("ffmpeg live m3u8 jpg: empty output") } return b, nil @@ -1504,7 +1504,7 @@ func fallbackPreviewImageAssetPathForProvider(provider string) (string, error) { case "mfc": return "assets/img/mfc.jpg", nil default: - return "", fmt.Errorf("unknown provider") + return "", appErrorf("unknown provider") } } @@ -1969,7 +1969,7 @@ func previewImageURLForJob(job *RecordJob) string { func imageBytesToJPG(raw []byte, quality int) ([]byte, error) { if len(raw) == 0 { - return nil, fmt.Errorf("empty image") + return nil, appErrorf("empty image") } if quality <= 0 || quality > 100 { quality = 80 @@ -1985,7 +1985,7 @@ func imageBytesToJPG(raw []byte, quality int) ([]byte, error) { return nil, err } if buf.Len() == 0 { - return nil, fmt.Errorf("jpeg encode returned empty data") + return nil, appErrorf("jpeg encode returned empty data") } return buf.Bytes(), nil } diff --git a/backend/rating.go b/backend/rating.go index 9ce67a7..d423a52 100644 --- a/backend/rating.go +++ b/backend/rating.go @@ -4,6 +4,7 @@ package main import ( "math" + "sort" "strings" ) @@ -58,23 +59,658 @@ func ratingEffectiveDurationSeconds(seconds float64) float64 { return kneeSeconds * math.Log1p(seconds/kneeSeconds) } -func nsfwSegmentSeverityWeight(label string) float64 { - switch strings.ToLower(strings.TrimSpace(label)) { +func segmentSeverityWeight(label string) float64 { + label = strings.ToLower(strings.TrimSpace(label)) + if label == "" { + return 0.50 + } + + // ------------------------- + // Kombi-Highlights + // ------------------------- + if strings.HasPrefix(label, "combo:") { + return comboSegmentSeverityWeight(label) + } + + // ------------------------- + // Sexpositionen + // ------------------------- + if strings.HasPrefix(label, "position:") { + pos := strings.TrimPrefix(label, "position:") + return positionSeverityWeight(pos) + } + + // ------------------------- + // Body / Objects / Clothing + // ------------------------- + if strings.HasPrefix(label, "body:") { + body := strings.TrimPrefix(label, "body:") + return bodyPartSeverityWeight(body) + } + + if strings.HasPrefix(label, "object:") { + obj := strings.TrimPrefix(label, "object:") + return objectSeverityWeight(obj) + } + + if strings.HasPrefix(label, "clothing:") { + clothing := strings.TrimPrefix(label, "clothing:") + return clothingSeverityWeight(clothing) + } + + if strings.HasPrefix(label, "detector:") { + det := strings.TrimPrefix(label, "detector:") + return detectorSeverityWeight(det) + } + + // ------------------------- + // Direkte YOLO-Labels + // ------------------------- + return detectorSeverityWeight(label) +} + +func isPersonSegmentLabel(label string) bool { + label = strings.ToLower(strings.TrimSpace(label)) + + label = strings.TrimPrefix(label, "detector:") + label = strings.TrimPrefix(label, "body:") + label = strings.TrimPrefix(label, "object:") + label = strings.TrimPrefix(label, "clothing:") + label = strings.TrimPrefix(label, "position:") + + switch label { + case "person", + "person_male", + "person_female", + "person_unknown", + "male_person", + "female_person", + "people_male", + "people_female": + return true + default: + return false + } +} + +func detectorSeverityWeight(label string) float64 { + label = strings.ToLower(strings.TrimSpace(label)) + + switch label { + // Personen werden vor dem Rating komplett herausgefiltert. + // Dieser Fallback bleibt nur für alte Pfade. + case "person", "person_male", "person_female", "person_unknown", "people_male", "people_female": + return 0.00 + + // bodyParts aus detecton_labels.json + case "pussy", "vulva": + return 1.00 + case "penis": + return 0.95 + case "anus": + return 0.90 + case "breasts": + return 0.80 + case "ass", "buttocks": + return 0.65 + case "tongue": + return 0.45 + + // objects aus detecton_labels.json + case "dildo", "vibrator", "strapon", "buttplug": + return 0.85 + case "handcuffs", "blindfold", "collar": + return 0.55 + case "shower": + return 0.40 + case "towel": + return 0.25 + + // clothing aus detecton_labels.json + case "lingerie": + return 0.60 + case "panties", "bra": + return 0.55 + case "bikini": + return 0.35 + case "stockings", "heels": + return 0.35 + case "skirt", "dress", "hotpants", "croptop": + return 0.30 + + // optionale alte/alias Labels, falls noch in alten Metas vorhanden case "female_genitalia_exposed": return 1.00 - case "female_breast_exposed": + case "male_genitalia_exposed": return 0.95 case "anus_exposed": - return 0.85 - case "male_genitalia_exposed": + return 0.90 + case "female_breast_exposed", "breast_exposed": return 0.80 case "buttocks_exposed": return 0.65 + default: return 0.50 } } +func bodyPartSeverityWeight(label string) float64 { + label = strings.ToLower(strings.TrimSpace(label)) + + switch label { + case "pussy", "vulva": + return 1.00 + case "penis": + return 0.95 + case "anus": + return 0.90 + case "breasts": + return 0.80 + case "ass", "buttocks": + return 0.65 + case "tongue": + return 0.45 + case "mouth", "face": + return 0.45 + case "hands": + return 0.35 + default: + return 0.50 + } +} + +func objectSeverityWeight(label string) float64 { + label = strings.ToLower(strings.TrimSpace(label)) + + switch label { + case "dildo", "vibrator", "strapon", "buttplug", "sex_toy": + return 0.85 + case "handcuffs", "blindfold", "collar": + return 0.55 + case "shower": + return 0.40 + case "towel": + return 0.25 + default: + return 0.45 + } +} + +func clothingSeverityWeight(label string) float64 { + label = strings.ToLower(strings.TrimSpace(label)) + + switch label { + case "nude", "naked": + return 0.90 + case "lingerie": + return 0.60 + case "panties", "bra", "underwear": + return 0.55 + case "bikini", "swimwear": + return 0.35 + case "stockings", "heels": + return 0.35 + case "skirt", "dress", "hotpants", "croptop": + return 0.30 + default: + return 0.30 + } +} + +func positionSeverityWeight(label string) float64 { + label = strings.ToLower(strings.TrimSpace(label)) + + switch label { + // sehr explizit / klar sexuelle Handlung + case "doggy", "doggystyle", "standing_doggy": + return 1.00 + case "cowgirl", "reverse_cowgirl": + return 0.98 + case "missionary": + return 0.95 + case "prone_bone": + return 0.95 + case "blowjob", "cunnilingus", "oral", "69", "facesitting": + return 0.94 + + // explizit, aber etwas niedriger + case "toy_play": + return 0.88 + case "handjob", "fingering": + return 0.84 + case "spooning": + return 0.78 + + // Pose/Kontext, aber nicht stark genug alleine + case "standing", "sitting": + return 0.42 + case "other": + return 0.45 + + case "unknown", "": + return 0.00 + + default: + return 0.00 + } +} + +type ratingSignalSet struct { + Position float64 + Body float64 + Object float64 + Clothing float64 + + HasPosition bool + HasBody bool + HasObject bool + HasClothing bool +} + +func isKnownPositionLabel(label string) bool { + label = strings.ToLower(strings.TrimSpace(label)) + + switch label { + case "doggy", + "doggystyle", + "standing_doggy", + "cowgirl", + "reverse_cowgirl", + "missionary", + "prone_bone", + "blowjob", + "cunnilingus", + "oral", + "69", + "facesitting", + "toy_play", + "handjob", + "fingering", + "spooning", + "standing", + "sitting", + "other": + return true + default: + return false + } +} + +func ratingSignalSetAddLabel(set *ratingSignalSet, label string) { + label = strings.ToLower(strings.TrimSpace(label)) + if label == "" || isPersonSegmentLabel(label) { + return + } + + switch { + case strings.HasPrefix(label, "position:"): + raw := strings.TrimPrefix(label, "position:") + if !isKnownPositionLabel(raw) { + return + } + + w := positionSeverityWeight(raw) + if w > set.Position { + set.Position = w + } + if w > 0 { + set.HasPosition = true + } + + case strings.HasPrefix(label, "body:"): + w := bodyPartSeverityWeight(strings.TrimPrefix(label, "body:")) + if w > set.Body { + set.Body = w + } + if w > 0 { + set.HasBody = true + } + + case strings.HasPrefix(label, "object:"): + w := objectSeverityWeight(strings.TrimPrefix(label, "object:")) + if w > set.Object { + set.Object = w + } + if w > 0 { + set.HasObject = true + } + + case strings.HasPrefix(label, "clothing:"): + w := clothingSeverityWeight(strings.TrimPrefix(label, "clothing:")) + if w > set.Clothing { + set.Clothing = w + } + if w > 0 { + set.HasClothing = true + } + + case strings.HasPrefix(label, "detector:"): + raw := strings.TrimPrefix(label, "detector:") + + // Detector-Labels sind meistens Body/Object/Clothing, nicht Position. + switch { + case bodyPartSeverityWeight(raw) >= 0.65: + ratingSignalSetAddLabel(set, "body:"+raw) + case objectSeverityWeight(raw) >= 0.50: + ratingSignalSetAddLabel(set, "object:"+raw) + case clothingSeverityWeight(raw) >= 0.50: + ratingSignalSetAddLabel(set, "clothing:"+raw) + case isKnownPositionLabel(raw): + ratingSignalSetAddLabel(set, "position:"+raw) + } + + default: + // Direkte alte YOLO-Labels sinnvoll einsortieren. + // Wichtig: Position zuletzt prüfen, sonst greift positionSeverityWeight(default=0.60) + // für fast alles. + switch { + case bodyPartSeverityWeight(label) >= 0.65: + ratingSignalSetAddLabel(set, "body:"+label) + case objectSeverityWeight(label) >= 0.50: + ratingSignalSetAddLabel(set, "object:"+label) + case clothingSeverityWeight(label) >= 0.50: + ratingSignalSetAddLabel(set, "clothing:"+label) + case isKnownPositionLabel(label): + ratingSignalSetAddLabel(set, "position:"+label) + } + } +} + +func ratingSignalSetFromLabel(label string) ratingSignalSet { + label = strings.ToLower(strings.TrimSpace(label)) + + var set ratingSignalSet + + if strings.HasPrefix(label, "combo:") { + raw := strings.TrimPrefix(label, "combo:") + for _, part := range strings.Split(raw, "+") { + ratingSignalSetAddLabel(&set, part) + } + return set + } + + ratingSignalSetAddLabel(&set, label) + return set +} + +func contextualSegmentSeverityWeight(label string) float64 { + set := ratingSignalSetFromLabel(label) + + hasAny := + set.HasPosition || + set.HasBody || + set.HasObject || + set.HasClothing + + if !hasAny { + return 0 + } + + var score float64 + + if set.HasPosition { + // Position ist der Hauptanker. + score = + 0.72*set.Position + + 0.14*set.Body + + 0.09*set.Object + + 0.05*set.Clothing + + // Echte Kombi-Boni. + if set.HasBody { + score += 0.04 + } + if set.HasObject { + score += 0.035 + } + if set.HasClothing { + score += 0.015 + } + + // Schwache Positionslabels wie standing/sitting sollen ohne Kontext niedrig bleiben. + if set.Position < 0.60 && !set.HasBody && !set.HasObject { + score = math.Min(score, 0.45) + } + + return ratingClamp01(score) + } + + // Ohne Position: Kontext darf zählen, aber gedeckelt. + score = + 0.58*set.Body + + 0.28*set.Object + + 0.14*set.Clothing + + if set.HasBody && set.HasObject { + score += 0.08 + } + if set.HasBody && set.HasClothing { + score += 0.03 + } + + // Kleidung alleine niemals stark bewerten. + if set.HasClothing && !set.HasBody && !set.HasObject { + score = math.Min(score, 0.32) + } + + // Keine Position => nicht höher als "mittel-hoch". + score = math.Min(score, 0.68) + + return ratingClamp01(score) +} + +func comboSegmentSeverityWeight(label string) float64 { + raw := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(label)), "combo:") + if raw == "" { + return 0.00 + } + + parts := strings.Split(raw, "+") + + var weights []float64 + hasPersonContext := false + + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + if isPersonSegmentLabel(part) { + hasPersonContext = true + continue + } + + weight := 0.0 + + switch { + case strings.HasPrefix(part, "position:"): + weight = positionSeverityWeight(strings.TrimPrefix(part, "position:")) + + case strings.HasPrefix(part, "body:"): + weight = bodyPartSeverityWeight(strings.TrimPrefix(part, "body:")) + + case strings.HasPrefix(part, "object:"): + weight = objectSeverityWeight(strings.TrimPrefix(part, "object:")) + + case strings.HasPrefix(part, "clothing:"): + weight = clothingSeverityWeight(strings.TrimPrefix(part, "clothing:")) + + case strings.HasPrefix(part, "detector:"): + det := strings.TrimPrefix(part, "detector:") + if isPersonSegmentLabel(det) { + hasPersonContext = true + continue + } + weight = detectorSeverityWeight(det) + + default: + if isPersonSegmentLabel(part) { + hasPersonContext = true + continue + } + weight = detectorSeverityWeight(part) + } + + if weight > 0 { + weights = append(weights, weight) + } + } + + // Nur Person in der Combo => kein Rating-/Highlight-Gewicht. + if len(weights) == 0 { + return 0.00 + } + + var sum float64 + var maxWeight float64 + + for _, weight := range weights { + sum += weight + if weight > maxWeight { + maxWeight = weight + } + } + + avg := sum / float64(len(weights)) + + // Kombi = stärkstes Signal plus leichter Kontext-Boost. + combined := 0.70*maxWeight + 0.30*avg + + // Person macht das Highlight verständlicher, soll aber nicht stark boosten. + if hasPersonContext { + combined += 0.03 + } + + // Echte Combos sollen sichtbar relevant bleiben, + // aber nur wenn mindestens ein Nicht-Personen-Signal existiert. + if combined < 0.60 { + combined = 0.60 + } + if combined > 1.00 { + combined = 1.00 + } + + return combined +} + +func normalizeSegmentLabel(label string) string { + label = strings.ToLower(strings.TrimSpace(label)) + + label = strings.TrimPrefix(label, "detector:") + label = strings.TrimPrefix(label, "body:") + label = strings.TrimPrefix(label, "object:") + label = strings.TrimPrefix(label, "clothing:") + label = strings.TrimPrefix(label, "position:") + + return strings.TrimSpace(label) +} + +func mergeAdjacentAISegments(segments []aiSegmentMeta, maxGapSec float64) []aiSegmentMeta { + if len(segments) == 0 { + return nil + } + + // Pro normalisiertem Label separat mergen. + // Dadurch verhindern andere Labels zwischen zwei Treffern nicht mehr das Zusammenführen. + byLabel := make(map[string][]aiSegmentMeta) + + for _, s := range segments { + if strings.TrimSpace(s.Label) == "" { + continue + } + + if s.DurationSeconds <= 0 { + s.DurationSeconds = s.EndSeconds - s.StartSeconds + } + + if s.EndSeconds <= s.StartSeconds { + continue + } + + key := normalizeSegmentLabel(s.Label) + if key == "" { + continue + } + + byLabel[key] = append(byLabel[key], s) + } + + out := make([]aiSegmentMeta, 0, len(segments)) + + for _, items := range byLabel { + sort.SliceStable(items, func(i, j int) bool { + if items[i].StartSeconds != items[j].StartSeconds { + return items[i].StartSeconds < items[j].StartSeconds + } + if items[i].EndSeconds != items[j].EndSeconds { + return items[i].EndSeconds < items[j].EndSeconds + } + return items[i].Label < items[j].Label + }) + + cur := items[0] + + for i := 1; i < len(items); i++ { + n := items[i] + + gap := n.StartSeconds - cur.EndSeconds + + if gap >= -0.25 && gap <= maxGapSec { + oldDur := cur.DurationSeconds + if oldDur <= 0 { + oldDur = cur.EndSeconds - cur.StartSeconds + } + + newDur := n.DurationSeconds + if newDur <= 0 { + newDur = n.EndSeconds - n.StartSeconds + } + + totalDur := oldDur + newDur + + cur.Label = preferAnalyzeSegmentLabel(cur.Label, n.Label) + + if n.StartSeconds < cur.StartSeconds { + cur.StartSeconds = n.StartSeconds + } + if n.EndSeconds > cur.EndSeconds { + cur.EndSeconds = n.EndSeconds + } + + cur.DurationSeconds = cur.EndSeconds - cur.StartSeconds + cur.AutoSelected = cur.AutoSelected || n.AutoSelected + + if totalDur > 0 { + cur.Score = ((cur.Score * oldDur) + (n.Score * newDur)) / totalDur + } else if n.Score > cur.Score { + cur.Score = n.Score + } + + continue + } + + out = append(out, cur) + cur = n + } + + out = append(out, cur) + } + + sort.SliceStable(out, func(i, j int) bool { + if out[i].StartSeconds != out[j].StartSeconds { + return out[i].StartSeconds < out[j].StartSeconds + } + if out[i].EndSeconds != out[j].EndSeconds { + return out[i].EndSeconds < out[j].EndSeconds + } + return normalizeSegmentLabel(out[i].Label) < normalizeSegmentLabel(out[j].Label) + }) + + return out +} + func ratingConfidenceWeight(conf float64) float64 { conf = ratingClamp01(conf) @@ -87,13 +723,13 @@ func ratingConfidenceWeight(conf float64) float64 { func starsFromNSFWScore(score float64) int { switch { - case score < 15: + case score < 18: return 1 - case score < 35: + case score < 38: return 2 - case score < 58: + case score < 60: return 3 - case score < 78: + case score < 80: return 4 default: return 5 @@ -101,6 +737,8 @@ func starsFromNSFWScore(score float64) int { } func computeNSFWRating(segments []aiSegmentMeta, durationSec float64) *aiRatingMeta { + segments = mergeAdjacentAISegments(segments, 5.0) + r := &aiRatingMeta{ Score: 0, Stars: 1, @@ -115,11 +753,20 @@ func computeNSFWRating(segments []aiSegmentMeta, durationSec float64) *aiRatingM var totalFlagged float64 var totalWeighted float64 var totalEffectiveWeighted float64 + + var positionEffectiveWeighted float64 + var contextEffectiveWeighted float64 + var peakQuality float64 + var longest float64 var confSum float64 var n int for _, s := range segments { + if isPersonSegmentLabel(s.Label) { + continue + } + segDur := s.DurationSeconds if segDur <= 0 { segDur = s.EndSeconds - s.StartSeconds @@ -128,13 +775,37 @@ func computeNSFWRating(segments []aiSegmentMeta, durationSec float64) *aiRatingM continue } - sev := nsfwSegmentSeverityWeight(s.Label) + sev := contextualSegmentSeverityWeight(s.Label) + if sev <= 0 { + continue + } + conf := ratingClamp01(s.Score) - weight := sev * ratingConfidenceWeight(conf) + confWeight := ratingConfidenceWeight(conf) + quality := sev * confWeight + + if quality <= 0 { + continue + } + + effectiveDur := ratingEffectiveDurationSeconds(segDur) + weightedDur := segDur * quality + effectiveWeightedDur := effectiveDur * quality totalFlagged += segDur - totalWeighted += segDur * weight - totalEffectiveWeighted += ratingEffectiveDurationSeconds(segDur) * weight + totalWeighted += weightedDur + totalEffectiveWeighted += effectiveWeightedDur + + set := ratingSignalSetFromLabel(s.Label) + if set.HasPosition { + positionEffectiveWeighted += effectiveWeightedDur + } else { + contextEffectiveWeighted += effectiveWeightedDur + } + + if quality > peakQuality { + peakQuality = quality + } confSum += conf n++ @@ -152,22 +823,39 @@ func computeNSFWRating(segments []aiSegmentMeta, durationSec float64) *aiRatingM weightedCoverageRatio := ratingClamp01(totalWeighted / durationSec) segmentsPerMinute := float64(n) / videoMinutes avgConfidence := confSum / float64(n) - effectiveWeightedSecondsPerMinute := totalEffectiveWeighted / videoMinutes - // Weichere Normalisierung statt harter Sättigung. - // Das reduziert 1/5-Ausreißer und verteilt mehr Fälle auf 2–4 Sterne. - densityNorm := ratingSoftCap(effectiveWeightedSecondsPerMinute, 8.0) + positionEffectiveWeightedPerMinute := positionEffectiveWeighted / videoMinutes + contextEffectiveWeightedPerMinute := contextEffectiveWeighted / videoMinutes + + // Position ist der Haupttreiber. + // Kontext ohne Position zählt mit, wird aber schwächer normalisiert. + peakNorm := ratingSmoothStep(peakQuality) + positionDensityNorm := ratingSoftCap(positionEffectiveWeightedPerMinute, 6.0) + contextDensityNorm := ratingSoftCap(contextEffectiveWeightedPerMinute, 10.0) coverageNorm := ratingSoftCap(weightedCoverageRatio, 0.22) - frequencyNorm := ratingSoftCap(segmentsPerMinute, 1.25) - longestNorm := ratingSoftCap(longest, 25.0) + frequencyNorm := ratingSoftCap(segmentsPerMinute, 1.40) + longestNorm := ratingSoftCap(longest, 28.0) confNorm := ratingSmoothStep((avgConfidence - 0.35) / 0.60) raw := - 0.38*densityNorm + - 0.30*coverageNorm + - 0.12*frequencyNorm + - 0.10*longestNorm + - 0.10*confNorm + 0.34*peakNorm + + 0.28*positionDensityNorm + + 0.14*coverageNorm + + 0.10*contextDensityNorm + + 0.06*longestNorm + + 0.04*frequencyNorm + + 0.04*confNorm + + // Sicherheits-Caps: + // Ohne Positionssignal soll Kontext alleine nicht auf 4–5 Sterne kippen. + if positionEffectiveWeighted <= 0 { + raw = math.Min(raw, 0.58) + } + + // Sehr kurze/spärliche Treffer nicht überbewerten. + if totalFlagged < 4.0 && n <= 1 { + raw = math.Min(raw, 0.42) + } score := ratingRound(ratingClamp01(raw)*100, 1) diff --git a/backend/record.go b/backend/record.go index cbf9499..78033ff 100644 --- a/backend/record.go +++ b/backend/record.go @@ -1373,7 +1373,7 @@ func recordVideo(w http.ResponseWriter, r *http.Request) { writeErr := func(code int, msg string) { if tw.wrote { - fmt.Println("[recordVideo] late error (headers already sent):", code, msg) + appLogln("[recordVideo] late error (headers already sent):", code, msg) return } http.Error(w, msg, code) diff --git a/backend/record_paths.go b/backend/record_paths.go index c18521c..73a40cd 100644 --- a/backend/record_paths.go +++ b/backend/record_paths.go @@ -2,7 +2,6 @@ package main import ( - "fmt" "net/http" "net/url" "os" @@ -29,7 +28,7 @@ func safeBasenameQuery(r *http.Request, key string) (string, bool, error) { } dec = strings.TrimSpace(dec) if !isSafeBasename(dec) { - return "", false, fmt.Errorf("invalid basename") + return "", false, appErrorf("invalid basename") } return dec, true, nil } @@ -252,7 +251,7 @@ func resolveDoneFileByName(doneAbs string, file string) (full string, from strin return p, "keep", fi, nil } - return "", "", nil, fmt.Errorf("not found") + return "", "", nil, appErrorf("not found") } func isTrashPath(p string) bool { diff --git a/backend/record_stream_cb.go b/backend/record_stream_cb.go index d7b37d7..1d5809a 100644 --- a/backend/record_stream_cb.go +++ b/backend/record_stream_cb.go @@ -6,7 +6,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "net/http" "net/url" "os" @@ -43,12 +42,12 @@ func RecordStream( loadFreshHLS := func() (*selectedHLSStream, error) { body, err := hc.FetchPage(ctx, pageURL, httpCookie) if err != nil { - return nil, fmt.Errorf("seite laden: %w", err) + return nil, appErrorf("seite laden: %w", err) } hlsURL, err := ParseStream(body) if err != nil { - return nil, fmt.Errorf("stream-parsing: %w", err) + return nil, appErrorf("stream-parsing: %w", err) } hlsURL = strings.TrimSpace(hlsURL) @@ -64,7 +63,7 @@ func RecordStream( pageURL, ) if err != nil { - return nil, fmt.Errorf("variant-playlist: %w", err) + return nil, appErrorf("variant-playlist: %w", err) } if stream == nil || strings.TrimSpace(stream.VideoURL) == "" { return nil, errors.New("leere final video url") @@ -119,7 +118,7 @@ func RecordStream( if mergeAfterRun && fileExistsNonEmpty(attemptOut) { if aerr := appendFileAndRemove(outputPath, attemptOut); aerr != nil { - return fmt.Errorf("retry-chunk merge failed: %w", aerr) + return appErrorf("retry-chunk merge failed: %w", aerr) } } @@ -147,7 +146,7 @@ func RecordStream( refreshes++ if refreshes > maxPlaylistRefreshes { - return fmt.Errorf("cb refresh-limit erreicht nach %d versuchen: %w", refreshes-1, err) + return appErrorf("cb refresh-limit erreicht nach %d versuchen: %w", refreshes-1, err) } select { @@ -193,14 +192,14 @@ func ParseStream(html string) (string, error) { strings.Replace(strconv.Quote(matches[1]), `\\u`, `\u`, -1), ) if err != nil { - return "", fmt.Errorf("Unicode-decode failed: %w", err) + return "", appErrorf("Unicode-decode failed: %w", err) } var rd struct { HLSSource string `json:"hls_source"` } if err := json.Unmarshal([]byte(decoded), &rd); err != nil { - return "", fmt.Errorf("JSON-parse failed: %w", err) + return "", appErrorf("JSON-parse failed: %w", err) } if rd.HLSSource == "" { return "", errors.New("kein HLS-Quell-URL im JSON") diff --git a/backend/record_stream_hls.go b/backend/record_stream_hls.go index deeb239..61fe471 100644 --- a/backend/record_stream_hls.go +++ b/backend/record_stream_hls.go @@ -147,19 +147,19 @@ func getWantedResolutionPlaylistWithHeaders( defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) + return nil, appErrorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) } rawBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("m3u8 read: %w", err) + return nil, appErrorf("m3u8 read: %w", err) } masterText := string(rawBody) playlist, listType, err := m3u8.DecodeFrom(bytes.NewReader(rawBody), true) if err != nil { - return nil, fmt.Errorf("m3u8 parse: %w", err) + return nil, appErrorf("m3u8 parse: %w", err) } // Schon Media-Playlist @@ -354,7 +354,7 @@ func appendFileAndRemove(dstPath, srcPath string) error { dstPath = strings.TrimSpace(dstPath) if srcPath == "" || dstPath == "" { - return fmt.Errorf("append path leer") + return appErrorf("append path leer") } if filepath.Clean(srcPath) == filepath.Clean(dstPath) { return nil @@ -423,13 +423,13 @@ func handleM3U8Mode( u, err := url.Parse(videoURL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") { - return fmt.Errorf("ungültige video URL: %q", videoURL) + return appErrorf("ungültige video URL: %q", videoURL) } if audioURL != "" { au, err := url.Parse(audioURL) if err != nil || (au.Scheme != "http" && au.Scheme != "https") { - return fmt.Errorf("ungültige audio URL: %q", audioURL) + return appErrorf("ungültige audio URL: %q", audioURL) } } @@ -440,7 +440,7 @@ func handleM3U8Mode( ext := strings.ToLower(filepath.Ext(outFile)) if ext != ".ts" && ext != ".mp4" { - return fmt.Errorf("nicht unterstützte output-endung: %q", ext) + return appErrorf("nicht unterstützte output-endung: %q", ext) } // Wichtig: @@ -562,9 +562,9 @@ func handleM3U8Mode( if err != nil { msg := strings.TrimSpace(stderr.String()) if msg != "" { - return fmt.Errorf("ffmpeg m3u8 failed: %w: %s", err, msg) + return appErrorf("ffmpeg m3u8 failed: %w: %s", err, msg) } - return fmt.Errorf("ffmpeg m3u8 failed: %w", err) + return appErrorf("ffmpeg m3u8 failed: %w", err) } if job != nil { diff --git a/backend/record_stream_mfc.go b/backend/record_stream_mfc.go index dc0f625..79319ce 100644 --- a/backend/record_stream_mfc.go +++ b/backend/record_stream_mfc.go @@ -54,9 +54,9 @@ func RecordStreamMFC( if time.Now().After(deadline) { if lastSt == nil { - return fmt.Errorf("mfc: stream wurde nicht public innerhalb %s", waitPublicMax) + return appErrorf("mfc: stream wurde nicht public innerhalb %s", waitPublicMax) } - return fmt.Errorf("mfc: stream ist nicht public nach %s (letzter Status: %s)", waitPublicMax, *lastSt) + return appErrorf("mfc: stream ist nicht public nach %s (letzter Status: %s)", waitPublicMax, *lastSt) } @@ -66,10 +66,10 @@ func RecordStreamMFC( // ✅ erst jetzt die Video URL holen (weil public) stream, err := mfc.GetSelectedStream(false) if err != nil { - return fmt.Errorf("mfc get video url: %w", err) + return appErrorf("mfc get video url: %w", err) } if stream == nil || strings.TrimSpace(stream.VideoURL) == "" { - return fmt.Errorf("mfc: keine m3u8 URL gefunden") + return appErrorf("mfc: keine m3u8 URL gefunden") } // ✅ WICHTIG: fMP4 live preview (/api/preview/live) braucht job.PreviewM3U8 als Input @@ -127,7 +127,7 @@ func (m *MyFreeCams) GetVideoURL(refresh bool) (string, error) { midInt, err := strconv.Atoi(midBase) if err != nil { - return "", fmt.Errorf("model-id parse error: %w", err) + return "", appErrorf("model-id parse error: %w", err) } mid := 100000000 + midInt a := "" @@ -180,7 +180,7 @@ func (m *MyFreeCams) GetStatus() (Status, error) { return StatusNotExist, nil } if resp.StatusCode != 200 { - return StatusUnknown, fmt.Errorf("HTTP %d", resp.StatusCode) + return StatusUnknown, appErrorf("HTTP %d", resp.StatusCode) } // wir brauchen sowohl Bytes (für Suche) als auch Reader (für HTML) @@ -202,7 +202,7 @@ func (m *MyFreeCams) GetStatus() (Status, error) { raw := string(bodyBytes[start : start+end]) u, err := url.Parse(raw) if err != nil { - return StatusUnknown, fmt.Errorf("tracking url invalid: %w", err) + return StatusUnknown, appErrorf("tracking url invalid: %w", err) } qs := u.Query() if qs.Get("model_id") == "" { @@ -255,7 +255,7 @@ func runMFC(ctx context.Context, username string, outArg string) error { return err } if st != StatusPublic { - return fmt.Errorf("Stream ist nicht live (Status: %s)", st) + return appErrorf("Stream ist nicht live (Status: %s)", st) } stream, err := mfc.GetSelectedStream(false) @@ -277,12 +277,12 @@ func getWantedResolutionPlaylist(playlistURL string) (string, error) { } defer resp.Body.Close() if resp.StatusCode != 200 { - return "", fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) + return "", appErrorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) } playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) if err != nil { - return "", fmt.Errorf("m3u8 parse: %w", err) + return "", appErrorf("m3u8 parse: %w", err) } if listType == m3u8.MEDIA { return playlistURL, nil diff --git a/backend/recorder.go b/backend/recorder.go index d01c7bc..db10af5 100644 --- a/backend/recorder.go +++ b/backend/recorder.go @@ -190,7 +190,7 @@ func scrubberIndexToTime(index, count int, durationSec float64) float64 { func lookupDurationForScrubber(r *http.Request) (float64, error) { path, ok, _, _ := resolvePlayablePathFromQuery(r) if !ok || strings.TrimSpace(path) == "" { - return 0, fmt.Errorf("unable to resolve file") + return 0, appErrorf("unable to resolve file") } // best-effort meta @@ -335,7 +335,7 @@ func statOutputForLog(path string) string { func waitForUsableOutput(path string, timeout time.Duration) (os.FileInfo, error) { path = strings.TrimSpace(path) if path == "" { - return nil, fmt.Errorf("path empty") + return nil, appErrorf("path empty") } deadline := time.Now().Add(timeout) @@ -350,11 +350,11 @@ func waitForUsableOutput(path string, timeout time.Duration) (os.FileInfo, error if err != nil { lastErr = err } else if fi == nil { - lastErr = fmt.Errorf("stat returned nil") + lastErr = appErrorf("stat returned nil") } else if fi.IsDir() { - lastErr = fmt.Errorf("path is dir") + lastErr = appErrorf("path is dir") } else { - lastErr = fmt.Errorf("file size is 0") + lastErr = appErrorf("file size is 0") } if time.Now().After(deadline) { @@ -364,7 +364,7 @@ func waitForUsableOutput(path string, timeout time.Duration) (os.FileInfo, error } if lastErr == nil { - lastErr = fmt.Errorf("usable output not ready in time") + lastErr = appErrorf("usable output not ready in time") } return nil, lastErr } @@ -433,7 +433,7 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) { provider := detectProvider(url) if provider != "chaturbate" && provider != "mfc" { - return nil, fmt.Errorf("unsupported provider: %q", url) + return nil, appErrorf("unsupported provider: %q", url) } if err := ensureDiskGuardAllowsStart(); err != nil { @@ -470,7 +470,7 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) { } if active >= max { - return nil, fmt.Errorf("Download-Limit erreicht (%d/%d aktiv)", active, max) + return nil, appErrorf("Download-Limit erreicht (%d/%d aktiv)", active, max) } } @@ -520,18 +520,18 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) { func requireNonEmptyRegularFile(path string, label string) error { path = strings.TrimSpace(path) if path == "" { - return fmt.Errorf("%s: path empty", label) + return appErrorf("%s: path empty", label) } fi, err := os.Stat(path) if err != nil { - return fmt.Errorf("%s: %w", label, err) + return appErrorf("%s: %w", label, err) } if fi == nil || fi.IsDir() { - return fmt.Errorf("%s: not a file", label) + return appErrorf("%s: not a file", label) } if fi.Size() <= 0 { - return fmt.Errorf("%s: empty file", label) + return appErrorf("%s: empty file", label) } return nil } @@ -541,16 +541,16 @@ func buildPostworkTempMP4Path(tsPath string) (string, error) { doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { - return "", fmt.Errorf("doneDir auflösung fehlgeschlagen: %w", err) + return "", appErrorf("doneDir auflösung fehlgeschlagen: %w", err) } doneAbs = strings.TrimSpace(doneAbs) if doneAbs == "" { - return "", fmt.Errorf("doneDir ist leer") + return "", appErrorf("doneDir ist leer") } tmpDir := filepath.Join(doneAbs, ".postwork_tmp") if err := os.MkdirAll(tmpDir, 0o755); err != nil { - return "", fmt.Errorf("postwork tmp dir erstellen fehlgeschlagen: %w", err) + return "", appErrorf("postwork tmp dir erstellen fehlgeschlagen: %w", err) } base := strings.TrimSuffix(filepath.Base(strings.TrimSpace(tsPath)), filepath.Ext(tsPath)) + ".mp4" @@ -581,14 +581,14 @@ func runPrimaryPostworkPipeline( fi, err := os.Stat(tsPath) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { - return out, fmt.Errorf("ts invalid before remux") + return out, appErrorf("ts invalid before remux") } inSize := fi.Size() mp4Path, err := buildPostworkTempMP4Path(tsPath) if err != nil { - return tsPath, fmt.Errorf("postwork temp mp4 path failed: %w", err) + return tsPath, appErrorf("postwork temp mp4 path failed: %w", err) } _ = removeWithRetry(mp4Path) @@ -620,7 +620,7 @@ func runPrimaryPostworkPipeline( ) if err != nil { _ = removeWithRetry(mp4Path) - return tsPath, fmt.Errorf("ts remux failed: %w", err) + return tsPath, appErrorf("ts remux failed: %w", err) } if err := requireNonEmptyRegularFile(mp4Path, "remux result"); err != nil { @@ -629,13 +629,13 @@ func runPrimaryPostworkPipeline( } if err := validateVideoDecodesNearEnd(ctx, mp4Path); err != nil { - fmt.Println("⚠️ remux mp4 decode validation failed, fallback to reencode:", err) + appLogln("⚠️ remux mp4 decode validation failed, fallback to reencode:", err) _ = removeWithRetry(mp4Path) if rerr := reencodeTSToMP4(ctx, tsPath, mp4Path); rerr != nil { _ = removeWithRetry(mp4Path) - return tsPath, fmt.Errorf("remux invalid (%v), reencode fallback failed: %w", err, rerr) + return tsPath, appErrorf("remux invalid (%v), reencode fallback failed: %w", err, rerr) } if err2 := requireNonEmptyRegularFile(mp4Path, "reencode result"); err2 != nil { @@ -645,7 +645,7 @@ func runPrimaryPostworkPipeline( if err2 := validateVideoDecodesNearEnd(ctx, mp4Path); err2 != nil { _ = removeWithRetry(mp4Path) - return tsPath, fmt.Errorf("reencoded mp4 still invalid: %w", err2) + return tsPath, appErrorf("reencoded mp4 still invalid: %w", err2) } } @@ -673,9 +673,9 @@ func runPrimaryPostworkPipeline( } if err != nil { - return fallbackOut, fmt.Errorf("moveToDoneDir failed: %w", err) + return fallbackOut, appErrorf("moveToDoneDir failed: %w", err) } - return fallbackOut, fmt.Errorf("moveToDoneDir returned empty path") + return fallbackOut, appErrorf("moveToDoneDir returned empty path") } out = strings.TrimSpace(moved) @@ -691,7 +691,7 @@ func runPrimaryPostworkPipeline( if cleanupTSAfterMove != "" { if err := removeWithRetry(cleanupTSAfterMove); err != nil && !os.IsNotExist(err) { - fmt.Println("⚠️ ts cleanup after successful move:", err) + appLogln("⚠️ ts cleanup after successful move:", err) } purgeDurationCacheForPath(cleanupTSAfterMove) } @@ -711,14 +711,14 @@ func runPrimaryPostworkPipeline( } if _, err := ensureMetaForVideoCtx(ctx, out, job.SourceURL); err != nil { - return out, fmt.Errorf("meta.json generation failed: %w", err) + return out, appErrorf("meta.json generation failed: %w", err) } attachMetaToJobBestEffort(ctx, job, out) assetID := assetIDFromVideoPath(out) if assetID == "" { - return out, fmt.Errorf("asset id missing after move") + return out, appErrorf("asset id missing after move") } _, thumbPath, previewPath, _, metaPath, err := assetPathsForID(assetID) @@ -763,7 +763,7 @@ func runPrimaryPostworkPipeline( } if _, err := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, out, job.SourceURL, update); err != nil { - return out, fmt.Errorf("primary assets failed: %w", err) + return out, appErrorf("primary assets failed: %w", err) } if err := requireNonEmptyRegularFile(thumbPath, "preview.jpg"); err != nil { @@ -859,7 +859,7 @@ func runChaturbateRecording( s := getSettings() recordDirAbs, err := resolvePathRelativeToApp(s.RecordDir) if err != nil || strings.TrimSpace(recordDirAbs) == "" { - return fmt.Errorf("recordDir auflösung fehlgeschlagen: %v", err) + return appErrorf("recordDir auflösung fehlgeschlagen: %v", err) } _ = os.MkdirAll(recordDirAbs, 0o755) @@ -897,7 +897,7 @@ func runMFCRecording( s := getSettings() recordDirAbs, err := resolvePathRelativeToApp(s.RecordDir) if err != nil || strings.TrimSpace(recordDirAbs) == "" { - return fmt.Errorf("recordDir auflösung fehlgeschlagen: %v", err) + return appErrorf("recordDir auflösung fehlgeschlagen: %v", err) } _ = os.MkdirAll(recordDirAbs, 0o755) @@ -1053,7 +1053,7 @@ func maybeDeleteSmallRecordedOutput(job *RecordJob, out string) bool { } if isFavoriteProtectedDownload(out, job.SourceURL) { - fmt.Println("⭐ auto-delete skipped for favorite:", filepath.Base(out)) + appLogln("⭐ auto-delete skipped for favorite:", filepath.Base(out)) return false } @@ -1066,7 +1066,7 @@ func maybeDeleteSmallRecordedOutput(job *RecordJob, out string) bool { removeJobAndPublish(job) return true } else { - fmt.Println("⚠️ auto-delete before enqueue failed:", derr) + appLogln("⚠️ auto-delete before enqueue failed:", derr) } return false @@ -1292,15 +1292,17 @@ func publishDeferredPhase(fileName, assetID, queue, phase, state, label string, func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL string) error { outPath = strings.TrimSpace(outPath) if outPath == "" { - return fmt.Errorf("output path leer") + return appErrorf("output path leer") } fileName := filepath.Base(outPath) assetID := assetIDFromVideoPath(outPath) if assetID == "" { - return fmt.Errorf("asset id fehlt") + return appErrorf("asset id fehlt") } + requiredGoals := requiredAnalyzeGoals() + // best effort sourceURL aus meta.json nachziehen, falls leer if strings.TrimSpace(sourceURL) == "" { if _, _, _, _, metaPath, err := assetPathsForID(assetID); err == nil { @@ -1337,7 +1339,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st } if err != nil || !okMeta || !assetsTruthForVideo(outPath).MetaReady { if err == nil { - err = fmt.Errorf("meta konnte nicht erzeugt werden") + err = appErrorf("meta konnte nicht erzeugt werden") } publishDeferredPhase(fileName, assetID, "postwork", "meta", "error", "Meta fehlgeschlagen", err) return err @@ -1356,7 +1358,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st } if err != nil || !assetsTruthForVideo(outPath).ThumbReady { if err == nil { - err = fmt.Errorf("vorschaubild konnte nicht erzeugt werden") + err = appErrorf("vorschaubild konnte nicht erzeugt werden") } publishDeferredPhase(fileName, assetID, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen", err) return err @@ -1375,7 +1377,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st } if err != nil || !assetsTruthForVideo(outPath).TeaserReady { if err == nil { - err = fmt.Errorf("teaser konnte nicht erzeugt werden") + err = appErrorf("teaser konnte nicht erzeugt werden") } publishDeferredPhase(fileName, assetID, "postwork", "teaser", "error", "Teaser fehlgeschlagen", err) return err @@ -1385,9 +1387,12 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st truth = assetsTruthForVideo(outPath) } - // Ab hier erst die langsamen Hintergrundphasen var firstErr error + // ------------------------------------------------- + // Langsame Hintergrundphase: Sprite + // ------------------------------------------------- + if !truth.SpritesReady { publishDeferredPhase(fileName, assetID, "postwork", "sprites", "running", "Sprites", nil) @@ -1397,7 +1402,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st } if err != nil || !assetsTruthForVideo(outPath).SpritesReady { if err == nil { - err = fmt.Errorf("sprites konnten nicht erzeugt werden") + err = appErrorf("sprites konnten nicht erzeugt werden") } publishDeferredPhase(fileName, assetID, "postwork", "sprites", "error", "Sprites fehlgeschlagen", err) if firstErr == nil { @@ -1405,7 +1410,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st } } else { if _, checkErr := runTailBlackoutCheck(ctx, outPath); checkErr != nil { - fmt.Println("⚠️ sprite tail blackout check failed:", outPath, checkErr) + appLogln("⚠️ sprite tail blackout check failed:", outPath, checkErr) } publishDeferredPhase(fileName, assetID, "postwork", "sprites", "done", "Sprites", nil) @@ -1414,23 +1419,40 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st truth = assetsTruthForVideo(outPath) - if !truth.AnalyzeReady { - publishDeferredPhase(fileName, assetID, "enrich", "analyze", "running", "Analyse", nil) + // Ohne Sprite kann keine Sprite-basierte Analyse laufen. + if !truth.SpritesReady { + if firstErr == nil { + firstErr = appErrorf("Analysen übersprungen: preview-sprite.jpg fehlt") + } + return firstErr + } - _, err := ensureAnalyzeForVideoCtx(ctx, outPath, sourceURL, "nsfw") + // ------------------------------------------------- + // Langsame Hintergrundphase: NSFW + Highlights zusammen + // ------------------------------------------------- + + analyzeReady := hasAIResultsForAllOutputGoals(outPath, requiredGoals) + + if !analyzeReady { + publishDeferredPhase(fileName, assetID, "enrich", "analyze", "running", "Analysen", nil) + + _, err := ensureAnalyzeAllGoalsForVideoCtx(ctx, outPath, sourceURL) if cerr := abortIfCanceled(err, "enrich", "analyze"); cerr != nil { return cerr } - if err != nil || !assetsTruthForVideo(outPath).AnalyzeReady { + + analyzeReady = hasAIResultsForAllOutputGoals(outPath, requiredGoals) + + if err != nil || !analyzeReady { if err == nil { - err = fmt.Errorf("Analyse konnte nicht erzeugt werden") + err = appErrorf("Analysen konnten nicht erzeugt werden") } - publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "Analyse fehlgeschlagen", err) + publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "Analysen fehlgeschlagen", err) if firstErr == nil { firstErr = err } } else { - publishDeferredPhase(fileName, assetID, "enrich", "analyze", "done", "Analyse", nil) + publishDeferredPhase(fileName, assetID, "enrich", "analyze", "done", "Analysen", nil) } } @@ -1500,7 +1522,7 @@ func handlePrimaryPostworkSuccess(job *RecordJob, out string, postTarget JobStat }) if ok := enqueueDeferredAssetsAndAI(job, out, job.SourceURL); !ok { - fmt.Println("⚠️ deferred enrichment enqueue failed:", out) + appLogln("⚠️ deferred enrichment enqueue failed:", out) } if postTarget == JobStopped { diff --git a/backend/routes.go b/backend/routes.go index 6a4e204..3df0824 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -3,7 +3,6 @@ package main import ( - "fmt" "net/http" "net/url" "strings" @@ -33,6 +32,8 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { api.HandleFunc("/api/events/stream", eventsStream) api.HandleFunc("/api/perf/stream", perfStreamHandler) api.HandleFunc("/api/status/disk", diskStatusHandler) + api.HandleFunc("/api/status/ai-server", aiServerStatusHandler) + api.HandleFunc("/api/status/log", appLogHandler) api.HandleFunc("/api/autostart/state", autostartStateHandler) api.HandleFunc("/api/autostart/state/stream", autostartStateStreamHandler) @@ -100,13 +101,13 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { dsn, err := buildPostgresDSNFromSettings() if err != nil { - fmt.Println("⚠️ models DSN:", err) + appLogln("⚠️ models DSN:", err) } - fmt.Println("📦 Models DB (Postgres):", sanitizeDSNForLog(dsn)) + appLogln("📦 Models DB (Postgres):", sanitizeDSNForLog(dsn)) store := NewModelStore(dsn) if err := store.Load(); err != nil { - fmt.Println("⚠️ models load:", err) + appLogln("⚠️ models load:", err) } setCoverModelStore(store) @@ -147,14 +148,14 @@ func buildPostgresDSNFromSettings() (string, error) { dbURL := strings.TrimSpace(s.DatabaseURL) if dbURL == "" { - return "", fmt.Errorf("databaseUrl ist leer") + return "", appErrorf("databaseUrl ist leer") } // Wenn databaseUrl ein Passwort enthält: verwenden, // aber gleichzeitig safe sein, falls da Altbestand drin ist. u, err := url.Parse(dbURL) if err != nil { - return "", fmt.Errorf("databaseUrl ungültig: %w", err) + return "", appErrorf("databaseUrl ungültig: %w", err) } // 1) Wenn URL bereits Passwort enthält -> nur verwenden, wenn es NICHT der Placeholder ist @@ -177,7 +178,7 @@ func buildPostgresDSNFromSettings() (string, error) { plainPw, err := decryptSettingString(enc) if err != nil { - return "", fmt.Errorf("db password decrypt failed: %w", err) + return "", appErrorf("db password decrypt failed: %w", err) } plainPw = strings.TrimSpace(plainPw) if plainPw == "" { @@ -190,7 +191,7 @@ func buildPostgresDSNFromSettings() (string, error) { user = u.User.Username() } if strings.TrimSpace(user) == "" { - return "", fmt.Errorf("databaseUrl enthält keinen Username, kann Passwort nicht einsetzen") + return "", appErrorf("databaseUrl enthält keinen Username, kann Passwort nicht einsetzen") } u.User = url.UserPassword(user, plainPw) @@ -200,12 +201,12 @@ func buildPostgresDSNFromSettings() (string, error) { func buildPostgresDSNFromRecorderSettings(s RecorderSettings) (string, error) { dbURL := strings.TrimSpace(s.DatabaseURL) if dbURL == "" { - return "", fmt.Errorf("databaseUrl ist leer") + return "", appErrorf("databaseUrl ist leer") } u, err := url.Parse(dbURL) if err != nil { - return "", fmt.Errorf("databaseUrl ungültig: %w", err) + return "", appErrorf("databaseUrl ungültig: %w", err) } // Wenn URL bereits ein echtes Passwort enthält, direkt verwenden @@ -226,7 +227,7 @@ func buildPostgresDSNFromRecorderSettings(s RecorderSettings) (string, error) { plainPw, err := decryptSettingString(enc) if err != nil { - return "", fmt.Errorf("db password decrypt failed: %w", err) + return "", appErrorf("db password decrypt failed: %w", err) } plainPw = strings.TrimSpace(plainPw) if plainPw == "" { @@ -238,7 +239,7 @@ func buildPostgresDSNFromRecorderSettings(s RecorderSettings) (string, error) { user = u.User.Username() } if strings.TrimSpace(user) == "" { - return "", fmt.Errorf("databaseUrl enthält keinen Username, kann Passwort nicht einsetzen") + return "", appErrorf("databaseUrl enthält keinen Username, kann Passwort nicht einsetzen") } u.User = url.UserPassword(user, plainPw) diff --git a/backend/serve_video.go b/backend/serve_video.go index 70c076b..53c0dc8 100644 --- a/backend/serve_video.go +++ b/backend/serve_video.go @@ -5,7 +5,6 @@ package main import ( "bytes" "context" - "fmt" "math" "net/http" "os" @@ -178,7 +177,7 @@ func maybeRemuxTSForJob(job *RecordJob, path string) (string, error) { func moveToDoneDir(src string) (string, error) { src = strings.TrimSpace(src) if src == "" { - return "", fmt.Errorf("src empty") + return "", appErrorf("src empty") } s := getSettings() @@ -189,7 +188,7 @@ func moveToDoneDir(src string) (string, error) { doneAbs = strings.TrimSpace(s.DoneDir) } if strings.TrimSpace(doneAbs) == "" { - return "", fmt.Errorf("doneDir empty") + return "", appErrorf("doneDir empty") } // Quelle normalisieren/abs machen (best effort) @@ -202,7 +201,7 @@ func moveToDoneDir(src string) (string, error) { fi, err := os.Stat(srcAbs) if err != nil || fi.IsDir() { - return "", fmt.Errorf("src not found: %v", err) + return "", appErrorf("src not found: %v", err) } file := filepath.Base(srcAbs) diff --git a/backend/server.go b/backend/server.go index 372b234..4267c45 100644 --- a/backend/server.go +++ b/backend/server.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "os/signal" + "path/filepath" "runtime" "strings" "sync" @@ -82,10 +83,333 @@ func buildStartupNotificationMessage() string { return "Die App läuft im Hintergrund." } +type aiServerProcess struct { + cmd *exec.Cmd +} + +func aiServerAutostartEnabled() bool { + raw := strings.ToLower(strings.TrimSpace(os.Getenv("AI_SERVER_AUTOSTART"))) + + // Standard: aktiv. + if raw == "" { + return true + } + + switch raw { + case "0", "false", "no", "off": + return false + default: + return true + } +} + +func aiServerURL() string { + raw := strings.TrimSpace(os.Getenv("AI_SERVER_URL")) + if raw == "" { + raw = "http://127.0.0.1:8765" + } + + return strings.TrimRight(raw, "/") +} + +func aiServerPortFromURL() string { + url := aiServerURL() + + if strings.Contains(url, ":8765") { + return "8765" + } + + // Einfacher Fallback. Wenn du später andere Ports willst, + // setze AI_SERVER_PORT explizit. + port := strings.TrimSpace(os.Getenv("AI_SERVER_PORT")) + if port != "" { + return port + } + + return "8765" +} + +func aiServerPythonPath() string { + raw := strings.TrimSpace(os.Getenv("AI_SERVER_PYTHON")) + if raw != "" { + return raw + } + + // Windows py launcher zuerst versuchen. + if runtime.GOOS == "windows" { + if _, err := exec.LookPath("py"); err == nil { + return "py" + } + } + + if _, err := exec.LookPath("python"); err == nil { + return "python" + } + + if _, err := exec.LookPath("python3"); err == nil { + return "python3" + } + + return "python" +} + +func findAIServerScriptDir() (string, error) { + cwd, _ := os.Getwd() + + exePath, _ := os.Executable() + exeDir := "" + if exePath != "" { + exeDir = filepath.Dir(exePath) + } + + candidates := []string{ + filepath.Join(cwd, "backend", "ai_server.py"), + filepath.Join(cwd, "ai_server.py"), + } + + if exeDir != "" { + candidates = append(candidates, + filepath.Join(exeDir, "backend", "ai_server.py"), + filepath.Join(exeDir, "ai_server.py"), + ) + } + + appLogln("🔎 Suche ai_server.py") + appLogln(" cwd:", cwd) + appLogln(" exeDir:", exeDir) + + for _, path := range candidates { + cleanPath := filepath.Clean(path) + appLogln(" prüfe:", cleanPath) + + if fi, err := os.Stat(cleanPath); err == nil && fi != nil && !fi.IsDir() { + appLogln("✅ ai_server.py gefunden:", cleanPath) + return filepath.Dir(cleanPath), nil + } + } + + embeddedDir, err := embeddedAIServerDir() + if err != nil { + return "", appErrorf("embedded ai_server.py konnte nicht extrahiert werden: %w", err) + } + + appLogln("✅ embedded ai_server.py extrahiert:", filepath.Join(embeddedDir, "ai_server.py")) + return embeddedDir, nil +} + +func waitForAIServer(ctx context.Context, url string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + client := &http.Client{ + Timeout: 800 * time.Millisecond, + } + + for { + if time.Now().After(deadline) { + return appErrorf("AI Server nicht rechtzeitig bereit") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url+"/health", nil) + if err == nil { + res, err := client.Do(req) + if err == nil { + _ = res.Body.Close() + + if res.StatusCode >= 200 && res.StatusCode < 300 { + return nil + } + } + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(300 * time.Millisecond): + } + } +} + +func aiServerStatusHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) + return + } + + url := aiServerURL() + client := &http.Client{ + Timeout: 900 * time.Millisecond, + } + + status := map[string]any{ + "ok": false, + "running": false, + "url": url, + } + + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, url+"/health", nil) + if err != nil { + status["error"] = err.Error() + writeJSON(w, http.StatusOK, status) + return + } + + res, err := client.Do(req) + if err != nil { + status["error"] = err.Error() + writeJSON(w, http.StatusOK, status) + return + } + defer res.Body.Close() + + status["statusCode"] = res.StatusCode + + if res.StatusCode >= 200 && res.StatusCode < 300 { + status["ok"] = true + status["running"] = true + } + + writeJSON(w, http.StatusOK, status) +} + +func startAIServer(ctx context.Context) (*aiServerProcess, error) { + if !aiServerAutostartEnabled() { + appLogln("ℹ️ AI Server Autostart deaktiviert.") + return nil, nil + } + + scriptDir, err := findAIServerScriptDir() + if err != nil { + return nil, err + } + + exePath, _ := os.Executable() + exeDir := "" + if exePath != "" { + exeDir = filepath.Dir(exePath) + } + + defaultProdModel := filepath.Join(exeDir, "generated", "training", "detector", "model", "best.pt") + scriptDirModel := filepath.Join(scriptDir, "generated", "training", "detector", "model", "best.pt") + + pythonPath := aiServerPythonPath() + port := aiServerPortFromURL() + + args := []string{} + + // Windows py launcher braucht meist -3. + if runtime.GOOS == "windows" && filepath.Base(strings.ToLower(pythonPath)) == "py" { + args = append(args, "-3") + } + + args = append(args, + "-m", "uvicorn", + "ai_server:app", + "--host", "127.0.0.1", + "--port", port, + "--log-level", "warning", + ) + + cmd := exec.CommandContext(ctx, pythonPath, args...) + cmd.Dir = scriptDir + + env := os.Environ() + env = append(env, + "PYTHONUNBUFFERED=1", + "AI_SERVER_URL="+aiServerURL(), + ) + + // Defaults nur setzen, wenn nicht schon extern gesetzt. + if strings.TrimSpace(os.Getenv("YOLO_MODEL")) == "" { + if fi, err := os.Stat(scriptDirModel); err == nil && fi != nil && !fi.IsDir() { + env = append(env, "YOLO_MODEL="+scriptDirModel) + } else if fi, err := os.Stat(defaultProdModel); err == nil && fi != nil && !fi.IsDir() { + env = append(env, "YOLO_MODEL="+defaultProdModel) + } + } + if strings.TrimSpace(os.Getenv("YOLO_IMGSZ")) == "" { + env = append(env, "YOLO_IMGSZ=640") + } + if strings.TrimSpace(os.Getenv("YOLO_BATCH")) == "" { + env = append(env, "YOLO_BATCH=16") + } + if strings.TrimSpace(os.Getenv("YOLO_CONF")) == "" { + env = append(env, "YOLO_CONF=0.25") + } + + cmd.Env = env + + appLogf( + "--- AI Server Start --- python=%s scriptDir=%s url=%s port=%s args=%s", + pythonPath, + scriptDir, + aiServerURL(), + port, + strings.Join(args, " "), + ) + + logWriter := appLogWriter() + cmd.Stdout = logWriter + cmd.Stderr = logWriter + + if runtime.GOOS == "windows" { + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } + } + + if err := cmd.Start(); err != nil { + return nil, err + } + + proc := &aiServerProcess{ + cmd: cmd, + } + + go func() { + err := cmd.Wait() + + if err != nil && ctx.Err() == nil { + appLogf("⚠️ AI Server beendet: %v", err) + } else { + appLogln("AI Server beendet.") + } + }() + + waitCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + if err := waitForAIServer(waitCtx, aiServerURL(), 30*time.Second); err != nil { + appLogln("⚠️ AI Server noch nicht bereit:", err) + // Nicht hart abbrechen. Deine Analyse kann auf den Fallback gehen. + return proc, nil + } + + appLogln("🧠 AI Server bereit:", aiServerURL()) + return proc, nil +} + +func (p *aiServerProcess) Stop() { + if p == nil { + return + } + + if p.cmd != nil && p.cmd.Process != nil { + appLogln("🛑 Beende AI Server...") + _ = p.cmd.Process.Kill() + } +} + // --- main --- func main() { + clearAppLog() + initAppLog() + defer closeAppLog() + loadSettings() + appCtx, appCancel := context.WithCancel(context.Background()) + defer appCancel() + // Nur eine Instanz erlauben: appLn, err := net.Listen("tcp", ":9999") if err != nil { @@ -93,14 +417,17 @@ func main() { "Recorder", "Die Anwendung wird bereits ausgeführt.", ) - fmt.Println("⚠️ Die App läuft bereits oder Port 9999 ist bereits belegt.") + appLogln("⚠️ Die App läuft bereits oder Port 9999 ist bereits belegt.") os.Exit(0) } defer appLn.Close() + aiProc, err := startAIServer(appCtx) + if err != nil { + appLogln("⚠️ AI Server konnte nicht gestartet werden:", err) + } + // ✅ Hier: alte manuelle Autostart-Pause beim echten App-Start zurücksetzen. - // Disk-Notbremse bleibt trotzdem aktiv, weil isAutostartPaused() - // zusätzlich diskEmergency prüft. resetAutostartPauseOnStartup() fixKeepRootFilesIntoModelSubdirs() @@ -112,11 +439,8 @@ func main() { go startGeneratedGarbageCollector() - if err := initNSFWDetector(); err != nil { - fmt.Println("❌ NSFW-ONNX Fehler:", err) - os.Exit(1) - } - + // Altes NSFW-ONNX nicht mehr hart initialisieren. + // Das neue Training-/YOLO-Modell wird über trainingPredictFrame genutzt. var closeNSFWOnce sync.Once closeNSFW := func() { closeNSFWOnce.Do(func() { @@ -129,7 +453,7 @@ func main() { auth, err := NewAuthManager() if err != nil { - fmt.Println("❌ auth init:", err) + appLogln("❌ auth init:", err) closeNSFW() os.Exit(1) } @@ -138,7 +462,7 @@ func main() { setChaturbateOnlineModelStore(store) if err := clearAllPendingAutoStartOnStartup(); err != nil { - fmt.Println("⚠️ [pending-autostart] startup clear failed:", err) + appLogln("⚠️ [pending-autostart] startup clear failed:", err) } // Hintergrund-Worker @@ -154,18 +478,18 @@ func main() { defer cancel() if _, err := refreshChaturbateSnapshotNow(ctx); err != nil { - fmt.Println("⚠️ Chaturbate API failed:", err) + appLogln("⚠️ Chaturbate API failed:", err) } else { - fmt.Println("✅ Chaturbate API geladen") + appLogln("✅ Chaturbate API geladen") } }() } if _, err := ensureCoversDir(); err != nil { - fmt.Println("⚠️ covers dir:", err) + appLogln("⚠️ covers dir:", err) } - fmt.Println("🌐 HTTP-API aktiv: http://localhost:9999") + appLogln("🌐 HTTP-API aktiv: http://localhost:9999") handler := withCORS(mux) srv := &http.Server{ @@ -176,12 +500,19 @@ func main() { var shutdownOnce sync.Once shutdown := func() { shutdownOnce.Do(func() { - fmt.Println("🛑 Beende Server...") + appLogln("🛑 Shutdown gestartet") + + if aiProc != nil { + aiProc.Stop() + } + + appCancel() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _ = srv.Shutdown(ctx) + closeNSFW() }) } @@ -214,7 +545,7 @@ func main() { go func() { err := <-serverErrCh if err != nil { - fmt.Println("❌ HTTP-Server Fehler:", err) + appLogln("❌ HTTP-Server Fehler:", err) shutdown() } }() diff --git a/backend/settings.go b/backend/settings.go index 72a3c8b..daa7b63 100644 --- a/backend/settings.go +++ b/backend/settings.go @@ -4,7 +4,6 @@ package main import ( "encoding/json" - "fmt" "net/http" "os" "path/filepath" @@ -114,7 +113,7 @@ func getSettings() RecorderSettings { func loadSettings() { p := settingsFilePath() b, err := os.ReadFile(p) - fmt.Println("🔧 settingsFile:", p) + appLogln("🔧 settingsFile:", p) if err == nil { s := getSettings() // ✅ startet mit Defaults if json.Unmarshal(b, &s) == nil { @@ -200,10 +199,10 @@ func loadSettings() { // ffmpeg-Pfad anhand Settings/Env/PATH bestimmen ffmpegPath = detectFFmpegPath() - fmt.Println("🔍 ffmpegPath:", ffmpegPath) + appLogln("🔍 ffmpegPath:", ffmpegPath) ffprobePath = detectFFprobePath() - fmt.Println("🔍 ffprobePath:", ffprobePath) + appLogln("🔍 ffprobePath:", ffprobePath) } @@ -212,18 +211,18 @@ func saveSettingsToDisk() { b, err := json.MarshalIndent(s, "", " ") if err != nil { - fmt.Println("⚠️ settings marshal:", err) + appLogln("⚠️ settings marshal:", err) return } b = append(b, '\n') p := settingsFilePath() if err := atomicWriteFile(p, b); err != nil { - fmt.Println("⚠️ settings write:", err) + appLogln("⚠️ settings write:", err) return } // optional - // fmt.Println("✅ settings saved:", p) + // appLogln("✅ settings saved:", p) } type RecorderSettingsPublic struct { @@ -505,10 +504,10 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) { } else { ffmpegPath = detectFFmpegPath() } - //fmt.Println("🔍 ffmpegPath:", ffmpegPath) + //appLogln("🔍 ffmpegPath:", ffmpegPath) ffprobePath = detectFFprobePath() - //fmt.Println("🔍 ffprobePath:", ffprobePath) + //appLogln("🔍 ffprobePath:", ffprobePath) w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") diff --git a/backend/split.go b/backend/split.go index 4f0912e..323f278 100644 --- a/backend/split.go +++ b/backend/split.go @@ -378,10 +378,10 @@ type normalizedSegment struct { func normalizeSegments(raw []splitVideoSegmentIn, duration float64) ([]normalizedSegment, error) { if duration <= 0 { - return nil, fmt.Errorf("duration <= 0") + return nil, appErrorf("duration <= 0") } if len(raw) == 0 { - return nil, fmt.Errorf("keine segmente übergeben") + return nil, appErrorf("keine segmente übergeben") } out := make([]normalizedSegment, 0, len(raw)) @@ -415,7 +415,7 @@ func normalizeSegments(raw []splitVideoSegmentIn, duration float64) ([]normalize } if len(out) == 0 { - return nil, fmt.Errorf("keine gültigen segmente übrig") + return nil, appErrorf("keine gültigen segmente übrig") } sort.Slice(out, func(i, j int) bool { @@ -445,7 +445,7 @@ func normalizeSegments(raw []splitVideoSegmentIn, duration float64) ([]normalize } if len(dedup) == 0 { - return nil, fmt.Errorf("keine eindeutigen segmente übrig") + return nil, appErrorf("keine eindeutigen segmente übrig") } return dedup, nil @@ -474,7 +474,7 @@ func uniqueSplitOutPath(dir, fileName string) string { func normalizeSplitPoints(raw []float64, duration float64) ([]float64, error) { if duration <= 0 { - return nil, fmt.Errorf("duration <= 0") + return nil, appErrorf("duration <= 0") } out := make([]float64, 0, len(raw)) @@ -489,7 +489,7 @@ func normalizeSplitPoints(raw []float64, duration float64) ([]float64, error) { } if len(out) == 0 { - return nil, fmt.Errorf("alle split-punkte liegen außerhalb der videodauer") + return nil, appErrorf("alle split-punkte liegen außerhalb der videodauer") } sort.Float64s(out) @@ -502,7 +502,7 @@ func normalizeSplitPoints(raw []float64, duration float64) ([]float64, error) { } if len(dedup) == 0 { - return nil, fmt.Errorf("keine eindeutigen split-punkte übrig") + return nil, appErrorf("keine eindeutigen split-punkte übrig") } return dedup, nil @@ -555,13 +555,13 @@ func splitSingleSegment( onProgress func(ratio float64), ) error { if strings.TrimSpace(srcPath) == "" { - return fmt.Errorf("srcPath leer") + return appErrorf("srcPath leer") } if strings.TrimSpace(outPath) == "" { - return fmt.Errorf("outPath leer") + return appErrorf("outPath leer") } if durSec <= 0 { - return fmt.Errorf("dauer <= 0") + return appErrorf("dauer <= 0") } ext := strings.ToLower(filepath.Ext(outPath)) @@ -682,12 +682,12 @@ func splitSingleSegment( _ = os.Remove(tmpPath) if ctx.Err() != nil { - return fmt.Errorf("ffmpeg timeout nach %s: %w", timeout, ctx.Err()) + return appErrorf("ffmpeg timeout nach %s: %w", timeout, ctx.Err()) } msg := strings.TrimSpace(stderr.String()) if msg != "" { - return fmt.Errorf("%w (%s)", err, msg) + return appErrorf("%w (%s)", err, msg) } return err } @@ -695,12 +695,12 @@ func splitSingleSegment( fi, err := os.Stat(tmpPath) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { _ = os.Remove(tmpPath) - return fmt.Errorf("ffmpeg hat keine gültige datei erzeugt") + return appErrorf("ffmpeg hat keine gültige datei erzeugt") } if err := os.Rename(tmpPath, outPath); err != nil { _ = os.Remove(tmpPath) - return fmt.Errorf("rename fehlgeschlagen: %w", err) + return appErrorf("rename fehlgeschlagen: %w", err) } if onProgress != nil { diff --git a/backend/sse.go b/backend/sse.go index 4b67b62..5549b46 100644 --- a/backend/sse.go +++ b/backend/sse.go @@ -117,7 +117,7 @@ func publishAnalysisProgress(ev analysisProgressEvent) { publishSSE("analysisProgress", b) } -func publishAnalysisStarted(total int, message string) int64 { +func publishAnalysisStarted(file string, total int, message string) int64 { startedAtMs := time.Now().UnixMilli() publishAnalysisProgress(analysisProgressEvent{ @@ -126,6 +126,7 @@ func publishAnalysisStarted(total int, message string) int64 { Progress: 0, Current: 0, Total: total, + File: strings.TrimSpace(file), Message: message, StartedAtMs: startedAtMs, }) @@ -145,13 +146,13 @@ func publishAnalysisStep(startedAtMs int64, current int, total int, file string, Progress: progress, Current: current, Total: total, - File: file, + File: strings.TrimSpace(file), Message: message, StartedAtMs: startedAtMs, }) } -func publishAnalysisFinished(startedAtMs int64, total int, message string) { +func publishAnalysisFinished(startedAtMs int64, total int, file string, message string) { finishedAtMs := time.Now().UnixMilli() durationMs := finishedAtMs - startedAtMs if durationMs < 0 { @@ -164,6 +165,7 @@ func publishAnalysisFinished(startedAtMs int64, total int, message string) { Progress: 1, Current: total, Total: total, + File: strings.TrimSpace(file), Message: message, StartedAtMs: startedAtMs, FinishedAtMs: finishedAtMs, @@ -171,7 +173,7 @@ func publishAnalysisFinished(startedAtMs int64, total int, message string) { }) } -func publishAnalysisError(startedAtMs int64, message string, err error) { +func publishAnalysisError(startedAtMs int64, file string, message string, err error) { finishedAtMs := time.Now().UnixMilli() durationMs := finishedAtMs - startedAtMs if durationMs < 0 { @@ -187,6 +189,7 @@ func publishAnalysisError(startedAtMs int64, message string, err error) { Running: false, Phase: "error", Progress: 0, + File: strings.TrimSpace(file), Message: message, Error: errText, StartedAtMs: startedAtMs, diff --git a/backend/tasks_assets.go b/backend/tasks_assets.go index ba88410..c83adea 100644 --- a/backend/tasks_assets.go +++ b/backend/tasks_assets.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "net/http" "os" "path/filepath" @@ -462,9 +461,10 @@ func assetsTaskTruthForVideo(id string, videoPath string) finishedPhaseTruth { truth.TeaserReady = fileExistsNonEmpty(previewPath) truth.SpritesReady = fileExistsNonEmpty(spritePath) - // Analyse bleibt inhaltlich/meta-basiert + // Analyse gilt als fertig, wenn alle Standard-Ziele gespeichert sind. + // Auch "keine Treffer" zählt als fertig. if strings.TrimSpace(videoPath) != "" { - truth.AnalyzeReady = hasAIResultsForOutput(videoPath) + truth.AnalyzeReady = hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals()) } return truth @@ -551,7 +551,13 @@ func runGenerateMissingAssetsJob(jobID string, ctx context.Context) { doneAbs, err := resolvePathRelativeToApp(settings.DoneDir) if err != nil || strings.TrimSpace(doneAbs) == "" { - finishWithErr(fmt.Errorf("doneDir auflösung fehlgeschlagen: %v", err)) + if err == nil { + err = appErrorf("doneDir auflösung fehlgeschlagen: doneDir ist leer") + } else { + err = appErrorf("doneDir auflösung fehlgeschlagen: %w", err) + } + + finishWithErr(err) return } @@ -759,7 +765,7 @@ func runGenerateMissingAssetsJob(jobID string, ctx context.Context) { }) finishFileControl() - fmt.Println("⚠️ assetPathsForID:", perr) + appLogln("⚠️ assetPathsForID:", perr) continue } diff --git a/backend/tasks_check_video.go b/backend/tasks_check_video.go index 8af6cba..beb71e9 100644 --- a/backend/tasks_check_video.go +++ b/backend/tasks_check_video.go @@ -243,7 +243,7 @@ func finishCheckVideosTask(jobID string, err error) { func runCheckVideosTask(jobID string, ctx context.Context) { if err := acquireExclusiveTask(ctx); err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - finishCheckVideosTask(jobID, fmt.Errorf("abgebrochen")) + finishCheckVideosTask(jobID, appErrorf("abgebrochen")) } else { finishCheckVideosTask(jobID, err) } @@ -271,7 +271,7 @@ func runCheckVideosTask(jobID string, ctx context.Context) { for i, file := range files { select { case <-ctx.Done(): - finishCheckVideosTask(jobID, fmt.Errorf("abgebrochen")) + finishCheckVideosTask(jobID, appErrorf("abgebrochen")) return default: } @@ -369,7 +369,7 @@ func analyzeImageBoundsBlackness(img image.Image, bounds image.Rectangle) (avgLu w := b.Dx() h := b.Dy() if w <= 0 || h <= 0 { - return 0, 0, fmt.Errorf("invalid image bounds") + return 0, 0, appErrorf("invalid image bounds") } totalPixels := w * h @@ -403,7 +403,7 @@ func analyzeImageBoundsBlackness(img image.Image, bounds image.Rectangle) (avgLu } if count == 0 { - return 0, 0, fmt.Errorf("no sampled pixels") + return 0, 0, appErrorf("no sampled pixels") } avgLuma = sumLuma / float64(count) @@ -421,10 +421,10 @@ func analyzeJPGBlackness(jpg []byte) (avgLuma float64, blackRatio float64, err e func spriteCellBounds(img image.Image, cols, rows, index int) (image.Rectangle, error) { if cols <= 0 || rows <= 0 { - return image.Rectangle{}, fmt.Errorf("invalid sprite layout") + return image.Rectangle{}, appErrorf("invalid sprite layout") } if index < 0 || index >= cols*rows { - return image.Rectangle{}, fmt.Errorf("sprite index out of range") + return image.Rectangle{}, appErrorf("sprite index out of range") } b := img.Bounds() @@ -438,7 +438,7 @@ func spriteCellBounds(img image.Image, cols, rows, index int) (image.Rectangle, rect := image.Rect(x0, y0, x1, y1) if rect.Dx() <= 0 || rect.Dy() <= 0 { - return image.Rectangle{}, fmt.Errorf("invalid sprite cell bounds") + return image.Rectangle{}, appErrorf("invalid sprite cell bounds") } return rect, nil @@ -519,7 +519,7 @@ func uniqueTailTimes(times []float64, dur float64) []float64 { func inspectVideoTailBlackout(videoPath string) (*tailBlackoutResult, error) { assetID := assetIDFromVideoPath(videoPath) if assetID == "" { - return nil, fmt.Errorf("asset id fehlt") + return nil, appErrorf("asset id fehlt") } _, _, _, spritePath, metaPath, err := assetPathsForID(assetID) @@ -529,13 +529,13 @@ func inspectVideoTailBlackout(videoPath string) (*tailBlackoutResult, error) { f, err := os.Open(spritePath) if err != nil { - return nil, fmt.Errorf("sprite open failed: %w", err) + return nil, appErrorf("sprite open failed: %w", err) } defer f.Close() img, err := jpeg.Decode(f) if err != nil { - return nil, fmt.Errorf("sprite decode failed: %w", err) + return nil, appErrorf("sprite decode failed: %w", err) } cols, rows, count, _, _ := fixedPreviewSpriteLayout() @@ -558,7 +558,7 @@ func inspectVideoTailBlackout(videoPath string) (*tailBlackoutResult, error) { maxCells := cols * rows if maxCells <= 0 { - return nil, fmt.Errorf("invalid sprite layout") + return nil, appErrorf("invalid sprite layout") } if count <= 0 { count = maxCells @@ -567,7 +567,7 @@ func inspectVideoTailBlackout(videoPath string) (*tailBlackoutResult, error) { count = maxCells } if count < 2 { - return nil, fmt.Errorf("sprite enthält zu wenige frames") + return nil, appErrorf("sprite enthält zu wenige frames") } res := &tailBlackoutResult{ @@ -641,7 +641,7 @@ func persistTailBlackoutResult(videoPath string, res *tailBlackoutResult) error assetID := assetIDFromVideoPath(videoPath) if assetID == "" { - return fmt.Errorf("asset id fehlt") + return appErrorf("asset id fehlt") } _, _, _, _, metaPath, err := assetPathsForID(assetID) diff --git a/backend/tasks_cleanup.go b/backend/tasks_cleanup.go index 94bc8d8..513c286 100644 --- a/backend/tasks_cleanup.go +++ b/backend/tasks_cleanup.go @@ -493,7 +493,7 @@ func removeDirIfEmpty(dir string) (bool, error) { func triggerGeneratedGarbageCollectorSync() generatedGCStats { // nur 1 GC gleichzeitig if !atomic.CompareAndSwapInt32(&generatedGCRunning, 0, 1) { - fmt.Println("🧹 [gc] skip: already running") + appLogln("🧹 [gc] skip: already running") return generatedGCStats{} } defer atomic.StoreInt32(&generatedGCRunning, 0) @@ -580,7 +580,7 @@ func runGeneratedGarbageCollector() generatedGCStats { doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { - fmt.Println("🧹 [gc] resolve doneDir failed:", err) + appLogln("🧹 [gc] resolve doneDir failed:", err) return stats } doneAbs = strings.TrimSpace(doneAbs) diff --git a/backend/tasks_regenerate_assets.go b/backend/tasks_regenerate_assets.go index 0f2d7c2..d17362f 100644 --- a/backend/tasks_regenerate_assets.go +++ b/backend/tasks_regenerate_assets.go @@ -85,9 +85,13 @@ type regeneratePhaseTruth struct { AnalyzeReady bool } -func regeneratePhaseTruthForVideo(videoPath string, assetID string) regeneratePhaseTruth { +func regeneratePhaseTruthForVideo(videoPath string, assetID string, goal string) regeneratePhaseTruth { videoPath = strings.TrimSpace(videoPath) assetID = strings.TrimSpace(assetID) + goal = strings.ToLower(strings.TrimSpace(goal)) + if goal == "" { + goal = "nsfw" + } if assetID == "" && videoPath != "" { assetID = assetIDFromVideoPath(videoPath) @@ -120,15 +124,15 @@ func regeneratePhaseTruthForVideo(videoPath string, assetID string) regeneratePh } } - // KI nur dann fertig, wenn Sprite existiert UND AI in meta.json vorhanden ist - out.AnalyzeReady = out.SpritesReady && hasAIResultsForOutput(videoPath) + // KI nur dann fertig, wenn Sprite existiert UND passende AI in meta.json vorhanden ist + out.AnalyzeReady = out.SpritesReady && hasAIAnalysisForOutputGoal(videoPath, goal) } return out } func firstMissingPrimaryRegeneratePhaseError(videoPath string, assetID string) error { - truth := regeneratePhaseTruthForVideo(videoPath, assetID) + truth := regeneratePhaseTruthForVideo(videoPath, assetID, "nsfw") if !truth.MetaReady { return errors.New("meta.json fehlt oder ist noch unvollständig") @@ -143,7 +147,7 @@ func firstMissingPrimaryRegeneratePhaseError(videoPath string, assetID string) e } func firstMissingDeferredRegeneratePhaseError(videoPath string, assetID string) error { - truth := regeneratePhaseTruthForVideo(videoPath, assetID) + truth := regeneratePhaseTruthForVideo(videoPath, assetID, "nsfw") if !truth.SpritesReady { return errors.New("preview-sprite.jpg fehlt oder ist leer") @@ -329,6 +333,8 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) { videoPath := job.VideoPath id := job.AssetID ctx := job.Ctx + requiredGoals := requiredAnalyzeGoals() + primaryGoal := "nsfw" setRegenerateAssetsTaskRunning(file, file) cancelDeferredEnrichForFile(file) @@ -390,7 +396,7 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) { return } - if !regeneratePhaseTruthForVideo(videoPath, id).MetaReady { + if !regeneratePhaseTruthForVideo(videoPath, id, primaryGoal).MetaReady { failJob("postwork", "meta", "meta.json fehlt oder ist noch unvollständig") return } @@ -409,7 +415,7 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) { return } - if !regeneratePhaseTruthForVideo(videoPath, id).ThumbReady { + if !regeneratePhaseTruthForVideo(videoPath, id, primaryGoal).ThumbReady { failJob("postwork", "thumb", "preview.jpg fehlt oder ist leer") return } @@ -428,7 +434,7 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) { return } - if !regeneratePhaseTruthForVideo(videoPath, id).TeaserReady { + if !regeneratePhaseTruthForVideo(videoPath, id, primaryGoal).TeaserReady { failJob("postwork", "teaser", "preview.mp4 fehlt oder ist leer") return } @@ -447,7 +453,7 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) { return } - if !regeneratePhaseTruthForVideo(videoPath, id).SpritesReady { + if !regeneratePhaseTruthForVideo(videoPath, id, primaryGoal).SpritesReady { failJob("postwork", "sprites", "preview-sprite.jpg fehlt oder ist leer") return } @@ -457,7 +463,7 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) { // 5) Analyse publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "Analyse", "") - if _, err := ensureAnalyzeForVideoCtx(ctx, videoPath, "", "nsfw"); err != nil { + if _, err := ensureAnalyzeAllGoalsForVideoCtxForce(ctx, videoPath, "", true); err != nil { if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled { cancelJob() return @@ -466,7 +472,15 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) { return } - if !regeneratePhaseTruthForVideo(videoPath, id).AnalyzeReady { + allAnalyzeReady := true + for _, goal := range requiredGoals { + if !hasAIAnalysisForOutputGoal(videoPath, goal) { + allAnalyzeReady = false + break + } + } + + if !allAnalyzeReady { failJob("enrich", "analyze", "Analyse fehlt oder wurde nicht in meta.json gespeichert") return } diff --git a/backend/tasks_status.go b/backend/tasks_status.go index 39c3cc8..e4fc307 100644 --- a/backend/tasks_status.go +++ b/backend/tasks_status.go @@ -47,12 +47,26 @@ type RegenerateAssetsTaskState struct { Error string `json:"error,omitempty"` } +type GenerateAssetsLiveStatus struct { + Running bool `json:"running"` + Queued bool `json:"queued"` + CurrentFile string `json:"currentFile,omitempty"` + CurrentQueue string `json:"currentQueue,omitempty"` + CurrentPhase string `json:"currentPhase,omitempty"` + CurrentLabel string `json:"currentLabel,omitempty"` + Progress float64 `json:"progress,omitempty"` + Current int `json:"current,omitempty"` + Total int `json:"total,omitempty"` + TS int64 `json:"ts,omitempty"` +} + type TasksStatusResponse struct { - GenerateAssetsJobs []AssetsTaskState `json:"generateAssetsJobs"` - RegenerateAssetsJobs []map[string]any `json:"regenerateAssetsJobs"` - CheckVideosJobs []CheckVideosTaskState `json:"checkVideosJobs"` - CleanupJobs []CleanupTaskState `json:"cleanupJobs"` - AnyRunning bool `json:"anyRunning"` + GenerateAssets GenerateAssetsLiveStatus `json:"generateAssets"` + GenerateAssetsJobs []AssetsTaskState `json:"generateAssetsJobs"` + RegenerateAssetsJobs []map[string]any `json:"regenerateAssetsJobs"` + CheckVideosJobs []CheckVideosTaskState `json:"checkVideosJobs"` + CleanupJobs []CleanupTaskState `json:"cleanupJobs"` + AnyRunning bool `json:"anyRunning"` } var ( @@ -86,6 +100,268 @@ func anyToString(v any) string { } } +func anyToBool(v any) bool { + switch x := v.(type) { + case bool: + return x + case string: + s := strings.TrimSpace(strings.ToLower(x)) + return s == "1" || s == "true" || s == "yes" || s == "running" || s == "queued" + default: + return false + } +} + +func anyToInt(v any) int { + switch x := v.(type) { + case int: + return x + case int64: + return int(x) + case float64: + return int(x) + case json.Number: + n, _ := x.Int64() + return int(n) + default: + return 0 + } +} + +func anyToFloat64(v any) float64 { + switch x := v.(type) { + case float64: + return x + case float32: + return float64(x) + case int: + return float64(x) + case int64: + return float64(x) + case json.Number: + n, _ := x.Float64() + return n + default: + return 0 + } +} + +func anyToMap(v any) map[string]any { + if m, ok := v.(map[string]any); ok { + return m + } + + b, err := json.Marshal(v) + if err != nil { + return nil + } + + var out map[string]any + if err := json.Unmarshal(b, &out); err != nil { + return nil + } + + return out +} + +func taskFirstString(m map[string]any, keys ...string) string { + for _, key := range keys { + if s := strings.TrimSpace(anyToString(m[key])); s != "" { + return s + } + } + return "" +} + +func firstFloat(m map[string]any, keys ...string) float64 { + for _, key := range keys { + if v, ok := m[key]; ok { + f := anyToFloat64(v) + if f != 0 { + return f + } + } + } + return 0 +} + +func normalizeTaskProgress(v float64) float64 { + if v > 1 && v <= 100 { + v = v / 100 + } + if v < 0 { + return 0 + } + if v > 1 { + return 1 + } + return v +} + +func taskStepLabel(queue, phase string) string { + queue = strings.TrimSpace(strings.ToLower(queue)) + phase = strings.TrimSpace(strings.ToLower(phase)) + + switch queue + ":" + phase { + case "postwork:meta": + return "Meta" + case "postwork:thumb": + return "Vorschaubild" + case "postwork:teaser", "postwork:assets": + return "Teaser" + case "postwork:sprites": + return "Sprites" + case "enrich:analyze", "enrich:assets", "enrich:": + return "Analyse" + default: + if queue == "enrich" { + return "Analyse" + } + if phase != "" { + return phase + } + return "Meta" + } +} + +func taskLabelWithProgress(queue, phase, label string, progress float64) string { + queue = strings.TrimSpace(strings.ToLower(queue)) + phase = strings.TrimSpace(strings.ToLower(phase)) + label = strings.TrimSpace(label) + progress = normalizeTaskProgress(progress) + + isAnalyze := queue == "enrich" && (phase == "" || phase == "analyze" || phase == "assets") + + if isAnalyze && progress > 0 { + return fmt.Sprintf("Analyse %d%%", int(progress*100+0.5)) + } + + if label != "" { + return label + } + + return taskStepLabel(queue, phase) +} + +func liveStatusFromJobMap(m map[string]any) GenerateAssetsLiveStatus { + if m == nil { + return GenerateAssetsLiveStatus{} + } + + state := strings.TrimSpace(strings.ToLower(taskFirstString(m, "state", "status"))) + running := anyToBool(m["running"]) || state == "running" + queued := anyToBool(m["queued"]) || state == "queued" + + if !running && !queued { + return GenerateAssetsLiveStatus{} + } + + file := taskFirstString(m, "currentFile", "file") + queue := strings.TrimSpace(strings.ToLower(taskFirstString(m, "currentQueue", "queue"))) + phase := strings.TrimSpace(strings.ToLower(taskFirstString(m, "currentPhase", "phase"))) + label := taskFirstString(m, "currentLabel", "label", "text", "message") + + current := anyToInt(m["current"]) + total := anyToInt(m["total"]) + + progress := firstFloat(m, "progress", "overallProgress", "overall") + progress = normalizeTaskProgress(progress) + + if progress == 0 && total > 0 && current > 0 { + progress = normalizeTaskProgress(float64(current) / float64(total)) + } + + if queue == "" { + queue = "postwork" + } + if queue == "enrich" && (phase == "" || phase == "assets") { + phase = "analyze" + } + + return GenerateAssetsLiveStatus{ + Running: running, + Queued: queued, + CurrentFile: file, + CurrentQueue: queue, + CurrentPhase: phase, + CurrentLabel: taskLabelWithProgress(queue, phase, label, progress), + Progress: progress, + Current: current, + Total: total, + TS: time.Now().UnixMilli(), + } +} + +func liveStatusFromRegenerateJob(job map[string]any) GenerateAssetsLiveStatus { + if job == nil { + return GenerateAssetsLiveStatus{} + } + + file := taskFirstString(job, "currentFile", "file") + assetID := taskFirstString(job, "assetId") + + rawPhases, _ := job["phases"].([]any) + + for _, raw := range rawPhases { + p := anyToMap(raw) + if p == nil { + continue + } + + state := strings.TrimSpace(strings.ToLower(taskFirstString(p, "state", "status"))) + if state != "running" && state != "queued" { + continue + } + + if file != "" { + p["file"] = file + } + if assetID != "" { + p["assetId"] = assetID + } + + return liveStatusFromJobMap(p) + } + + return liveStatusFromJobMap(job) +} + +func buildGenerateAssetsLiveStatus( + assetsJobs []AssetsTaskState, + regenerateJobs []map[string]any, +) GenerateAssetsLiveStatus { + // Running gewinnt vor queued. + for _, st := range assetsJobs { + status := liveStatusFromJobMap(anyToMap(st)) + if status.Running { + return status + } + } + + for _, job := range regenerateJobs { + status := liveStatusFromRegenerateJob(job) + if status.Running { + return status + } + } + + for _, st := range assetsJobs { + status := liveStatusFromJobMap(anyToMap(st)) + if status.Queued { + return status + } + } + + for _, job := range regenerateJobs { + status := liveStatusFromRegenerateJob(job) + if status.Queued { + return status + } + } + + return GenerateAssetsLiveStatus{} +} + func snapshotRegenerateAssetsTaskState() RegenerateAssetsTaskState { regenerateAssetsTaskMu.Lock() st := regenerateAssetsTaskState @@ -261,6 +537,7 @@ func tasksStatusHandler(w http.ResponseWriter, r *http.Request) { regenerateJobs := getRegenerateAssetsJobsSnapshot() checkVideosJobs := snapshotCheckVideosJobs() cleanupJobs := snapshotCleanupJobs() + generateAssets := buildGenerateAssetsLiveStatus(assetsJobs, regenerateJobs) anyRunning := false @@ -302,6 +579,7 @@ func tasksStatusHandler(w http.ResponseWriter, r *http.Request) { } resp := TasksStatusResponse{ + GenerateAssets: generateAssets, GenerateAssetsJobs: assetsJobs, RegenerateAssetsJobs: regenerateJobs, CheckVideosJobs: checkVideosJobs, diff --git a/backend/teaser.go b/backend/teaser.go index 95026cd..01cec9e 100644 --- a/backend/teaser.go +++ b/backend/teaser.go @@ -159,7 +159,7 @@ func generateTeaserChunkMP4( cmd.Stderr = &stderr if err := cmd.Run(); err != nil { _ = os.Remove(tmp) - return fmt.Errorf("ffmpeg teaser chunk failed: %v (%s)", err, strings.TrimSpace(stderr.String())) + return appErrorf("ffmpeg teaser chunk failed: %v (%s)", err, strings.TrimSpace(stderr.String())) } _ = os.Remove(out) return os.Rename(tmp, out) @@ -377,7 +377,7 @@ func generateTeaserPreviewMP4WithProgress( if err := cmd.Wait(); err != nil { _ = os.Remove(tmp) - return fmt.Errorf("ffmpeg teaser preview failed: %v (%s)", err, strings.TrimSpace(stderr.String())) + return appErrorf("ffmpeg teaser preview failed: %v (%s)", err, strings.TrimSpace(stderr.String())) } _ = os.Remove(outPath) @@ -461,7 +461,7 @@ func generateTeaserMP4(ctx context.Context, srcPath, outPath string, startSec, d if out, err := cmd.CombinedOutput(); err != nil { _ = os.Remove(tmp) - return fmt.Errorf("ffmpeg teaser failed: %v (%s)", err, strings.TrimSpace(string(out))) + return appErrorf("ffmpeg teaser failed: %v (%s)", err, strings.TrimSpace(string(out))) } _ = os.Remove(outPath) diff --git a/backend/training.go b/backend/training.go index a7a75b4..0a4b76e 100644 --- a/backend/training.go +++ b/backend/training.go @@ -4,6 +4,7 @@ package main import ( "bufio" + "context" "crypto/sha1" "encoding/hex" "encoding/json" @@ -394,19 +395,19 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) { var startedAtMs int64 if refreshPrediction { - startedAtMs = publishAnalysisStarted(2, "Aktuelles Bild wird neu analysiert…") + startedAtMs = publishAnalysisStarted("", 2, "Aktuelles Bild wird neu analysiert…") } if sample, ok, err := trainingLatestOpenSample(root, refreshPrediction, startedAtMs); err != nil { if refreshPrediction { - publishAnalysisError(startedAtMs, "Aktuelles Bild konnte nicht neu analysiert werden.", err) + publishAnalysisError(startedAtMs, "", "Aktuelles Bild konnte nicht neu analysiert werden.", err) } trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } else if ok { if refreshPrediction { - publishAnalysisFinished(startedAtMs, 2, "Analyse abgeschlossen.") + publishAnalysisFinished(startedAtMs, 2, sample.SourceFile, "Analyse abgeschlossen.") } trainingWriteJSON(w, http.StatusOK, sample) @@ -414,16 +415,16 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) { } } - startedAtMs := publishAnalysisStarted(4, "Neues Trainingsbild wird vorbereitet…") + startedAtMs := publishAnalysisStarted("", 4, "Neues Trainingsbild wird vorbereitet…") sample, err := trainingCreateNextSampleWithProgress(startedAtMs) if err != nil { - publishAnalysisError(startedAtMs, "Trainingsbild konnte nicht erstellt werden.", err) + publishAnalysisError(startedAtMs, "", "Trainingsbild konnte nicht erstellt werden.", err) trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } - publishAnalysisFinished(startedAtMs, 4, "Analyse abgeschlossen.") + publishAnalysisFinished(startedAtMs, 4, sample.SourceFile, "Analyse abgeschlossen.") trainingWriteJSON(w, http.StatusOK, sample) } @@ -494,6 +495,35 @@ func trainingLatestOpenSample(root string, refreshPrediction bool, startedAtMs i continue } + if refreshPrediction { + sourceFile := strings.TrimSpace(sample.SourceFile) + if sourceFile == "" { + sourceFile = filepath.Base(sample.SourcePath) + } + + publishAnalysisStep( + startedAtMs, + 1, + 2, + sourceFile, + "Aktuelles Bild wird analysiert…", + ) + + sample.Prediction = trainingPredictFrame(framePath) + + publishAnalysisStep( + startedAtMs, + 2, + 2, + sourceFile, + "Analyse-Ergebnis wird gespeichert…", + ) + + if err := trainingWriteSample(root, sample); err != nil { + return nil, false, err + } + } + return sample, true, nil } @@ -627,7 +657,7 @@ func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) { if len(detectorBoxes) > 0 { if err := trainingWriteDetectorSample(root, sample, detectorBoxes); err != nil { - fmt.Println("⚠️ detector sample write failed:", err) + appLogln("⚠️ detector sample write failed:", err) } } @@ -703,7 +733,7 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) { // ein Validation-Sample durch Kopieren. Bei mehr Daten solltest du später // einen echten 80/20 Split verwenden. if err := trainingEnsureDetectorValidationSample(root); err != nil { - fmt.Println("⚠️ detector val sample ensure failed:", err) + appLogln("⚠️ detector val sample ensure failed:", err) } feedbackPath := filepath.Join(root, "feedback.jsonl") @@ -799,15 +829,15 @@ func trainingRunJob(root string, count int) { if sceneErr != nil { sceneStatus = "failed" - fmt.Println("⚠️ scene position training failed:", sceneErr) + appLogln("⚠️ scene position training failed:", sceneErr) if sceneOutputClean != "" { - fmt.Println("⚠️ scene position output:", sceneOutputClean) + appLogln("⚠️ scene position output:", sceneOutputClean) } } else { sceneStatus = "trained" if sceneOutputClean != "" { - fmt.Println("✅ scene position training:", sceneOutputClean) + appLogln("✅ scene position training:", sceneOutputClean) } } @@ -826,7 +856,7 @@ func trainingRunJob(root string, count int) { detectorValLabels := filepath.Join(root, "detector", "dataset", "labels", "val") if err := trainingEnsureDetectorValidationSample(root); err != nil { - fmt.Println("⚠️ detector val sample ensure failed:", err) + appLogln("⚠️ detector val sample ensure failed:", err) } trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels) @@ -860,7 +890,7 @@ func trainingRunJob(root string, count int) { ) }, "--root", root, - "--base", "yolo11n.pt", + "--base", "yolo26n.pt", "--epochs", strconv.Itoa(trainingDetectorEpochs()), "--imgsz", "640", ) @@ -871,15 +901,15 @@ func trainingRunJob(root string, count int) { if detectorErr != nil { detectorStatus = "failed" - fmt.Println("⚠️ detector training failed:", detectorErr) + appLogln("⚠️ detector training failed:", detectorErr) if detectorOutputClean != "" { - fmt.Println("⚠️ detector output:", detectorOutputClean) + appLogln("⚠️ detector output:", detectorOutputClean) } } else { detectorStatus = "trained" if detectorOutputClean != "" { - fmt.Println("✅ detector training:", detectorOutputClean) + appLogln("✅ detector training:", detectorOutputClean) } } } else { @@ -892,7 +922,7 @@ func trainingRunJob(root string, count int) { minDetectorValCount, ) - fmt.Println("⚠️", detectorOutput) + appLogln("⚠️", detectorOutput) } detectorOutputClean := cleanOutput(detectorOutput) @@ -998,7 +1028,7 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) { } if err := trainingEnsureDetectorValidationSample(root); err != nil { - fmt.Println("⚠️ detector val sample ensure failed:", err) + appLogln("⚠️ detector val sample ensure failed:", err) } } @@ -1735,7 +1765,7 @@ func trainingCreateNextSample() (*TrainingSample, error) { framePath = filepath.Join(root, "frames", id+".jpg") if err2 := trainingExtractFrame(videoPath, framePath, second); err2 != nil { - return nil, fmt.Errorf("frame extraction failed: %v / fallback: %w", err, err2) + return nil, appErrorf("frame extraction failed: %v / fallback: %w", err, err2) } } @@ -1809,7 +1839,7 @@ func trainingCreateNextSampleWithProgress(startedAtMs int64) (*TrainingSample, e framePath = filepath.Join(root, "frames", id+".jpg") if err2 := trainingExtractFrame(videoPath, framePath, second); err2 != nil { - return nil, fmt.Errorf("frame extraction failed: %v / fallback: %w", err, err2) + return nil, appErrorf("frame extraction failed: %v / fallback: %w", err, err2) } } @@ -1914,7 +1944,7 @@ func trainingExtractFrame(videoPath string, framePath string, second float64) er out, err := cmd.CombinedOutput() if err != nil { - return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) + return appErrorf("%w: %s", err, strings.TrimSpace(string(out))) } if st, err := os.Stat(framePath); err != nil || st.Size() == 0 { @@ -1932,7 +1962,7 @@ func trainingPredictFrame(framePath string) TrainingPrediction { root, err := trainingRootDir() if err != nil { - fmt.Println("⚠️ training predict root error:", err) + appLogln("⚠️ training predict root error:", err) return trainingEmptyPrediction("root_error") } @@ -1947,6 +1977,172 @@ func trainingPredictFrame(framePath string) TrainingPrediction { return pred } +func trainingPredictFrameDetectorOnly(framePath string) TrainingPrediction { + settings := getSettings() + if !settings.TrainingRecognitionEnabled { + return trainingEmptyPrediction("recognition_disabled") + } + + root, err := trainingRootDir() + if err != nil { + appLogln("⚠️ training predict root error:", err) + return trainingEmptyPrediction("root_error") + } + + det := trainingPredictDetector(root, framePath) + return trainingPredictionFromDetector(det) +} + +type TrainingSpriteBatchCandidate struct { + Index int `json:"index"` + Time float64 `json:"time"` +} + +type TrainingSpriteBatchRequest struct { + SpritePath string `json:"spritePath"` + Cols int `json:"cols"` + Rows int `json:"rows"` + Count int `json:"count"` + Candidates []TrainingSpriteBatchCandidate `json:"candidates"` +} + +type TrainingSpriteBatchResult struct { + Index int `json:"index"` + Time float64 `json:"time"` + Prediction TrainingPrediction `json:"prediction"` + Error string `json:"error,omitempty"` +} + +type TrainingSpriteBatchResponse struct { + OK bool `json:"ok"` + Results []TrainingSpriteBatchResult `json:"results"` + Error string `json:"error,omitempty"` +} + +func trainingPredictSpriteBatch( + ctx context.Context, + spritePath string, + ps previewSpriteMetaFileInfo, + candidates []spriteFrameCandidate, +) ([]TrainingSpriteBatchResult, error) { + spritePath = strings.TrimSpace(spritePath) + if spritePath == "" { + return nil, appErrorf("spritePath fehlt") + } + + if !trainingRecognitionEnabled() { + out := make([]TrainingSpriteBatchResult, 0, len(candidates)) + for _, c := range candidates { + out = append(out, TrainingSpriteBatchResult{ + Index: c.Index, + Time: c.Time, + Prediction: trainingEmptyPrediction("recognition_disabled"), + }) + } + return out, nil + } + + if ps.Cols <= 0 || ps.Rows <= 0 || ps.Count <= 0 { + return nil, appErrorf("ungültige sprite meta") + } + + reqCandidates := make([]TrainingSpriteBatchCandidate, 0, len(candidates)) + for _, c := range candidates { + if c.Index < 0 || c.Index >= ps.Count { + continue + } + reqCandidates = append(reqCandidates, TrainingSpriteBatchCandidate{ + Index: c.Index, + Time: c.Time, + }) + } + + if len(reqCandidates) == 0 { + return []TrainingSpriteBatchResult{}, nil + } + + tmp, err := os.CreateTemp("", "sprite-batch-request-*.json") + if err != nil { + return nil, err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + req := TrainingSpriteBatchRequest{ + SpritePath: spritePath, + Cols: ps.Cols, + Rows: ps.Rows, + Count: ps.Count, + Candidates: reqCandidates, + } + + enc := json.NewEncoder(tmp) + if err := enc.Encode(req); err != nil { + _ = tmp.Close() + return nil, err + } + if err := tmp.Close(); err != nil { + return nil, err + } + + python := trainingPythonExe() + script := trainingScriptPath("predict_sprite_batch.py") + + cmd := exec.CommandContext( + ctx, + python, + script, + "--input", + tmpPath, + ) + + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, + } + + var stdout strings.Builder + var stderr strings.Builder + + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + + outText := strings.TrimSpace(stdout.String()) + errText := strings.TrimSpace(stderr.String()) + + if errText != "" { + appLogln("🔎 sprite batch stderr:", errText) + } + + if err != nil { + return nil, appErrorf("sprite batch predict failed: %w: %s", err, errText) + } + + if outText == "" { + return nil, appErrorf("sprite batch predict returned empty stdout") + } + + var resp TrainingSpriteBatchResponse + if err := json.Unmarshal([]byte(outText), &resp); err != nil { + return nil, appErrorf("sprite batch json failed: %w: %s", err, outText) + } + + if !resp.OK { + if strings.TrimSpace(resp.Error) != "" { + return nil, appErrorf("%s", resp.Error) + } + return nil, appErrorf("sprite batch predict failed") + } + + if resp.Results == nil { + resp.Results = []TrainingSpriteBatchResult{} + } + + return resp.Results, nil +} + func trainingPredictScenePosition(root string, framePath string) TrainingScenePositionPrediction { python := trainingPythonExe() script := trainingScriptPath("predict_scene_model.py") @@ -1987,9 +2183,9 @@ func trainingPredictScenePosition(root string, framePath string) TrainingScenePo errText := strings.TrimSpace(stderr.String()) if err != nil { - fmt.Println("⚠️ scene position predict failed:", err) - fmt.Println(" stdout:", outText) - fmt.Println(" stderr:", errText) + appLogln("⚠️ scene position predict failed:", err) + appLogln(" stdout:", outText) + appLogln(" stderr:", errText) return TrainingScenePositionPrediction{ Available: false, @@ -2000,7 +2196,7 @@ func trainingPredictScenePosition(root string, framePath string) TrainingScenePo } if outText == "" { - fmt.Println("⚠️ scene position predict empty stdout") + appLogln("⚠️ scene position predict empty stdout") return TrainingScenePositionPrediction{ Available: false, @@ -2012,8 +2208,8 @@ func trainingPredictScenePosition(root string, framePath string) TrainingScenePo var scenePred TrainingPrediction if err := json.Unmarshal([]byte(outText), &scenePred); err != nil { - fmt.Println("⚠️ scene position json failed:", err) - fmt.Println(" stdout:", outText) + appLogln("⚠️ scene position json failed:", err) + appLogln(" stdout:", outText) return TrainingScenePositionPrediction{ Available: false, @@ -2099,7 +2295,7 @@ func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPred grouped, err := trainingGroupedLabels() if err != nil { - fmt.Println("⚠️ detector label grouping failed:", err) + appLogln("⚠️ detector label grouping failed:", err) return pred } @@ -2197,7 +2393,7 @@ func trainingPredictDetector(root string, framePath string) TrainingDetectorPred } } - confValues := []string{"0.40", "0.30", "0.20"} + confValues := []string{"0.30"} best := TrainingDetectorPrediction{ Available: true, @@ -2232,31 +2428,31 @@ func trainingPredictDetector(root string, framePath string) TrainingDetectorPred errText := strings.TrimSpace(stderr.String()) if errText != "" { - fmt.Println("🔎 detector stderr:", errText) + appLogln("🔎 detector stderr:", errText) } if err != nil { - fmt.Println("⚠️ detector predict failed") - fmt.Println(" conf:", conf) - fmt.Println(" error:", err) - fmt.Println(" stdout:", outText) - fmt.Println(" stderr:", errText) + appLogln("⚠️ detector predict failed") + appLogln(" conf:", conf) + appLogln(" error:", err) + appLogln(" stdout:", outText) + appLogln(" stderr:", errText) continue } if outText == "" { - fmt.Println("⚠️ detector predict empty stdout") - fmt.Println(" conf:", conf) - fmt.Println(" stderr:", errText) + appLogln("⚠️ detector predict empty stdout") + appLogln(" conf:", conf) + appLogln(" stderr:", errText) continue } var det TrainingDetectorPrediction if err := json.Unmarshal([]byte(outText), &det); err != nil { - fmt.Println("⚠️ detector predict json failed:", err) - fmt.Println(" conf:", conf) - fmt.Println(" stdout:", outText) - fmt.Println(" stderr:", errText) + appLogln("⚠️ detector predict json failed:", err) + appLogln(" conf:", conf) + appLogln(" stdout:", outText) + appLogln(" stderr:", errText) continue } @@ -2341,7 +2537,7 @@ func trainingApplyDetectorToPrediction(pred TrainingPrediction, det TrainingDete grouped, err := trainingGroupedLabels() if err != nil { - fmt.Println("⚠️ detector label grouping failed:", err) + appLogln("⚠️ detector label grouping failed:", err) pred.Boxes = boxes pred.BodyPartsPresent = []TrainingScoredLabel{} @@ -2382,7 +2578,7 @@ func trainingWriteDetectorSample(root string, sample *TrainingSample, boxes []Tr srcFrame := filepath.Join(root, "frames", sample.SampleID+".jpg") if _, err := os.Stat(srcFrame); err != nil { - return fmt.Errorf("frame missing: %w", err) + return appErrorf("frame missing: %w", err) } // Stabiler 80/20 Split: gleicher sampleID landet immer im gleichen Split. diff --git a/backend/training_label_loader.go b/backend/training_label_loader.go index c51a28d..8bb91ec 100644 --- a/backend/training_label_loader.go +++ b/backend/training_label_loader.go @@ -50,12 +50,12 @@ func trainingGroupedLabels() (TrainingGroupedLabels, error) { b, err := os.ReadFile(path) if err != nil { - return TrainingGroupedLabels{}, fmt.Errorf("detection_labels.json konnte nicht gelesen werden: %w", err) + return TrainingGroupedLabels{}, appErrorf("detection_labels.json konnte nicht gelesen werden: %w", err) } var grouped TrainingGroupedLabels if err := json.Unmarshal(b, &grouped); err != nil { - return TrainingGroupedLabels{}, fmt.Errorf("detection_labels.json ist ungültig: %w", err) + return TrainingGroupedLabels{}, appErrorf("detection_labels.json ist ungültig: %w", err) } grouped.People = uniqueNonEmptyLabels(grouped.People) @@ -69,11 +69,11 @@ func trainingGroupedLabels() (TrainingGroupedLabels, error) { } if len(grouped.BodyParts)+len(grouped.Objects)+len(grouped.Clothing) == 0 { - return TrainingGroupedLabels{}, fmt.Errorf("detection_labels.json enthält keine Detection-Labels") + return TrainingGroupedLabels{}, appErrorf("detection_labels.json enthält keine Detection-Labels") } if len(grouped.People)+len(grouped.BodyParts)+len(grouped.Objects)+len(grouped.Clothing) == 0 { - return TrainingGroupedLabels{}, fmt.Errorf("detection_labels.json enthält keine Detection-Labels") + return TrainingGroupedLabels{}, appErrorf("detection_labels.json enthält keine Detection-Labels") } return grouped, nil diff --git a/backend/yolo11n.pt b/backend/yolo11n.pt deleted file mode 100644 index 45b273b..0000000 Binary files a/backend/yolo11n.pt and /dev/null differ diff --git a/backend/yolo26n.pt b/backend/yolo26n.pt new file mode 100644 index 0000000..be48188 Binary files /dev/null and b/backend/yolo26n.pt differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 34d5c34..2c4235e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -127,6 +127,22 @@ type TaskStateEvent = { ts?: number } +type AnalysisProgressEvent = { + type?: 'analysis_progress' + running?: boolean + phase?: 'starting' | 'running' | 'done' | 'error' | string + progress?: number + current?: number + total?: number + file?: string + message?: string + error?: string + startedAtMs?: number + finishedAtMs?: number + durationMs?: number + ts?: number +} + type BackgroundSplitProgress = { phase: 'preparing' | 'splitting' | 'done' | 'error' total: number @@ -2822,6 +2838,75 @@ export default function App() { } catch {} } + const onAnalysisProgress = (ev: MessageEvent) => { + try { + const msg = JSON.parse(String(ev.data ?? 'null')) as AnalysisProgressEvent + + if (msg?.type !== 'analysis_progress') return + + const file = baseName(String(msg?.file ?? '').trim()) + if (!file) return + + const phase = String(msg?.phase ?? '').trim().toLowerCase() + const progressRaw = Number(msg?.progress ?? 0) + const progress = Number.isFinite(progressRaw) + ? Math.max(0, Math.min(1, progressRaw)) + : 0 + + const totalRaw = Number(msg?.total ?? 0) + + const total = + Number.isFinite(totalRaw) && totalRaw > 0 + ? Math.floor(totalRaw) + : 0 + + const percent = Math.round(progress * 100) + + const state = + phase === 'error' + ? 'error' + : phase === 'done' + ? 'done' + : 'running' + + const label = + phase === 'error' + ? 'Analyse Fehler' + : phase === 'done' + ? 'Analyse fertig' + : total > 0 + ? `Analyse ${percent}%` + : String(msg?.message ?? '').trim() || 'Analyse läuft' + + window.dispatchEvent( + new CustomEvent('finished-downloads:postwork', { + detail: { + file, + assetId: file.replace(/\.[^.]+$/, ''), + queue: 'enrich', + phase: 'analyze', + state, + label, + error: String(msg?.error ?? '').trim() || undefined, + ts: typeof msg?.ts === 'number' && Number.isFinite(msg.ts) + ? msg.ts + : Date.now(), + }, + }) + ) + + if (state === 'done' || state === 'error') { + bumpAssets() + + if (selectedTabRef.current === 'finished') { + void loadDonePage(donePageRef.current, doneSortRef.current) + } + } + } catch { + // ignore + } + } + const onTaskState = (ev: MessageEvent) => { try { const msg = JSON.parse(String(ev.data ?? 'null')) as TaskStateEvent @@ -2887,6 +2972,7 @@ export default function App() { es.addEventListener('finishedPostwork', onFinishedPostwork as any) es.addEventListener('taskState', onTaskState as any) es.addEventListener('training', onTraining as any) + es.addEventListener('analysisProgress', onAnalysisProgress as any) const onVis = () => { if (document.hidden) return @@ -2912,6 +2998,7 @@ export default function App() { es.removeEventListener('finishedPostwork', onFinishedPostwork as any) es.removeEventListener('taskState', onTaskState as any) es.removeEventListener('training', onTraining as any) + es.removeEventListener('analysisProgress', onAnalysisProgress as any) const handler = onModelJobEventRef.current if (handler) { @@ -3785,15 +3872,29 @@ export default function App() { const mobileHeaderStatBadgeClass = 'inline-flex h-6 min-w-[3.1rem] items-center justify-center gap-1 rounded-full border border-gray-200/70 bg-white/70 px-1.5 text-[10px] font-semibold text-gray-900 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-100' + const isTrainingTab = selectedTab === 'training' + return ( -
+