added log + bugfixes

This commit is contained in:
Linrador 2026-05-05 14:05:56 +02:00
parent 9cf9dfeb21
commit 96b81fc89d
98 changed files with 7988 additions and 2064 deletions

BIN
Icons/69.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
Icons/anus.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
Icons/bikini.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
Icons/blindfold.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
Icons/blowjob.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
Icons/bra.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

BIN
Icons/breasts.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
Icons/buttocks.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

BIN
Icons/buttplug.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
Icons/choker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

BIN
Icons/cowgirl.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
Icons/crop-top.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
Icons/cunnilingus.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
Icons/dildo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
Icons/doggy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
Icons/dress.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
Icons/female.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

BIN
Icons/fingering.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

BIN
Icons/hand-job.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
Icons/handcuffs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

BIN
Icons/high-heels.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

BIN
Icons/hotpants.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
Icons/lingerie.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
Icons/male.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

BIN
Icons/missionary.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
Icons/other.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
Icons/panties.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
Icons/penis.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
Icons/prone-bone.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
Icons/reverse-cowgirl.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
Icons/shower.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

BIN
Icons/sitting.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

BIN
Icons/skirt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

BIN
Icons/standing-doggy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
Icons/standing.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
Icons/stockings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

BIN
Icons/strap-on.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
Icons/tongue.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
Icons/towel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
Icons/toy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
Icons/unknown.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

BIN
Icons/vagina.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

244
backend/ai_server.py Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

266
backend/log.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

BIN
backend/yolo26n.pt Normal file

Binary file not shown.

View File

@ -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 (
<div className="min-h-[100dvh] bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
<div
className={[
isTrainingTab
? 'min-h-[100dvh] lg:h-[100dvh] lg:overflow-hidden'
: 'min-h-[100dvh]',
'bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100',
].join(' ')}
>
<div aria-hidden="true" className="pointer-events-none fixed inset-0 overflow-hidden">
<div className="absolute -top-28 left-1/2 h-80 w-[52rem] -translate-x-1/2 rounded-full bg-indigo-500/10 blur-3xl dark:bg-indigo-400/10" />
<div className="absolute -bottom-28 right-[-6rem] h-80 w-[46rem] rounded-full bg-sky-500/10 blur-3xl dark:bg-sky-400/10" />
</div>
<div className="relative">
<header className="z-[70] bg-white/70 backdrop-blur dark:bg-gray-950/60 sm:sticky sm:top-0 sm:border-b sm:border-gray-200/70 sm:dark:border-white/10">
<div
className={[
'relative',
isTrainingTab ? 'min-h-[100dvh] lg:flex lg:h-full lg:min-h-0 lg:flex-col' : '',
].join(' ')}
>
<header className="z-[70] shrink-0 bg-white/70 backdrop-blur dark:bg-gray-950/60 sm:sticky sm:top-0 sm:border-b sm:border-gray-200/70 sm:dark:border-white/10">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pt-2.5 sm:py-4 space-y-2 sm:space-y-3">
<div className="flex items-center justify-between gap-3 sm:gap-4">
<div className="min-w-0 flex-1">
@ -3993,13 +4094,20 @@ export default function App() {
</div>
</header>
<div className="sm:hidden sticky top-0 z-[65] border-b border-gray-200/70 bg-white/70 backdrop-blur dark:border-white/10 dark:bg-gray-950/60">
<div className="sm:hidden sticky top-0 z-[65] shrink-0 border-b border-gray-200/70 bg-white/70 backdrop-blur dark:border-white/10 dark:bg-gray-950/60">
<div className="mx-auto max-w-7xl px-4 pt-0 pb-2">
<Tabs tabs={tabs} value={selectedTab} onChange={setSelectedTab} ariaLabel="Tabs" variant="barUnderline" />
</div>
</div>
<main className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-2 space-y-2">
<main
className={[
'mx-auto w-full max-w-7xl px-4 py-2 sm:px-6 lg:px-8',
isTrainingTab
? 'min-h-0 pb-4 lg:flex-1 lg:overflow-hidden'
: 'space-y-2',
].join(' ')}
>
{selectedTab === 'running' ? (
<Downloads
jobs={jobs}

View File

@ -26,7 +26,7 @@ function cn(...parts: Array<string | false | null | undefined>) {
}
const base =
'inline-flex items-center justify-center font-semibold transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed'
'inline-flex items-center justify-center font-semibold transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed'
const roundedMap = {
sm: 'rounded-sm',
@ -47,8 +47,10 @@ const colorMap: Record<Color, Record<Variant, string>> = {
'!bg-indigo-600 !text-white shadow-sm hover:!bg-indigo-700 focus-visible:outline-indigo-600 ' +
'dark:!bg-indigo-500 dark:hover:!bg-indigo-400 dark:focus-visible:outline-indigo-500',
secondary:
'bg-white text-gray-900 hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20',
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' +
'disabled:bg-gray-100 disabled:text-gray-500 disabled:ring-gray-300 disabled:shadow-none disabled:hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-white/20 ' +
'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5',
soft:
'bg-indigo-500/14 text-indigo-700 shadow-xs inset-ring inset-ring-indigo-500/20 hover:bg-indigo-500/24 hover:inset-ring-indigo-500/30 ' +
'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-indigo-500/30',
@ -59,8 +61,10 @@ const colorMap: Record<Color, Record<Variant, string>> = {
'!bg-blue-600 !text-white shadow-sm hover:!bg-blue-700 focus-visible:outline-blue-600 ' +
'dark:!bg-blue-500 dark:hover:!bg-blue-400 dark:focus-visible:outline-blue-500',
secondary:
'bg-white text-gray-900 hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20',
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' +
'disabled:bg-gray-100 disabled:text-gray-500 disabled:ring-gray-300 disabled:shadow-none disabled:hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-white/20 ' +
'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5',
soft:
'bg-blue-500/14 text-blue-700 shadow-xs inset-ring inset-ring-blue-500/20 hover:bg-blue-500/24 hover:inset-ring-blue-500/30 ' +
'dark:bg-blue-500/20 dark:text-blue-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-blue-500/30',
@ -71,8 +75,10 @@ const colorMap: Record<Color, Record<Variant, string>> = {
'!bg-emerald-600 !text-white shadow-sm hover:!bg-emerald-700 focus-visible:outline-emerald-600 ' +
'dark:!bg-emerald-500 dark:hover:!bg-emerald-400 dark:focus-visible:outline-emerald-500',
secondary:
'bg-white text-gray-900 hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20',
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' +
'disabled:bg-gray-100 disabled:text-gray-500 disabled:ring-gray-300 disabled:shadow-none disabled:hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-white/20 ' +
'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5',
soft:
'bg-emerald-500/14 text-emerald-700 shadow-xs inset-ring inset-ring-emerald-500/20 hover:bg-emerald-500/24 hover:inset-ring-emerald-500/30 ' +
'dark:bg-emerald-500/20 dark:text-emerald-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-emerald-500/30',
@ -83,8 +89,10 @@ const colorMap: Record<Color, Record<Variant, string>> = {
'!bg-red-600 !text-white shadow-sm hover:!bg-red-700 focus-visible:outline-red-600 ' +
'dark:!bg-red-500 dark:hover:!bg-red-400 dark:focus-visible:outline-red-500',
secondary:
'bg-white text-gray-900 hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20',
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' +
'disabled:bg-gray-100 disabled:text-gray-500 disabled:ring-gray-300 disabled:shadow-none disabled:hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-white/20 ' +
'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5',
soft:
'bg-red-500/14 text-red-700 shadow-xs inset-ring inset-ring-red-500/20 hover:bg-red-500/24 hover:inset-ring-red-500/30 ' +
'dark:bg-red-500/20 dark:text-red-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-red-500/30',
@ -95,8 +103,10 @@ const colorMap: Record<Color, Record<Variant, string>> = {
'!bg-amber-500 !text-white shadow-sm hover:!bg-amber-600 focus-visible:outline-amber-500 ' +
'dark:!bg-amber-500 dark:hover:!bg-amber-400 dark:focus-visible:outline-amber-500',
secondary:
'bg-white text-gray-900 hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20',
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' +
'disabled:bg-gray-100 disabled:text-gray-500 disabled:ring-gray-300 disabled:shadow-none disabled:hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-white/20 ' +
'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5',
soft:
'bg-amber-500/12 text-amber-800 shadow-xs inset-ring inset-ring-amber-500/20 hover:bg-amber-500/22 hover:inset-ring-amber-500/30 ' +
'dark:bg-amber-500/20 dark:text-amber-300 dark:shadow-none dark:inset-ring-0 dark:hover:bg-amber-500/30',

View File

@ -1118,15 +1118,15 @@ function summarizePostworkSteps(
const rawPhase = String(badge.phase ?? '').trim().toLowerCase()
const phase = normalizeFinishedPhase(queue, rawPhase)
const key = `${queue}:${phase}`
const badgeLabel = String(badge.label ?? '').trim()
const prev = byKey.get(key)
if (prev && prev.ts > badge.ts) continue
const normalizedBadgeLabel = normalizeAnalysisProgressLabel(badgeLabel) ?? badgeLabel
byKey.set(key, {
const candidate: FinishedPostworkStepSummary = {
key,
queue,
phase,
label: labelForStep(queue, phase),
label: normalizedBadgeLabel || labelForStep(queue, phase),
state: badge.state,
error: String(badge.error ?? '').trim() || undefined,
ts: badge.ts,
@ -1134,7 +1134,20 @@ function summarizePostworkSteps(
waiting: badge.waiting,
running: badge.running,
maxParallel: badge.maxParallel,
})
}
const prev = byKey.get(key)
if (prev) {
if ((prev.ts || 0) > (candidate.ts || 0)) {
byKey.set(key, preferProgressStepLabel(prev, candidate))
} else {
byKey.set(key, preferProgressStepLabel(candidate, prev))
}
continue
}
byKey.set(key, candidate)
}
// ✅ Erst alle erwarteten Schritte anlegen
@ -1238,9 +1251,19 @@ function mergePostworkSummaries(
step.state === 'queued' ||
step.state === 'error'
) {
if (!prev || (step.ts || 0) >= (prev.ts || 0)) {
if (!prev) {
stepMap.set(step.key, step)
continue
}
if ((step.ts || 0) >= (prev.ts || 0)) {
stepMap.set(step.key, preferProgressStepLabel(step, prev))
continue
}
// Auch wenn der ältere Step vom Polling kommt:
// Ein vorhandenes Analyse-Prozentlabel darf nicht durch "Analyse" verloren gehen.
stepMap.set(step.key, preferProgressStepLabel(prev, step))
continue
}
@ -1341,6 +1364,150 @@ function samePostworkBadge(a: FinishedPostworkBadge, b: FinishedPostworkBadge):
)
}
function analysisProgressPercent(label?: string): number | null {
const s = String(label ?? '').trim()
const percentMatch = s.match(/^analyse\s+(\d{1,3})%$/i)
if (percentMatch) {
const n = Number(percentMatch[1])
if (!Number.isFinite(n)) return null
return Math.max(0, Math.min(100, Math.round(n)))
}
const framesMatch = s.match(/^analyse\s+(\d+)\s*\/\s*(\d+)\s*frames?$/i)
if (framesMatch) {
const current = Number(framesMatch[1])
const total = Number(framesMatch[2])
if (!Number.isFinite(current) || !Number.isFinite(total) || total <= 0) {
return null
}
return Math.max(0, Math.min(100, Math.round((current / total) * 100)))
}
return null
}
function postworkStepDisplayLabel(step: FinishedPostworkStepSummary): string {
if (isAnalyzeStep(step)) {
return 'Analyse'
}
return step.label
}
function runningStepStateLabel(step: FinishedPostworkStepSummary): string {
if (isAnalyzeStep(step)) {
const percent = analysisProgressPercent(step.label)
if (percent != null) return `${percent}%`
}
return 'Läuft'
}
function normalizeAnalysisProgressLabel(label?: string): string | undefined {
const percent = analysisProgressPercent(label)
if (percent == null) return label
return `Analyse ${percent}%`
}
function isAnalyzeBadge(badge?: Pick<FinishedPostworkBadge, 'queue' | 'phase'> | null): boolean {
return (
badge?.queue === 'enrich' &&
normalizeFinishedPhase('enrich', badge.phase) === 'analyze'
)
}
function isAnalyzeStep(step?: Pick<FinishedPostworkStepSummary, 'queue' | 'phase'> | null): boolean {
return (
step?.queue === 'enrich' &&
normalizeFinishedPhase('enrich', step.phase) === 'analyze'
)
}
function preferProgressLabel(
incoming: FinishedPostworkBadge,
previous?: FinishedPostworkBadge
): FinishedPostworkBadge {
if (!isAnalyzeBadge(incoming) || incoming.state !== 'running') {
return incoming
}
const normalizedIncoming: FinishedPostworkBadge = {
...incoming,
label: normalizeAnalysisProgressLabel(incoming.label),
}
if (!previous || previous.state !== 'running' || !isAnalyzeBadge(previous)) {
return normalizedIncoming
}
const incomingPercent = analysisProgressPercent(normalizedIncoming.label)
const previousPercent = analysisProgressPercent(previous.label)
if (previousPercent != null && incomingPercent == null) {
return {
...normalizedIncoming,
label: previous.label,
}
}
if (
previousPercent != null &&
incomingPercent != null &&
incomingPercent < previousPercent
) {
return {
...normalizedIncoming,
label: previous.label,
}
}
return normalizedIncoming
}
function preferProgressStepLabel(
incoming: FinishedPostworkStepSummary,
previous?: FinishedPostworkStepSummary
): FinishedPostworkStepSummary {
if (!isAnalyzeStep(incoming) || incoming.state !== 'running') {
return incoming
}
const normalizedIncoming: FinishedPostworkStepSummary = {
...incoming,
label: normalizeAnalysisProgressLabel(incoming.label) ?? incoming.label,
}
if (!previous || previous.state !== 'running' || !isAnalyzeStep(previous)) {
return normalizedIncoming
}
const incomingPercent = analysisProgressPercent(normalizedIncoming.label)
const previousPercent = analysisProgressPercent(previous.label)
if (previousPercent != null && incomingPercent == null) {
return {
...normalizedIncoming,
label: previous.label,
}
}
if (
previousPercent != null &&
incomingPercent != null &&
incomingPercent < previousPercent
) {
return {
...normalizedIncoming,
label: previous.label,
}
}
return normalizedIncoming
}
function samePostworkBadgeList(
a?: FinishedPostworkBadge[],
b?: FinishedPostworkBadge[]
@ -1377,6 +1544,214 @@ function samePostworkBadgeMap(
return true
}
function postworkSummaryTitle(summary: FinishedPostworkSummary): string {
switch (summary.state) {
case 'running':
return 'Postwork läuft'
case 'queued':
return 'Postwork wartet'
case 'done':
return 'Postwork fertig'
case 'error':
return 'Postwork Fehler'
case 'missing':
return 'Postwork fehlt'
default:
return 'Postwork-Status'
}
}
function postworkSummaryIcon(summary: FinishedPostworkSummary): ReactNode {
const iconClass = 'size-5 shrink-0'
switch (summary.state) {
case 'running':
return <LoadingSpinner size="sm" className="shrink-0" srLabel={summary.label} />
case 'queued':
return <ClockIcon className={`${iconClass} text-amber-600 dark:text-amber-300`} />
case 'done':
return <CheckCircleIcon className={`${iconClass} text-emerald-600 dark:text-emerald-300`} />
case 'error':
return <ExclamationTriangleIcon className={`${iconClass} text-red-600 dark:text-red-300`} />
case 'missing':
return <span className="size-5 shrink-0 rounded-full border border-gray-300 dark:border-white/20" />
default:
return <span className="size-5 shrink-0" />
}
}
function postworkDoneCount(summary: FinishedPostworkSummary): number {
return summary.steps.filter((step) => step.state === 'done').length
}
function postworkProgressPercent(summary: FinishedPostworkSummary): number {
const total = summary.steps.length
if (total <= 0) return 0
const done = postworkDoneCount(summary)
const running = summary.steps.some((step) => step.state === 'running') ? 0.5 : 0
return Math.max(0, Math.min(100, Math.round(((done + running) / total) * 100)))
}
function postworkStepStateLabel(step: FinishedPostworkStepSummary): string {
if (step.state === 'running') return runningStepStateLabel(step)
if (step.state === 'queued') {
return typeof step.position === 'number' && step.position > 0
? `Wartet #${step.position}`
: 'Wartet'
}
if (step.state === 'done') return 'Fertig'
if (step.state === 'error') return 'Fehler'
if (step.state === 'missing') return 'Offen'
return '—'
}
function postworkStepTone(step: FinishedPostworkStepSummary): string {
switch (step.state) {
case 'running':
return 'bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-200 dark:ring-indigo-500/30'
case 'queued':
return 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-500/30'
case 'done':
return 'bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-200 dark:ring-emerald-500/30'
case 'error':
return 'bg-red-50 text-red-700 ring-red-200 dark:bg-red-500/10 dark:text-red-200 dark:ring-red-500/30'
default:
return 'bg-gray-100 text-gray-600 ring-gray-200 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10'
}
}
function postworkStepIcon(step: FinishedPostworkStepSummary): ReactNode {
if (step.state === 'running') {
return <LoadingSpinner size="sm" className="shrink-0" srLabel={step.label} />
}
if (step.state === 'queued') {
return <ClockIcon className="size-3.5 shrink-0 text-amber-600 dark:text-amber-300" />
}
if (step.state === 'done') {
return <CheckCircleIcon className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-300" />
}
if (step.state === 'error') {
return <ExclamationTriangleIcon className="size-3.5 shrink-0 text-red-600 dark:text-red-300" />
}
return <span className="size-3.5 shrink-0 rounded-full border border-gray-300 dark:border-white/20" />
}
function renderPostworkPopover(summary: FinishedPostworkSummary): ReactNode {
const done = postworkDoneCount(summary)
const total = summary.steps.length
const progress = postworkProgressPercent(summary)
return (
<div className="w-[340px] overflow-hidden rounded-[inherit] bg-white dark:bg-gray-950">
<div className="border-b border-gray-200/60 bg-gradient-to-br from-gray-50 to-white px-3 py-2.5 dark:border-white/10 dark:from-white/10 dark:to-white/5">
<div className="flex items-center gap-2.5">
<div className="grid size-7 shrink-0 place-items-center rounded-lg bg-white shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
{postworkSummaryIcon(summary)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-[13px] font-semibold text-gray-900 dark:text-white">
{postworkSummaryTitle(summary)}
</div>
<span
className={[
'shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold ring-1',
postworkBadgeTone(summary),
].join(' ')}
>
{summary.label}
</span>
</div>
<div className="mt-0.5 truncate text-[10px] text-gray-500 dark:text-gray-400">
{summary.file}
</div>
</div>
</div>
<div className="mt-2 flex items-center gap-2">
<div className="h-1 flex-1 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
<div
className={[
'h-full rounded-full transition-all duration-300',
summary.state === 'error'
? 'bg-red-500'
: summary.state === 'queued'
? 'bg-amber-500'
: summary.state === 'running'
? 'bg-indigo-500'
: 'bg-emerald-500',
].join(' ')}
style={{ width: `${progress}%` }}
/>
</div>
<span className="shrink-0 text-[10px] tabular-nums text-gray-500 dark:text-gray-400">
{done}/{total}
</span>
</div>
</div>
<div className="px-3 py-3">
<div className="space-y-1.5">
{summary.steps.map((step) => (
<div
key={step.key}
className={[
'grid grid-cols-[minmax(0,1fr)_24px_auto] items-center gap-2 rounded-xl px-2.5 py-2',
'bg-gray-50/80 ring-1 ring-gray-200/70 dark:bg-white/[0.03] dark:ring-white/10',
].join(' ')}
>
<div className="min-w-0">
<div className="truncate text-[12px] font-medium text-gray-900 dark:text-gray-100">
{postworkStepDisplayLabel(step)}
</div>
{step.state === 'error' && step.error ? (
<div className="mt-0.5 truncate text-[11px] text-red-600 dark:text-red-300" title={step.error}>
{step.error}
</div>
) : null}
</div>
<div className="flex size-6 items-center justify-center">
{postworkStepIcon(step)}
</div>
<div className="flex items-center gap-1.5">
<span
className={[
'inline-flex min-w-[64px] justify-center rounded-full px-2 py-0.5 text-[11px] font-semibold ring-1',
postworkStepTone(step),
].join(' ')}
title={step.state === 'error' ? step.error || 'Unbekannter Fehler' : undefined}
>
{postworkStepStateLabel(step)}
</span>
{step.state === 'error' && step.error ? (
<CopyErrorButton text={step.error} />
) : null}
</div>
</div>
))}
</div>
</div>
</div>
)
}
export function renderFinishedPostworkBadge(
summary?: FinishedPostworkSummary,
opts?: {
@ -1444,73 +1819,7 @@ export function renderFinishedPostworkBadge(
return (
<HoverPopover
content={(open) =>
open && (
<div className="w-[280px] rounded-lg">
<div className="px-3 py-2 border-b border-gray-200 dark:border-white/10">
<div className="text-xs font-semibold text-gray-900 dark:text-white">
{summary.state === 'missing' ? 'Postwork-Phasen' : 'Postwork-Status'}
</div>
<div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400 truncate">
{summary.file}
</div>
</div>
<div className="px-3 py-2 space-y-2">
{summary.steps.map((step) => (
<div key={step.key} className="flex items-center justify-between gap-3">
<div className="min-w-0 text-[12px] text-gray-800 dark:text-gray-200">
{step.label}
</div>
<div className="shrink-0 flex items-center gap-1.5 text-[11px]">
{step.state === 'running' ? (
<>
<LoadingSpinner size="sm" className="shrink-0" srLabel={step.label} />
<span className="text-indigo-700 dark:text-indigo-200">Läuft</span>
</>
) : step.state === 'queued' ? (
<>
<ClockIcon className="size-3.5 shrink-0 text-amber-600 dark:text-amber-300" />
<span className="text-amber-700 dark:text-amber-200">
{typeof step.position === 'number' && step.position > 0
? `Wartet #${step.position}`
: 'Wartet'}
</span>
</>
) : step.state === 'done' ? (
<>
<CheckCircleIcon className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-300" />
<span className="text-emerald-700 dark:text-emerald-200">Fertig</span>
</>
) : step.state === 'error' ? (
<div className="flex items-center gap-1.5">
<ExclamationTriangleIcon className="size-3.5 shrink-0 text-red-600 dark:text-red-300" />
<span
className="text-red-700 dark:text-red-200"
title={step.error || 'Unbekannter Fehler'}
>
Fehler
</span>
{step.error ? <CopyErrorButton text={step.error} /> : null}
</div>
) : step.state === 'missing' ? (
<>
<span className="size-3.5 shrink-0 rounded-full border border-gray-300 dark:border-white/20" />
<span className="text-gray-500 dark:text-gray-400">Kein Status</span>
</>
) : (
<span className="text-gray-500 dark:text-gray-400"></span>
)}
</div>
</div>
))}
</div>
</div>
)
}
content={(open) => open && renderPostworkPopover(summary)}
>
{badgeNode}
</HoverPopover>
@ -3636,9 +3945,31 @@ export default function FinishedDownloads({
: {}
setGenerateAssetsLiveByFile((prev) => {
return samePostworkBadgeMap(prev, nextGenerateAssetsLiveByFile)
? prev
: nextGenerateAssetsLiveByFile
const patched: Record<string, FinishedPostworkBadge[]> = {}
for (const [file, badges] of Object.entries(nextGenerateAssetsLiveByFile)) {
const prevBadges = prev[file] ?? postworkByFile[file] ?? []
patched[file] = badges.map((badge) => {
const key = `${badge.queue}:${normalizeFinishedPhase(
badge.queue,
String(badge.phase ?? '').trim().toLowerCase()
)}`
const previous = prevBadges.find((p) => {
const prevKey = `${p.queue}:${normalizeFinishedPhase(
p.queue,
String(p.phase ?? '').trim().toLowerCase()
)}`
return prevKey === key
})
return preferProgressLabel(badge, previous)
})
}
return samePostworkBadgeMap(prev, patched) ? prev : patched
})
const jobs = Array.isArray(data?.regenerateAssetsJobs)
@ -3665,6 +3996,8 @@ export default function FinishedDownloads({
const phases = Array.isArray(item?.phases) ? item.phases : []
if (phases.length > 0) {
const prevBadgesForFile = prev[file] ?? []
nextBadges = phases
.map((p: any): FinishedPostworkBadge | null => {
const queueRaw = String(p?.queue ?? '').trim().toLowerCase()
@ -3689,21 +4022,40 @@ export default function FinishedDownloads({
return null
}
return {
const queue = queueRaw as 'postwork' | 'enrich'
const phase = normalizeFinishedPhase(queue, phaseRaw)
const incoming: FinishedPostworkBadge = {
file,
assetId,
queue: queueRaw as 'postwork' | 'enrich',
phase: phaseRaw,
queue,
phase,
state,
label:
String(p?.label ?? '').trim() ||
labelForStep(queueRaw as 'postwork' | 'enrich', phaseRaw),
normalizeAnalysisProgressLabel(String(p?.label ?? '').trim()) ||
labelForStep(queue, phase),
position: typeof p?.position === 'number' ? p.position : undefined,
waiting: typeof p?.waiting === 'number' ? p.waiting : undefined,
running: typeof p?.running === 'number' ? p.running : undefined,
maxParallel: typeof p?.maxParallel === 'number' ? p.maxParallel : undefined,
ts: typeof p?.ts === 'number' && Number.isFinite(p.ts) ? p.ts : 0,
}
const key = `${incoming.queue}:${normalizeFinishedPhase(
incoming.queue,
String(incoming.phase ?? '').trim().toLowerCase()
)}`
const previous = prevBadgesForFile.find((old) => {
const oldKey = `${old.queue}:${normalizeFinishedPhase(
old.queue,
String(old.phase ?? '').trim().toLowerCase()
)}`
return oldKey === key
})
return preferProgressLabel(incoming, previous)
})
.filter(Boolean) as FinishedPostworkBadge[]
} else {
@ -3763,7 +4115,7 @@ export default function FinishedDownloads({
} catch {
// ignore
}
}, [])
}, [postworkByFile])
useEffect(() => {
void refreshPostworkState()
@ -3943,9 +4295,20 @@ export default function FinishedDownloads({
const cur = Array.isArray(prev[file]) ? prev[file] : []
if (detail.state === 'missing') {
const missingKey = `${detail.queue}:${String(detail.phase ?? '').trim().toLowerCase() || 'postwork'}`
const detailQueue = detail.queue === 'enrich' ? 'enrich' : 'postwork'
const detailPhase = normalizeFinishedPhase(
detailQueue,
String(detail.phase ?? '').trim().toLowerCase()
)
const missingKey = `${detailQueue}:${detailPhase}`
const filtered = cur.filter((item) => {
const itemKey = `${item.queue}:${String(item.phase ?? '').trim().toLowerCase() || 'postwork'}`
const itemKey = `${item.queue}:${normalizeFinishedPhase(
item.queue,
String(item.phase ?? '').trim().toLowerCase()
)}`
return itemKey !== missingKey
})
@ -3967,14 +4330,27 @@ export default function FinishedDownloads({
error: String(detail?.error ?? '').trim() || undefined,
}
const key = `${incoming.queue}:${String(incoming.phase ?? '').trim().toLowerCase() || 'postwork'}`
const key = `${incoming.queue}:${normalizeFinishedPhase(
incoming.queue,
String(incoming.phase ?? '').trim().toLowerCase()
)}`
let replaced = false
const merged = cur.map((item) => {
const itemKey = `${item.queue}:${String(item.phase ?? '').trim().toLowerCase() || 'postwork'}`
const itemKey = `${item.queue}:${normalizeFinishedPhase(
item.queue,
String(item.phase ?? '').trim().toLowerCase()
)}`
if (itemKey !== key) return item
replaced = true
return (item.ts ?? 0) > (incoming.ts ?? 0) ? item : incoming
if ((item.ts ?? 0) > (incoming.ts ?? 0)) {
return item
}
return preferProgressLabel(incoming, item)
})
const nextBadges = replaced ? merged : [...merged, incoming]

File diff suppressed because it is too large Load Diff

View File

@ -193,7 +193,7 @@ export default function Modal({
>
<div className="min-w-0 flex-1">
{title ? (
<Dialog.Title className="hidden sm:block text-base font-semibold text-gray-900 dark:text-white truncate">
<Dialog.Title className="block text-base font-semibold text-gray-900 dark:text-white truncate">
{title}
</Dialog.Title>
) : null}

View File

@ -586,6 +586,9 @@ export default function Player({
const playerRef = useRef<VideoJsPlayer | null>(null)
const videoNodeRef = useRef<HTMLVideoElement | null>(null)
const mobileSegmentsScrollRef = useRef<HTMLDivElement | null>(null)
const mobileHeaderTouchYRef = useRef<number | null>(null)
const [mounted, setMounted] = useState(false)
const updateIntrinsicDims = useCallback(() => {
@ -1960,7 +1963,7 @@ export default function Player({
<div className="min-w-0 pointer-events-auto overflow-visible">{showSideInfo ? null : footerRight}</div>
<div className="shrink-0 flex items-center gap-1 pointer-events-auto">
{miniDesktop && !isLive && hasRatingSegments ? (
{!isLive && hasRatingSegments ? (
<button
type="button"
className={cn(
@ -2359,6 +2362,115 @@ export default function Player({
</div>
) : null
const MOBILE_PLAYER_H = 'min(220px, 40vh)'
const MOBILE_PLAYER_GAP = 10
const mobilePlayerBottom = `calc(${bottomInset}px + env(safe-area-inset-bottom))`
const mobileSegmentsBottom = `calc(${mobilePlayerBottom} + ${MOBILE_PLAYER_H} + ${MOBILE_PLAYER_GAP}px)`
const mobileSegmentsMaxHeight = `calc(100dvh - ${mobileSegmentsBottom} - 12px)`
const mobileSegmentsSheetEl =
!isDesktop && !isLive && hasRatingSegments && segmentsPanelOpen ? (
<div
className="
fixed inset-x-2 z-[2147483647]
flex min-h-0 flex-col overflow-hidden rounded-2xl
border border-white/10 bg-white shadow-2xl ring-1 ring-black/10
dark:bg-gray-950 dark:ring-white/10
sm:hidden
"
style={{
bottom: mobileSegmentsBottom,
maxHeight: mobileSegmentsMaxHeight,
height: mobileSegmentsMaxHeight,
touchAction: 'pan-y',
}}
onClick={(e) => e.stopPropagation()}
onTouchMoveCapture={(e) => {
e.stopPropagation()
}}
onWheelCapture={(e) => {
e.stopPropagation()
}}
>
<div
className="shrink-0 flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-white/10"
style={{
touchAction: 'none',
}}
onTouchStart={(e) => {
mobileHeaderTouchYRef.current = e.touches[0]?.clientY ?? null
e.stopPropagation()
}}
onTouchMove={(e) => {
const y = e.touches[0]?.clientY ?? null
const lastY = mobileHeaderTouchYRef.current
if (y != null && lastY != null) {
const dy = lastY - y
mobileSegmentsScrollRef.current?.scrollBy({ top: dy })
mobileHeaderTouchYRef.current = y
}
e.preventDefault()
e.stopPropagation()
}}
onTouchEnd={(e) => {
mobileHeaderTouchYRef.current = null
e.stopPropagation()
}}
onWheel={(e) => {
mobileSegmentsScrollRef.current?.scrollBy({ top: e.deltaY })
e.preventDefault()
e.stopPropagation()
}}
>
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Interessante Stellen
</div>
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{ratingSegments.length} Segmente
</div>
</div>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-white/10 dark:text-gray-200 dark:hover:bg-white/15"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setSegmentsPanelOpen(false)
}}
aria-label="Segmente schließen"
title="Schließen"
>
×
</button>
</div>
<div
ref={mobileSegmentsScrollRef}
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3 [-webkit-overflow-scrolling:touch]"
style={{
touchAction: 'pan-y',
}}
onClick={(e) => {
e.stopPropagation()
}}
>
<RatingSegmentsPanel
segments={ratingSegments}
onOpenAt={(seconds) => {
seekPlayerToAbsolute(seconds)
}}
maxHeight="100%"
className="h-full max-h-full shadow-none"
/>
</div>
</div>
) : null
const expandedRect = {
left: ox + 16,
top: oy + 16,
@ -2532,18 +2644,22 @@ export default function Player({
) : null}
</div>
) : (
<div
className="
fixed z-[2147483647] inset-x-0 w-full
shadow-2xl
md:bottom-4 md:left-1/2 md:right-auto md:inset-x-auto md:w-[min(760px,calc(100vw-32px))] md:-translate-x-1/2
"
style={{
bottom: `calc(${bottomInset}px + env(safe-area-inset-bottom))`,
}}
>
{cardEl}
</div>
<>
{mobileSegmentsSheetEl}
<div
className="
fixed z-[2147483647] inset-x-0 w-full
shadow-2xl
md:bottom-4 md:left-1/2 md:right-auto md:inset-x-auto md:w-[min(760px,calc(100vw-32px))] md:-translate-x-1/2
"
style={{
bottom: mobilePlayerBottom,
}}
>
{cardEl}
</div>
</>
)}
</>
)

View File

@ -2,10 +2,16 @@
'use client'
import { type CSSProperties } from 'react'
import {
useEffect,
useState,
type CSSProperties,
type KeyboardEvent,
type MouseEvent,
} from 'react'
import { createPortal } from 'react-dom'
import HoverPopover from './HoverPopover'
import type { RecordJobMeta } from '../../types'
import { getSegmentLabelText } from './Icons'
type RatingOverlayProps = {
metaRaw: unknown
@ -61,68 +67,166 @@ const VALUE_LABELS: Record<string, string> = {
}
const AI_LABEL_FALLBACKS: Record<string, string> = {
// entspricht SEGMENT_LABEL_META in Icons.tsx
anus_exposed: 'Anus',
vulva: 'Vagina',
pussy: 'Vagina',
penis: 'Penis',
anus: 'Anus',
female_genitalia_exposed: 'Vagina',
vulva_exposed: 'Vagina',
labia_exposed: 'Vagina',
male_genitalia_exposed: 'Penis',
penis_exposed: 'Penis',
female_breast_exposed: 'Brüste',
breast_exposed: 'Brüste',
male_breast_exposed: 'Männliche Brust',
buttocks_exposed: 'Hintern',
ass: 'Hintern',
breasts: 'Brüste',
buttocks: 'Hintern',
tongue: 'Zunge',
belly_exposed: 'Bauch',
stomach_exposed: 'Bauch',
abdomen_exposed: 'Bauch',
navel_exposed: 'Bauch',
blindfold: 'Augenbinde',
buttplug: 'Buttplug',
collar: 'Halsband',
dildo: 'Dildo',
handcuffs: 'Handschellen',
shower: 'Dusche',
strapon: 'Strapon',
towel: 'Handtuch',
vibrator: 'Vibrator',
feet_exposed: 'Füße',
foot_exposed: 'Füße',
face_female: 'Gesicht',
face_male: 'Gesicht',
face: 'Gesicht',
female_face: 'Gesicht',
male_face: 'Gesicht',
// covered-Varianten passend zur Logik in Icons.tsx
anus_covered: 'Anus bedeckt',
buttocks_covered: 'Hintern bedeckt',
female_breast_covered: 'Brüste bedeckt',
breast_covered: 'Brüste bedeckt',
male_breast_covered: 'Männliche Brust bedeckt',
female_genitalia_covered: 'Vagina bedeckt',
vulva_covered: 'Vagina bedeckt',
labia_covered: 'Vagina bedeckt',
male_genitalia_covered: 'Penis bedeckt',
penis_covered: 'Penis bedeckt',
belly_covered: 'Bauch bedeckt',
stomach_covered: 'Bauch bedeckt',
abdomen_covered: 'Bauch bedeckt',
navel_covered: 'Bauch bedeckt',
feet_covered: 'Füße bedeckt',
foot_covered: 'Füße bedeckt',
face_covered: 'Gesicht bedeckt',
// allgemeine AI-/Safety-Labels
explicit_nudity: 'Explizite Nacktheit',
non_explicit_nudity: 'Nicht-explizite Nacktheit',
suggestive: 'Suggestiv',
sexual_activity: 'Sexuelle Aktivität',
sexual_display: 'Sexuelle Darstellung',
sex_toy: 'Sexspielzeug',
underwear: 'Unterwäsche',
bikini: 'Bikini',
bra: 'BH',
dress: 'Kleid',
heels: 'High Heels',
hotpants: 'Hotpants',
lingerie: 'Lingerie',
swimwear: 'Bademode',
panties: 'Slip',
skirt: 'Rock',
stockings: 'Strümpfe',
croptop: 'Crop Top',
unknown: 'Unbekannt',
missionary: 'Missionary',
doggy: 'Doggy',
cowgirl: 'Cowgirl',
reverse_cowgirl: 'Reverse Cowgirl',
cunnilingus: 'Cunnilingus',
prone_bone: 'Prone Bone',
standing: 'Stehend',
standing_doggy: 'Standing Doggy',
spooning: 'Spooning',
sitting: 'Sitzend',
facesitting: 'Facesitting',
handjob: 'Handjob',
blowjob: 'Blowjob',
toy_play: 'Toy Play',
fingering: 'Fingering',
'69': '69',
other: 'Andere',
person: 'Person',
person_unknown: 'Person',
person_male: 'Mann',
person_female: 'Frau',
}
const PERSON_SEGMENT_LABELS = new Set([
'person',
'person_unknown',
'person_male',
'person_female',
'male_person',
'female_person',
'people_male',
'people_female',
'frau',
'mann',
'person',
])
function normalizeSegmentLabel(value: unknown): string {
return String(value ?? '')
.trim()
.toLowerCase()
.replace(/^detector:/, '')
.replace(/^body:/, '')
.replace(/^object:/, '')
.replace(/^clothing:/, '')
.replace(/^position:/, '')
}
function isPersonSegmentLabel(value: unknown): boolean {
return PERSON_SEGMENT_LABELS.has(normalizeSegmentLabel(value))
}
function splitMaybeComboOrList(value: unknown): string[] {
return String(value ?? '')
.split(/[+,;|]+/g)
.map((part) => part.trim())
.filter(Boolean)
}
function isNonPersonHighlightLabel(value: unknown): boolean {
const parts = splitMaybeComboOrList(value)
for (const part of parts) {
const normalized = normalizeSegmentLabel(part)
if (!normalized) continue
if (!isPersonSegmentLabel(normalized)) {
return true
}
}
return false
}
function segmentObjectHasNonPersonContext(obj: Record<string, unknown>): boolean {
const values: unknown[] = [
obj.tags,
obj.labels,
obj.classes,
obj.flags,
obj.summary,
obj.reason,
obj.description,
obj.text,
obj.caption,
obj.note,
]
for (const value of values) {
if (Array.isArray(value)) {
if (value.some((item) => isNonPersonHighlightLabel(item))) {
return true
}
continue
}
if (typeof value === 'string' && isNonPersonHighlightLabel(value)) {
return true
}
}
return false
}
function isPersonSegmentObject(obj: Record<string, unknown>): boolean {
const rawLabel =
obj.label ??
obj.title ??
obj.category ??
obj.type ??
obj.kind ??
obj.name ??
''
// Wenn der Haupttitel keine reine Person ist, behalten.
// Beispiel: combo:person_female+object:dildo
if (!isPersonSegmentLabel(rawLabel)) {
return false
}
// Wenn es Zusatzkontext gibt, behalten.
// Beispiel: label=person_female, tags=[dildo, vibrator]
if (segmentObjectHasNonPersonContext(obj)) {
return false
}
// Wirklich nur "Frau", "Mann", "Person" alleine ausblenden.
return true
}
function isAiLikeLabel(value: string): boolean {
@ -150,18 +254,33 @@ function prettyAiLabel(value: unknown): string {
const key = raw.toLowerCase()
try {
const fromIcons = getSegmentLabelText(raw).trim()
if (key.startsWith('position:')) {
return prettyAiLabel(key.slice('position:'.length))
}
if (
fromIcons &&
fromIcons !== raw &&
fromIcons.toLowerCase() !== key
) {
return fromIcons
}
} catch {
// fallback below
if (key.startsWith('object:')) {
return prettyAiLabel(key.slice('object:'.length))
}
if (key.startsWith('clothing:')) {
return prettyAiLabel(key.slice('clothing:'.length))
}
if (key.startsWith('body:')) {
return prettyAiLabel(key.slice('body:'.length))
}
if (key.startsWith('detector:')) {
return prettyAiLabel(key.slice('detector:'.length))
}
if (key.startsWith('combo:')) {
const rest = key.slice('combo:'.length)
return rest
.split('+')
.map((part) => prettyAiLabel(part))
.filter(Boolean)
.join(' + ')
}
const direct = AI_LABEL_FALLBACKS[key]
@ -170,6 +289,23 @@ function prettyAiLabel(value: unknown): string {
return isAiLikeLabel(raw) ? titleCaseLabel(raw) : raw
}
function segmentTitleFromRawLabel(raw: string): string {
return prettyAiLabel(raw)
}
function readSegmentTags(obj: Record<string, unknown>): string[] {
const tags = readTags(obj)
const out: string[] = []
for (const tag of tags) {
if (!tag) continue
if (out.some((x) => x.toLowerCase() === tag.toLowerCase())) continue
out.push(tag)
}
return out.slice(0, 6)
}
function prettyMaybeAiValue(key: string, value: string): string {
const k = key.toLowerCase()
@ -803,26 +939,111 @@ function segmentTimeLabel(obj: Record<string, unknown>): string | undefined {
function readSegmentArray(metaRaw: unknown): unknown[] {
const meta = parseMetaObject(metaRaw) as any
const ai = readAiNode(metaRaw) as any
const rating = readRatingNode(metaRaw) as any
const candidates = [
ai?.segments,
ai?.ratingSegments,
ai?.hits,
ai?.matches,
rating?.segments,
rating?.hits,
meta?.analysis?.segments,
meta?.analysis?.hits,
]
const arrays: unknown[][] = []
for (const candidate of candidates) {
if (Array.isArray(candidate) && candidate.length > 0) {
return candidate
const pushSegmentSources = (node: unknown) => {
if (!isPlainObject(node)) return
const n = node as any
// Bevorzugt echte Segmente. Hits nur als Fallback,
// sonst werden dieselben Stellen doppelt angezeigt.
const preferred = [
n.segments,
n.ratingSegments,
n.matches,
n.hits,
]
for (const value of preferred) {
if (Array.isArray(value) && value.length > 0) {
arrays.push(value)
return
}
}
}
return []
// Wichtig: Highlights zuerst, damit Positionen/Combos oben erscheinen.
const goalNodes = [
meta?.analysis?.highlights,
meta?.analysis?.highlight,
ai?.highlights,
ai?.highlight,
ai?.goals?.highlights,
ai?.goals?.highlight,
meta?.analysis?.ai?.highlights,
meta?.analysis?.ai?.highlight,
]
for (const node of goalNodes) {
pushSegmentSources(node)
}
if (arrays.length === 0) return []
const out: unknown[] = []
const seen = new Set<string>()
for (const arr of arrays) {
for (const item of arr) {
if (!isPlainObject(item)) continue
const obj = item as Record<string, unknown>
if (isPersonSegmentObject(obj)) continue
const label = String(
obj.label ??
obj.title ??
obj.category ??
obj.type ??
obj.kind ??
obj.name ??
''
).trim()
const start = firstNumber(obj, [
'startSec',
'startSeconds',
'start_s',
'start',
'from',
'time',
't',
'timestamp',
'startMs',
])
const end = firstNumber(obj, [
'endSec',
'endSeconds',
'end_s',
'end',
'to',
'endMs',
])
const key = [
normalizeSegmentLabel(label),
typeof start === 'number' ? start.toFixed(1) : '',
typeof end === 'number' ? end.toFixed(1) : '',
].join('|')
if (seen.has(key)) continue
seen.add(key)
out.push(item)
}
}
return out
}
function isRatingSegment(value: RatingSegment | null): value is RatingSegment {
return value !== null
}
export function readRatingSegments(metaRaw: unknown): RatingSegment[] {
@ -838,7 +1059,7 @@ export function readRatingSegments(metaRaw: unknown): RatingSegment[] {
firstString(raw, ['title', 'label', 'category', 'type', 'kind', 'name']) ??
`Segment ${index + 1}`
const title = prettyAiLabel(rawTitle)
const title = segmentTitleFromRawLabel(rawTitle)
const rawDetail =
firstString(raw, ['summary', 'reason', 'description', 'text', 'caption', 'note']) ??
@ -865,13 +1086,12 @@ export function readRatingSegments(metaRaw: unknown): RatingSegment[] {
timeLabel: segmentTimeLabel(raw),
valueLabel: segmentValueLabel(raw),
detail,
tags: readTags(raw),
tags: readSegmentTags(raw),
preview,
openAtSec,
}
})
.filter(Boolean)
.slice(0, 10) as RatingSegment[]
.filter(isRatingSegment)
}
export function RatingSegmentsPanel({
@ -911,10 +1131,10 @@ export function RatingSegmentsPanel({
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-xs font-semibold text-gray-900 dark:text-white">
Segmente
Interessante Stellen
</div>
<div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">
AI Rating · {segments.length}
Positionen · Highlights · AI · {segments.length}
</div>
</div>
</div>
@ -1100,6 +1320,173 @@ function RatingStar({
)
}
function useTouchLikePointer(): boolean {
const [touchLike, setTouchLike] = useState(false)
useEffect(() => {
if (typeof window === 'undefined') return
const queries = [
window.matchMedia('(hover: none)'),
window.matchMedia('(pointer: coarse)'),
]
const update = () => {
setTouchLike(queries.some((query) => query.matches))
}
update()
for (const query of queries) {
if (query.addEventListener) query.addEventListener('change', update)
else query.addListener(update)
}
return () => {
for (const query of queries) {
if (query.removeEventListener) query.removeEventListener('change', update)
else query.removeListener(update)
}
}
}, [])
return touchLike
}
function RatingMobileSheet({
open,
stars,
toneLabel,
entries,
segments,
onClose,
onOpenAt,
}: {
open: boolean
stars: number
toneLabel: string
entries: RatingEntry[]
segments: RatingSegment[]
onClose: () => void
onOpenAt?: (seconds: number) => void
}) {
useEffect(() => {
if (!open) return
const onKeyDown = (e: globalThis.KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [open, onClose])
if (!open || typeof document === 'undefined') return null
return createPortal(
<div
className="fixed inset-0 z-[2147483647] bg-black/45 backdrop-blur-sm sm:hidden"
role="dialog"
aria-modal="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onClose()
}}
>
<div
className="absolute inset-x-0 bottom-0 max-h-[82dvh] overflow-hidden rounded-t-2xl border border-white/10 bg-white shadow-2xl dark:bg-gray-950"
style={{
paddingBottom: 'env(safe-area-inset-bottom)',
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
<div className="flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-white/10">
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
AI Rating
</div>
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{toneLabel} · {stars}/5 Sterne
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<RatingStar stars={stars} className="h-8 w-8" />
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-white/10 dark:text-gray-200 dark:hover:bg-white/15"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onClose()
}}
aria-label="Schließen"
title="Schließen"
>
×
</button>
</div>
</div>
<div className="max-h-[calc(82dvh-58px)] overflow-y-auto overscroll-contain px-3 py-3">
{entries.length > 0 ? (
<div className="mb-3 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 dark:border-white/10 dark:bg-white/5">
<div className="space-y-1.5">
{entries.map((entry) => (
<div
key={entry.key}
className="flex items-start justify-between gap-3 text-[12px]"
>
<span className="min-w-0 text-gray-500 dark:text-gray-400">
{entry.label}
</span>
<span
className={[
'shrink-0 text-right font-medium',
entry.tone === 'strong'
? 'text-gray-950 dark:text-white'
: 'text-gray-800 dark:text-gray-100',
].join(' ')}
>
{entry.value}
</span>
</div>
))}
</div>
</div>
) : null}
{segments.length > 0 ? (
<RatingSegmentsPanel
segments={segments}
onOpenAt={
onOpenAt
? (seconds) => {
onClose()
onOpenAt(seconds)
}
: undefined
}
maxHeight="none"
className="max-h-none shadow-none"
/>
) : (
<div className="rounded-xl border border-gray-200 bg-gray-50 px-3 py-4 text-sm text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
Keine interessanten Stellen vorhanden.
</div>
)}
</div>
</div>
</div>,
document.body
)
}
function RatingWithPopover({
stars,
metaRaw,
@ -1115,195 +1502,137 @@ function RatingWithPopover({
const segments = readRatingSegments(metaRaw)
const tone = ratingTone(stars)
const touchLike = useTouchLikePointer()
const [tapOpen, setTapOpen] = useState(false)
useEffect(() => {
if (!touchLike) setTapOpen(false)
}, [touchLike])
const hasContent = entries.length > 0 || segments.length > 0
const starNode = (
<span
className="inline-flex items-center justify-center"
role={touchLike ? 'button' : undefined}
tabIndex={touchLike ? 0 : undefined}
className="inline-flex items-center justify-center touch-manipulation"
aria-label={`Rating ${stars} von 5`}
title={`Rating ${stars}/5`}
title={
hasContent
? `Rating ${stars}/5 · Details anzeigen`
: `Rating ${stars}/5`
}
onClick={(e: MouseEvent<HTMLSpanElement>) => {
if (!touchLike) return
e.preventDefault()
e.stopPropagation()
setTapOpen((v) => !v)
}}
onKeyDown={(e: KeyboardEvent<HTMLSpanElement>) => {
if (!touchLike) return
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
e.stopPropagation()
setTapOpen((v) => !v)
}}
>
<RatingStar stars={stars} className={className} />
</span>
)
if (entries.length === 0 && segments.length === 0) {
if (!hasContent) {
return starNode
}
return (
<HoverPopover
content={(open) =>
open && (
<div className="w-[340px] overflow-hidden rounded-lg">
<div className="border-b border-gray-200 px-3 py-2 dark:border-white/10">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold text-gray-900 dark:text-white">
AI Rating
</div>
<div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">
{tone.label} · {stars}/5 Sterne
</div>
</div>
<RatingStar stars={stars} className="h-7 w-7" />
</div>
const desktopPopoverContent = (
<div className="w-[360px] overflow-hidden rounded-xl border border-gray-200 bg-white shadow-2xl ring-1 ring-black/5 dark:border-white/10 dark:bg-gray-950 dark:ring-white/10">
<div className="border-b border-gray-200 px-3 py-2 dark:border-white/10">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold text-gray-900 dark:text-white">
AI Rating
</div>
<div className="max-h-[420px] overflow-y-auto px-3 py-2">
{entries.length > 0 ? (
<div className="space-y-1.5">
{entries.map((entry) => (
<div key={entry.key} className="flex items-start justify-between gap-3 text-[12px]">
<span className="min-w-0 text-gray-500 dark:text-gray-400">
{entry.label}
</span>
<span
className={[
'shrink-0 text-right font-medium',
entry.tone === 'strong'
? 'text-gray-950 dark:text-white'
: 'text-gray-800 dark:text-gray-100',
].join(' ')}
title={entry.value}
>
{entry.value}
</span>
</div>
))}
</div>
) : null}
{segments.length > 0 ? (
<div className={entries.length > 0 ? 'mt-3 border-t border-gray-200 pt-2 dark:border-white/10' : ''}>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Segmente
</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500">
{segments.length}
</div>
</div>
<div className="space-y-2">
{segments.map((segment, index) => (
<button
key={`${segment.key}-${index}`}
type="button"
disabled={!onOpenAt || typeof segment.openAtSec !== 'number'}
title={
onOpenAt && typeof segment.openAtSec === 'number'
? `Video bei ${formatSeconds(segment.openAtSec)} öffnen`
: undefined
}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (!onOpenAt || typeof segment.openAtSec !== 'number') return
onOpenAt(segment.openAtSec)
}}
className={[
'relative block w-full overflow-hidden rounded-md text-left ring-1 transition',
onOpenAt && typeof segment.openAtSec === 'number'
? 'cursor-pointer hover:scale-[1.01] hover:ring-indigo-400/60 focus:outline-none focus:ring-2 focus:ring-indigo-500'
: 'cursor-default',
segment.preview
? 'bg-black ring-white/10'
: 'bg-gray-50 ring-gray-200 dark:bg-white/5 dark:ring-white/10',
].join(' ')}
>
{segment.preview ? (
<div className="absolute inset-0 bg-black" aria-hidden="true">
<div style={segment.preview.frameStyle}>
<img
src={segment.preview.src}
alt=""
draggable={false}
style={segment.preview.imageStyle}
/>
</div>
<div className="absolute inset-0 bg-gradient-to-r from-black/75 via-black/45 to-black/70" />
<div className="absolute inset-0 bg-black/20" />
</div>
) : null}
<div className="relative z-10 px-2.5 py-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div
className={[
'truncate text-[12px] font-semibold',
segment.preview ? 'text-white' : 'text-gray-900 dark:text-white',
].join(' ')}
>
{segment.title}
</div>
{segment.timeLabel ? (
<div
className={[
'mt-0.5 text-[11px] tabular-nums',
segment.preview ? 'text-white/75' : 'text-gray-500 dark:text-gray-400',
].join(' ')}
>
{segment.timeLabel}
</div>
) : null}
</div>
{segment.valueLabel ? (
<div
className={[
'shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold ring-1',
segment.preview
? 'bg-black/45 text-white ring-white/20 backdrop-blur-sm'
: 'bg-white text-gray-800 ring-gray-200 dark:bg-black/20 dark:text-gray-100 dark:ring-white/10',
].join(' ')}
>
{segment.valueLabel}
</div>
) : null}
</div>
{segment.detail ? (
<div
className={[
'mt-1.5 text-[11px] leading-snug',
segment.preview ? 'text-white/85' : 'text-gray-600 dark:text-gray-300',
].join(' ')}
>
{segment.detail}
</div>
) : null}
{segment.tags.length > 0 ? (
<div className="mt-1.5 flex flex-wrap gap-1">
{segment.tags.map((tag) => (
<span
key={tag}
className={[
'rounded-full px-1.5 py-0.5 text-[10px] font-medium ring-1',
segment.preview
? 'bg-black/45 text-white/90 ring-white/20 backdrop-blur-sm'
: 'bg-white text-gray-600 ring-gray-200 dark:bg-black/20 dark:text-gray-300 dark:ring-white/10',
].join(' ')}
>
{tag}
</span>
))}
</div>
) : null}
</div>
</button>
))}
</div>
</div>
) : null}
<div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">
{tone.label} · {stars}/5 Sterne
</div>
</div>
)
}
<RatingStar stars={stars} className="h-7 w-7" />
</div>
</div>
<div className="max-h-[520px] overflow-y-auto overscroll-contain p-3">
{entries.length > 0 ? (
<div className="mb-3 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 dark:border-white/10 dark:bg-white/5">
<div className="space-y-1.5">
{entries.map((entry) => (
<div
key={entry.key}
className="flex items-start justify-between gap-3 text-[12px]"
>
<span className="min-w-0 text-gray-500 dark:text-gray-400">
{entry.label}
</span>
<span
className={[
'shrink-0 text-right font-medium',
entry.tone === 'strong'
? 'text-gray-950 dark:text-white'
: 'text-gray-800 dark:text-gray-100',
].join(' ')}
>
{entry.value}
</span>
</div>
))}
</div>
</div>
) : null}
{segments.length > 0 ? (
<RatingSegmentsPanel
segments={segments}
onOpenAt={onOpenAt}
maxHeight={360}
className="shadow-none"
/>
) : (
<div className="rounded-xl border border-gray-200 bg-gray-50 px-3 py-4 text-sm text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
Keine interessanten Stellen vorhanden.
</div>
)}
</div>
</div>
)
// Mobile/Touch: kein HoverPopover, nur Bottom Sheet
if (touchLike) {
return (
<>
{starNode}
<RatingMobileSheet
open={tapOpen}
stars={stars}
toneLabel={tone.label}
entries={entries}
segments={segments}
onClose={() => setTapOpen(false)}
onOpenAt={onOpenAt}
/>
</>
)
}
// Desktop: Hover-Popover
return (
<HoverPopover
content={(open) => (open ? desktopPopoverContent : null)}
>
{starNode}
</HoverPopover>

View File

@ -42,6 +42,23 @@ type RecorderSettings = {
trainingDetectorEpochs?: number
}
type AIServerStatus = {
ok: boolean
running: boolean
url?: string
statusCode?: number
error?: string
}
type AppLog = {
ok: boolean
exists?: boolean
path?: string
sizeBytes?: number
log?: string
error?: string
}
type DiskStatus = {
emergency: boolean
pauseGB: number
@ -286,6 +303,10 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [msg, setMsg] = useState<string | null>(null)
const [err, setErr] = useState<string | null>(null)
const [diskStatus, setDiskStatus] = useState<DiskStatus | null>(null)
const [aiServerStatus, setAiServerStatus] = useState<AIServerStatus | null>(null)
const [appLog, setAppLog] = useState<AppLog | null>(null)
const [appLogLoading, setAppLogLoading] = useState(false)
const [appLogOpen, setAppLogOpen] = useState(false)
const [dbModalOpen, setDbModalOpen] = useState(false)
const [loadedDatabaseUrl, setLoadedDatabaseUrl] = useState('')
const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet
@ -647,6 +668,95 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
}
}, [])
useEffect(() => {
let alive = true
const load = async () => {
try {
const res = await fetch('/api/status/ai-server', { cache: 'no-store' })
const data = await res.json().catch(() => null)
if (!alive) return
if (!res.ok || !data) {
setAiServerStatus({
ok: false,
running: false,
error: `HTTP ${res.status}`,
})
return
}
setAiServerStatus({
ok: Boolean(data.ok),
running: Boolean(data.running),
url: String(data.url || ''),
statusCode: Number(data.statusCode || 0),
error: String(data.error || ''),
})
} catch (e) {
if (!alive) return
setAiServerStatus({
ok: false,
running: false,
error: e instanceof Error ? e.message : String(e),
})
}
}
void load()
const timer = window.setInterval(load, 5000)
return () => {
alive = false
window.clearInterval(timer)
}
}, [])
async function loadAppLog() {
setAppLogLoading(true)
try {
const res = await fetch('/api/status/log?maxBytes=131072', {
cache: 'no-store',
})
const data = await res.json().catch(() => null)
if (!res.ok || !data) {
setAppLog({
ok: false,
log: '',
error: `HTTP ${res.status}`,
})
return
}
setAppLog({
ok: Boolean(data.ok),
exists: Boolean(data.exists),
path: String(data.path || ''),
sizeBytes: Number(data.sizeBytes || 0),
log: String(data.log || ''),
error: String(data.error || ''),
})
} catch (e) {
setAppLog({
ok: false,
log: '',
error: e instanceof Error ? e.message : String(e),
})
} finally {
setAppLogLoading(false)
}
}
useEffect(() => {
if (!appLogOpen) return
void loadAppLog()
}, [appLogOpen])
async function browse(target: 'record' | 'done' | 'ffmpeg') {
setErr(null)
setMsg(null)
@ -934,7 +1044,47 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
header={
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-base font-semibold text-gray-900 dark:text-white">Einstellungen</div>
<div className="flex flex-wrap items-center gap-2">
<div className="text-base font-semibold text-gray-900 dark:text-white">
Einstellungen
</div>
<span
className={[
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-bold ring-1',
aiServerStatus === null
? 'bg-gray-100 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10'
: aiServerStatus.running
? 'bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/30'
: 'bg-red-50 text-red-800 ring-red-200 dark:bg-red-500/15 dark:text-red-100 dark:ring-red-400/30',
].join(' ')}
title={
aiServerStatus === null
? 'AI Server Status wird geladen…'
: aiServerStatus.running
? `AI Server läuft${aiServerStatus.url ? `: ${aiServerStatus.url}` : ''}`
: `AI Server offline${aiServerStatus.error ? `: ${aiServerStatus.error}` : ''}`
}
>
<span
className={[
'h-1.5 w-1.5 rounded-full',
aiServerStatus === null
? 'bg-gray-400'
: aiServerStatus.running
? 'bg-emerald-500'
: 'bg-red-500',
].join(' ')}
aria-hidden="true"
/>
{aiServerStatus === null
? 'AI prüft…'
: aiServerStatus.running
? 'AI online'
: 'AI offline'}
</span>
</div>
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">
Recorder-Konfiguration, Automatisierung und Tasks.
</div>
@ -1400,16 +1550,16 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
/>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
<div className="sm:col-span-4">
<div className="sm:col-span-9">
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">
Object Detection Epochs
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Maximale Trainingsdauer. 60 ist ein guter Standard.
<div className="max-w-4xl text-xs text-gray-600 dark:text-gray-300">
Anzahl der Trainingsdurchläufe für die Box-Erkennung. Mehr Epochs können die Erkennung verbessern, dauern aber länger. 60 ist ein guter Standard.
</div>
</div>
<div className="sm:col-span-8">
<div className="sm:col-span-3">
<div className="flex items-center justify-end gap-2">
<input
type="number"
@ -1526,6 +1676,83 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
)}
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-950/40">
<button
type="button"
onClick={() => setAppLogOpen((v) => !v)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Log
</div>
{appLog?.sizeBytes ? (
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-700 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10">
{Math.ceil(appLog.sizeBytes / 1024)} KB
</span>
) : null}
</div>
<div className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300">
Zeigt die aktuelle recorder.log aus dem App-Verzeichnis.
</div>
</div>
<span
className={[
'shrink-0 text-sm text-gray-500 transition-transform dark:text-gray-400',
appLogOpen ? 'rotate-180' : '',
].join(' ')}
aria-hidden="true"
>
</span>
</button>
{appLogOpen ? (
<div className="border-t border-gray-200 p-4 dark:border-white/10">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0 text-xs text-gray-600 dark:text-gray-300">
{appLog?.path ? (
<span className="break-all">
Logdatei: {appLog.path}
</span>
) : appLogLoading ? (
'Logdatei wird geladen…'
) : (
'Logdatei noch nicht geladen.'
)}
</div>
<Button
variant="secondary"
size="sm"
disabled={appLogLoading}
onClick={() => void loadAppLog()}
>
{appLogLoading ? 'Lade…' : 'Aktualisieren'}
</Button>
</div>
{appLog?.error ? (
<div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-400/30 dark:bg-amber-500/10 dark:text-amber-100">
{appLog.error}
</div>
) : null}
<pre className="max-h-96 overflow-auto whitespace-pre-wrap rounded-xl bg-gray-950 p-3 font-mono text-[11px] leading-relaxed text-gray-100 ring-1 ring-black/10 dark:ring-white/10">
{appLogLoading
? 'Log wird geladen…'
: appLog?.log?.trim()
? appLog.log
: 'Noch kein Log vorhanden.'}
</pre>
</div>
) : null}
</div>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -449,14 +449,6 @@ function buildSegmentPreviewSrc(job: RecordJob | null, seg: Segment): string {
return `/api/preview?id=${encodeURIComponent(id)}&t=${encodeURIComponent(safeTime.toFixed(3))}`
}
const AUTO_SELECTED_AI_LABELS = new Set([
'anus_exposed',
'female_genitalia_exposed',
'male_genitalia_exposed',
'female_breast_exposed',
'buttocks_exposed',
])
const AI_SEGMENT_PADDING_SECONDS = 3
function pickFirstDefined<T = any>(...vals: T[]): T | undefined {
@ -829,7 +821,15 @@ function normalizeAiSegmentsFromHits(
function shouldAutoSelectAiHit(hit: {
label: string
}): boolean {
return AUTO_SELECTED_AI_LABELS.has(String(hit.label || '').trim().toLowerCase())
const label = String(hit.label || '').trim().toLowerCase()
return (
label.startsWith('combo:') ||
label.startsWith('position:') ||
label.startsWith('object:') ||
label.startsWith('clothing:') ||
label.startsWith('body:')
)
}
function segmentPairKey(aIndex: number, bIndex: number): string {

View File

@ -7,16 +7,12 @@
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,