bugfixes
This commit is contained in:
parent
ec4fcea9b1
commit
19859b15be
Binary file not shown.
@ -1,6 +1,8 @@
|
||||
# backend\ai_server.py
|
||||
# backend/ai_server.py
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import FastAPI
|
||||
@ -8,59 +10,144 @@ from pydantic import BaseModel
|
||||
from ultralytics import YOLO
|
||||
|
||||
|
||||
BODY_LABELS = {
|
||||
"anus",
|
||||
"ass",
|
||||
"breasts",
|
||||
"penis",
|
||||
"tongue",
|
||||
"pussy",
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def existing_file(path: Path) -> Optional[Path]:
|
||||
try:
|
||||
if path.exists() and path.is_file() and path.stat().st_size > 0:
|
||||
return path
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def resolve_training_root() -> Path:
|
||||
env_root = os.environ.get("TRAINING_ROOT", "").strip()
|
||||
if env_root:
|
||||
root = Path(env_root).expanduser().resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
candidates = [
|
||||
# Wenn ai_server.py aus backend/ läuft:
|
||||
BASE_DIR / "generated" / "training",
|
||||
|
||||
# Wenn ai_server.py aus backend/ml/ laufen würde:
|
||||
BASE_DIR.parent / "generated" / "training",
|
||||
|
||||
# Wenn ai_server.py embedded aus Temp läuft, aber backendRoot als cwd gesetzt wurde:
|
||||
Path.cwd() / "generated" / "training",
|
||||
|
||||
# Wenn Working Directory Projektroot ist:
|
||||
Path.cwd() / "backend" / "generated" / "training",
|
||||
]
|
||||
|
||||
for root in candidates:
|
||||
if (
|
||||
existing_file(root / "detection_labels.json")
|
||||
or existing_file(root / "detector" / "model" / "best.pt")
|
||||
):
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root.resolve()
|
||||
|
||||
# Fallback: Server soll trotzdem starten.
|
||||
root = (Path.cwd() / "generated" / "training").resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
TRAINING_ROOT = resolve_training_root()
|
||||
DEFAULT_MODEL_PATH = TRAINING_ROOT / "detector" / "model" / "best.pt"
|
||||
|
||||
|
||||
def resolve_detection_labels_path() -> Path:
|
||||
env_path = os.environ.get("DETECTION_LABELS_PATH", "").strip()
|
||||
if env_path:
|
||||
p = Path(env_path).expanduser().resolve()
|
||||
if existing_file(p):
|
||||
return p
|
||||
raise RuntimeError(f"DETECTION_LABELS_PATH not found: {p}")
|
||||
|
||||
candidates = [
|
||||
TRAINING_ROOT / "detection_labels.json",
|
||||
|
||||
# Wenn ai_server.py direkt neben detection_labels.json embedded liegt:
|
||||
BASE_DIR / "detection_labels.json",
|
||||
|
||||
# Dev-Fallbacks:
|
||||
BASE_DIR / "ml" / "detection_labels.json",
|
||||
BASE_DIR.parent / "ml" / "detection_labels.json",
|
||||
Path.cwd() / "ml" / "detection_labels.json",
|
||||
Path.cwd() / "backend" / "ml" / "detection_labels.json",
|
||||
]
|
||||
|
||||
for p in candidates:
|
||||
if existing_file(p):
|
||||
return p.resolve()
|
||||
|
||||
raise RuntimeError(
|
||||
"detection_labels.json not found. Checked: "
|
||||
+ ", ".join(str(p) for p in candidates)
|
||||
)
|
||||
|
||||
|
||||
def resolve_model_path() -> str:
|
||||
env_path = os.environ.get("YOLO_MODEL", "").strip()
|
||||
if env_path:
|
||||
p = Path(env_path).expanduser().resolve()
|
||||
if existing_file(p):
|
||||
return str(p)
|
||||
raise RuntimeError(f"YOLO_MODEL not found: {p}")
|
||||
|
||||
if existing_file(DEFAULT_MODEL_PATH):
|
||||
return str(DEFAULT_MODEL_PATH)
|
||||
|
||||
raise RuntimeError(f"YOLO model not found: {DEFAULT_MODEL_PATH}")
|
||||
|
||||
|
||||
# Server darf auch ohne Labels/Model starten.
|
||||
DETECTION_LABELS_PATH: Optional[Path] = None
|
||||
|
||||
LABEL_GROUPS = {
|
||||
"people": set(),
|
||||
"sexPositions": {"unknown"},
|
||||
"bodyParts": set(),
|
||||
"objects": set(),
|
||||
"clothing": set(),
|
||||
}
|
||||
|
||||
OBJECT_LABELS = {
|
||||
"blindfold",
|
||||
"buttplug",
|
||||
"collar",
|
||||
"dildo",
|
||||
"handcuffs",
|
||||
"shower",
|
||||
"strapon",
|
||||
"towel",
|
||||
"vibrator",
|
||||
BODY_LABELS = LABEL_GROUPS["bodyParts"]
|
||||
OBJECT_LABELS = LABEL_GROUPS["objects"]
|
||||
CLOTHING_LABELS = LABEL_GROUPS["clothing"]
|
||||
POSITION_LABELS = set()
|
||||
|
||||
PERSON_LABELS = {
|
||||
"person",
|
||||
"person_male",
|
||||
"person_female",
|
||||
"person_unknown",
|
||||
"male_person",
|
||||
"female_person",
|
||||
}
|
||||
|
||||
CLOTHING_LABELS = {
|
||||
"bikini",
|
||||
"bra",
|
||||
"dress",
|
||||
"heels",
|
||||
"hotpants",
|
||||
"lingerie",
|
||||
"panties",
|
||||
"skirt",
|
||||
"stockings",
|
||||
"croptop",
|
||||
}
|
||||
MALE_LABELS = {"person_male", "male_person"}
|
||||
FEMALE_LABELS = {"person_female", "female_person"}
|
||||
UNKNOWN_PERSON_LABELS = {"person", "person_unknown"}
|
||||
|
||||
POSITION_LABELS = {
|
||||
"missionary",
|
||||
"doggy",
|
||||
"cowgirl",
|
||||
"reverse_cowgirl",
|
||||
"cunnilingus",
|
||||
"prone_bone",
|
||||
"standing",
|
||||
"standing_doggy",
|
||||
"spooning",
|
||||
"sitting",
|
||||
"facesitting",
|
||||
"handjob",
|
||||
"blowjob",
|
||||
"toy_play",
|
||||
"fingering",
|
||||
"69",
|
||||
"other",
|
||||
}
|
||||
_MODEL_PATH = ""
|
||||
_MODEL_ERROR = ""
|
||||
_LABEL_ERROR = ""
|
||||
|
||||
_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 = None
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class PredictBatchRequest(BaseModel):
|
||||
@ -70,36 +157,133 @@ class PredictBatchRequest(BaseModel):
|
||||
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}")
|
||||
def empty_prediction(source: str = "model_missing") -> dict:
|
||||
return {
|
||||
"modelAvailable": False,
|
||||
"source": source,
|
||||
"peopleCount": 0,
|
||||
"maleCount": 0,
|
||||
"femaleCount": 0,
|
||||
"unknownCount": 0,
|
||||
"sexPosition": "unknown",
|
||||
"sexPositionScore": 0.0,
|
||||
"bodyPartsPresent": [],
|
||||
"objectsPresent": [],
|
||||
"clothingPresent": [],
|
||||
"boxes": [],
|
||||
}
|
||||
|
||||
|
||||
_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"}
|
||||
def load_label_groups_safe() -> None:
|
||||
global DETECTION_LABELS_PATH
|
||||
global LABEL_GROUPS
|
||||
global BODY_LABELS
|
||||
global OBJECT_LABELS
|
||||
global CLOTHING_LABELS
|
||||
global POSITION_LABELS
|
||||
global PERSON_LABELS
|
||||
global _LABEL_ERROR
|
||||
|
||||
model = YOLO(_MODEL_PATH)
|
||||
try:
|
||||
path = resolve_detection_labels_path()
|
||||
DETECTION_LABELS_PATH = path
|
||||
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
LABEL_GROUPS = {
|
||||
"people": set(
|
||||
str(x).strip().lower()
|
||||
for x in data.get("people", [])
|
||||
if str(x).strip()
|
||||
),
|
||||
"sexPositions": set(
|
||||
str(x).strip().lower()
|
||||
for x in data.get("sexPositions", [])
|
||||
if str(x).strip()
|
||||
),
|
||||
"bodyParts": set(
|
||||
str(x).strip().lower()
|
||||
for x in data.get("bodyParts", [])
|
||||
if str(x).strip()
|
||||
),
|
||||
"objects": set(
|
||||
str(x).strip().lower()
|
||||
for x in data.get("objects", [])
|
||||
if str(x).strip()
|
||||
),
|
||||
"clothing": set(
|
||||
str(x).strip().lower()
|
||||
for x in data.get("clothing", [])
|
||||
if str(x).strip()
|
||||
),
|
||||
}
|
||||
|
||||
if not LABEL_GROUPS["sexPositions"]:
|
||||
LABEL_GROUPS["sexPositions"] = {"unknown"}
|
||||
|
||||
_LABEL_ERROR = ""
|
||||
|
||||
except Exception as exc:
|
||||
DETECTION_LABELS_PATH = None
|
||||
_LABEL_ERROR = str(exc)
|
||||
|
||||
LABEL_GROUPS = {
|
||||
"people": set(),
|
||||
"sexPositions": {"unknown"},
|
||||
"bodyParts": set(),
|
||||
"objects": set(),
|
||||
"clothing": set(),
|
||||
}
|
||||
|
||||
BODY_LABELS = LABEL_GROUPS["bodyParts"]
|
||||
OBJECT_LABELS = LABEL_GROUPS["objects"]
|
||||
CLOTHING_LABELS = LABEL_GROUPS["clothing"]
|
||||
|
||||
POSITION_LABELS = {
|
||||
label for label in LABEL_GROUPS["sexPositions"]
|
||||
if label and label != "unknown"
|
||||
}
|
||||
|
||||
PERSON_LABELS = {
|
||||
label for label in LABEL_GROUPS["people"]
|
||||
if label
|
||||
} | {
|
||||
"person",
|
||||
"person_male",
|
||||
"person_female",
|
||||
"person_unknown",
|
||||
"male_person",
|
||||
"female_person",
|
||||
}
|
||||
|
||||
|
||||
def get_model():
|
||||
global model
|
||||
global _MODEL_PATH
|
||||
global _MODEL_ERROR
|
||||
|
||||
if model is not None:
|
||||
return model
|
||||
|
||||
try:
|
||||
path = resolve_model_path()
|
||||
loaded = YOLO(path)
|
||||
|
||||
model = loaded
|
||||
_MODEL_PATH = path
|
||||
_MODEL_ERROR = ""
|
||||
|
||||
# Labels erst laden, wenn Inference wirklich gebraucht wird.
|
||||
load_label_groups_safe()
|
||||
|
||||
return model
|
||||
|
||||
except Exception as exc:
|
||||
model = None
|
||||
_MODEL_PATH = ""
|
||||
_MODEL_ERROR = str(exc)
|
||||
return None
|
||||
|
||||
|
||||
def scored(label: str, score: float) -> dict:
|
||||
@ -143,6 +327,7 @@ def prediction_from_result(result) -> dict:
|
||||
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))
|
||||
@ -170,13 +355,10 @@ def prediction_from_result(result) -> dict:
|
||||
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)
|
||||
people_count = sum(1 for box in boxes_out if box["label"] in PERSON_LABELS)
|
||||
male_count = sum(1 for box in boxes_out if box["label"] in MALE_LABELS)
|
||||
female_count = sum(1 for box in boxes_out if box["label"] in FEMALE_LABELS)
|
||||
unknown_count = sum(1 for box in boxes_out if box["label"] in UNKNOWN_PERSON_LABELS)
|
||||
|
||||
return {
|
||||
"modelAvailable": True,
|
||||
@ -204,10 +386,18 @@ def predict_batch(req: PredictBatchRequest):
|
||||
"error": "no paths supplied",
|
||||
}
|
||||
|
||||
current_model = get_model()
|
||||
if current_model is None:
|
||||
return {
|
||||
"ok": True,
|
||||
"predictions": [empty_prediction("model_missing") for _ in paths],
|
||||
"error": _MODEL_ERROR or f"YOLO model not found: {DEFAULT_MODEL_PATH}",
|
||||
}
|
||||
|
||||
imgsz = int(req.imageSize or _IMGSZ or 640)
|
||||
|
||||
try:
|
||||
results = model.predict(
|
||||
results = current_model.predict(
|
||||
source=paths,
|
||||
imgsz=imgsz,
|
||||
conf=_CONF,
|
||||
@ -234,11 +424,19 @@ def predict_batch(req: PredictBatchRequest):
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
names = getattr(model, "names", {}) or {}
|
||||
current_model = get_model()
|
||||
names = getattr(current_model, "names", {}) or {} if current_model is not None else {}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"ready": current_model is not None,
|
||||
"modelAvailable": current_model is not None,
|
||||
"model": _MODEL_PATH,
|
||||
"modelError": _MODEL_ERROR,
|
||||
"expectedModel": str(DEFAULT_MODEL_PATH),
|
||||
"trainingRoot": str(TRAINING_ROOT),
|
||||
"classCount": len(names),
|
||||
"classes": list(names.values())[:80] if isinstance(names, dict) else names,
|
||||
"labelConfig": str(DETECTION_LABELS_PATH) if DETECTION_LABELS_PATH else "",
|
||||
"labelError": _LABEL_ERROR,
|
||||
}
|
||||
@ -8,7 +8,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
"math"
|
||||
"net/http"
|
||||
@ -17,6 +16,7 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
@ -46,11 +46,6 @@ type analyzeVideoResp struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type spriteFrameCandidate struct {
|
||||
Index int
|
||||
Time float64
|
||||
}
|
||||
|
||||
type videoFrameSample struct {
|
||||
Index int
|
||||
Time float64
|
||||
@ -62,9 +57,6 @@ const (
|
||||
nsfwThresholdModerate = 0.35
|
||||
nsfwThresholdStrong = 0.60
|
||||
|
||||
// Sprite-Modus ist aktuell deaktiviert. Analyse läuft über Video-Frames.
|
||||
analyzeMaxSpriteCandidates = 24
|
||||
|
||||
// Video-Modus: extrahiert 1 Frame alle N Sekunden.
|
||||
// 1 = jeder Sekunde, 3 = alle 3 Sekunden, 5 = alle 5 Sekunden.
|
||||
analyzeVideoFrameIntervalSeconds = 3
|
||||
@ -81,56 +73,48 @@ const (
|
||||
analyzeAIServerDefaultURL = "http://127.0.0.1:8765"
|
||||
)
|
||||
|
||||
var autoSelectedAILabels = map[string]struct{}{
|
||||
// bodyParts aus detecton_labels.json
|
||||
"anus": {},
|
||||
"ass": {},
|
||||
"breasts": {},
|
||||
"penis": {},
|
||||
"tongue": {},
|
||||
"pussy": {},
|
||||
func autoSelectedAILabelSet() map[string]struct{} {
|
||||
grouped, err := trainingGroupedLabels()
|
||||
if err != nil {
|
||||
appLogln("⚠️ analyze labels fallback:", err)
|
||||
return map[string]struct{}{}
|
||||
}
|
||||
|
||||
// objects aus detecton_labels.json
|
||||
"blindfold": {},
|
||||
"buttplug": {},
|
||||
"collar": {},
|
||||
"dildo": {},
|
||||
"handcuffs": {},
|
||||
"shower": {},
|
||||
"strapon": {},
|
||||
"towel": {},
|
||||
"vibrator": {},
|
||||
out := map[string]struct{}{}
|
||||
|
||||
// clothing aus detecton_labels.json
|
||||
"bikini": {},
|
||||
"bra": {},
|
||||
"dress": {},
|
||||
"heels": {},
|
||||
"hotpants": {},
|
||||
"lingerie": {},
|
||||
"panties": {},
|
||||
"skirt": {},
|
||||
"stockings": {},
|
||||
"croptop": {},
|
||||
add := func(values []string) {
|
||||
for _, value := range values {
|
||||
label := strings.ToLower(strings.TrimSpace(value))
|
||||
if label == "" || label == "unknown" {
|
||||
continue
|
||||
}
|
||||
out[label] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// sexPositions aus detecton_labels.json
|
||||
"missionary": {},
|
||||
"doggy": {},
|
||||
"cowgirl": {},
|
||||
"reverse_cowgirl": {},
|
||||
"cunnilingus": {},
|
||||
"prone_bone": {},
|
||||
"standing": {},
|
||||
"standing_doggy": {},
|
||||
"spooning": {},
|
||||
"sitting": {},
|
||||
"facesitting": {},
|
||||
"handjob": {},
|
||||
"blowjob": {},
|
||||
"toy_play": {},
|
||||
"fingering": {},
|
||||
"69": {},
|
||||
"other": {},
|
||||
add(grouped.BodyParts)
|
||||
add(grouped.Objects)
|
||||
add(grouped.Clothing)
|
||||
add(grouped.SexPositions)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
var autoSelectedAILabelsOnce sync.Once
|
||||
var autoSelectedAILabelsCache map[string]struct{}
|
||||
|
||||
func shouldAutoSelectAnalyzeHit(label string) bool {
|
||||
label = strings.ToLower(strings.TrimSpace(label))
|
||||
if label == "" || label == "unknown" {
|
||||
return false
|
||||
}
|
||||
|
||||
autoSelectedAILabelsOnce.Do(func() {
|
||||
autoSelectedAILabelsCache = autoSelectedAILabelSet()
|
||||
})
|
||||
|
||||
_, ok := autoSelectedAILabelsCache[label]
|
||||
return ok
|
||||
}
|
||||
|
||||
var nsfwIgnoredLabels = map[string]struct{}{
|
||||
@ -139,90 +123,20 @@ var nsfwIgnoredLabels = map[string]struct{}{
|
||||
"person_male": {},
|
||||
"person_female": {},
|
||||
"person_unknown": {},
|
||||
"male_person": {},
|
||||
"female_person": {},
|
||||
|
||||
// Falls dein Detector irgendwann diese Varianten liefert:
|
||||
"people_male": {},
|
||||
"people_female": {},
|
||||
}
|
||||
|
||||
func shouldAutoSelectAnalyzeHit(label string) bool {
|
||||
label = strings.ToLower(strings.TrimSpace(label))
|
||||
_, ok := autoSelectedAILabels[label]
|
||||
return ok
|
||||
}
|
||||
|
||||
func isIgnoredNSFWLabel(label string) bool {
|
||||
label = strings.ToLower(strings.TrimSpace(label))
|
||||
_, ok := nsfwIgnoredLabels[label]
|
||||
return ok
|
||||
}
|
||||
|
||||
func extractSpriteFrames(spritePath string, ps previewSpriteMetaFileInfo) ([]image.Image, error) {
|
||||
f, err := os.Open(spritePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
img, _, err := image.Decode(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b := img.Bounds()
|
||||
if ps.Cols <= 0 || ps.Rows <= 0 {
|
||||
return nil, appErrorf("sprite cols/rows fehlen")
|
||||
}
|
||||
|
||||
cellW := b.Dx() / ps.Cols
|
||||
cellH := b.Dy() / ps.Rows
|
||||
if cellW <= 0 || cellH <= 0 {
|
||||
return nil, appErrorf("ungültige sprite cell size")
|
||||
}
|
||||
|
||||
count := ps.Count
|
||||
if count <= 0 {
|
||||
count = ps.Cols * ps.Rows
|
||||
}
|
||||
|
||||
out := make([]image.Image, 0, count)
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
col := i % ps.Cols
|
||||
row := i / ps.Cols
|
||||
if row >= ps.Rows {
|
||||
break
|
||||
}
|
||||
|
||||
srcRect := image.Rect(
|
||||
b.Min.X+col*cellW,
|
||||
b.Min.Y+row*cellH,
|
||||
b.Min.X+(col+1)*cellW,
|
||||
b.Min.Y+(row+1)*cellH,
|
||||
)
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, cellW, cellH))
|
||||
draw.Draw(dst, dst.Bounds(), img, srcRect.Min, draw.Src)
|
||||
out = append(out, dst)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func classifyFrameNSFW(ctx context.Context, img image.Image) (*NsfwImageResponse, error) {
|
||||
_ = ctx
|
||||
|
||||
results, err := detectNSFWFromImage(img)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &NsfwImageResponse{
|
||||
Ok: true,
|
||||
Results: results,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func addTrainingAnalyzeResult(best map[string]float64, label string, score float64) {
|
||||
label = strings.ToLower(strings.TrimSpace(label))
|
||||
if label == "" {
|
||||
@ -1146,109 +1060,6 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func analyzeVideoFromSpriteAllGoals(ctx context.Context, outPath string) (nsfwHits []analyzeHit, highlightHits []analyzeHit, err error) {
|
||||
id := strings.TrimSpace(videoIDFromOutputPath(outPath))
|
||||
if id == "" {
|
||||
return nil, nil, appErrorf("konnte keine video-id aus output ableiten")
|
||||
}
|
||||
|
||||
metaPath, err := generatedMetaFile(id)
|
||||
if err != nil || strings.TrimSpace(metaPath) == "" {
|
||||
return nil, nil, appErrorf("meta.json nicht gefunden")
|
||||
}
|
||||
|
||||
ps, ok := readPreviewSpriteMetaFromMetaFile(metaPath)
|
||||
if !ok {
|
||||
return nil, nil, appErrorf("previewSprite meta fehlt")
|
||||
}
|
||||
if ps.Count <= 0 {
|
||||
return nil, nil, appErrorf("previewSprite count fehlt")
|
||||
}
|
||||
|
||||
spritePath := filepath.Join(filepath.Dir(metaPath), "preview-sprite.jpg")
|
||||
if fi, err := os.Stat(spritePath); err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||
return nil, nil, appErrorf("preview-sprite.jpg nicht gefunden")
|
||||
}
|
||||
|
||||
durationSec := ps.StepSeconds * math.Max(1, float64(ps.Count-1))
|
||||
if durationSec <= 0 {
|
||||
durationSec, _ = durationSecondsForAnalyze(ctx, outPath)
|
||||
}
|
||||
|
||||
candidates := buildSpriteFrameCandidates(ps.Count, ps.StepSeconds, durationSec)
|
||||
candidates = limitSpriteFrameCandidates(candidates, analyzeMaxSpriteCandidates)
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return nil, nil, appErrorf("keine sprite-kandidaten vorhanden")
|
||||
}
|
||||
|
||||
// 1) Schneller Pfad: Python-Batch.
|
||||
results, batchErr := trainingPredictSpriteBatch(ctx, spritePath, ps, candidates)
|
||||
if batchErr == nil {
|
||||
for _, item := range results {
|
||||
pred := item.Prediction
|
||||
if !pred.ModelAvailable {
|
||||
continue
|
||||
}
|
||||
|
||||
t := item.Time
|
||||
|
||||
nsfwResults := trainingPredictionToNSFWResults(pred)
|
||||
bestLabel, bestScore := pickBestNSFWResult(nsfwResults)
|
||||
if bestLabel != "" && bestScore >= nsfwThresholdForLabel(bestLabel) {
|
||||
nsfwHits = append(nsfwHits, analyzeHit{
|
||||
Time: t,
|
||||
Label: bestLabel,
|
||||
Score: bestScore,
|
||||
Start: t,
|
||||
End: t,
|
||||
})
|
||||
}
|
||||
|
||||
highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, t)
|
||||
}
|
||||
|
||||
return mergeAnalyzeHits(nsfwHits), mergeAnalyzeHits(highlightHits), nil
|
||||
}
|
||||
|
||||
// 2) Fallback: alte langsame Methode, damit Analyse nicht komplett fehlschlägt.
|
||||
appLogln("⚠️ sprite batch analyse fehlgeschlagen, fallback auf langsame Analyse:", batchErr)
|
||||
|
||||
frames, err := extractSpriteFrames(spritePath, ps)
|
||||
if err != nil {
|
||||
return nil, nil, appErrorf("sprite frames extrahieren fehlgeschlagen: %w", err)
|
||||
}
|
||||
|
||||
for _, c := range candidates {
|
||||
if c.Index < 0 || c.Index >= len(frames) {
|
||||
continue
|
||||
}
|
||||
|
||||
pred := predictFrameForAnalyze(ctx, frames[c.Index])
|
||||
if !pred.ModelAvailable {
|
||||
continue
|
||||
}
|
||||
|
||||
t := c.Time
|
||||
|
||||
nsfwResults := trainingPredictionToNSFWResults(pred)
|
||||
bestLabel, bestScore := pickBestNSFWResult(nsfwResults)
|
||||
if bestLabel != "" && bestScore >= nsfwThresholdForLabel(bestLabel) {
|
||||
nsfwHits = append(nsfwHits, analyzeHit{
|
||||
Time: t,
|
||||
Label: bestLabel,
|
||||
Score: bestScore,
|
||||
Start: t,
|
||||
End: t,
|
||||
})
|
||||
}
|
||||
|
||||
highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, t)
|
||||
}
|
||||
|
||||
return mergeAnalyzeHits(nsfwHits), mergeAnalyzeHits(highlightHits), nil
|
||||
}
|
||||
|
||||
func nsfwThresholdForLabel(label string) float64 {
|
||||
label = strings.ToLower(strings.TrimSpace(label))
|
||||
|
||||
@ -2174,65 +1985,6 @@ func analyzeVideoFromFramesForGoal(
|
||||
return cleanNSFWHits, cleanHighlightHits, nil
|
||||
}
|
||||
|
||||
func analyzeSpriteCandidatesWithAI(
|
||||
ctx context.Context,
|
||||
spritePath string,
|
||||
ps previewSpriteMetaFileInfo,
|
||||
candidates []spriteFrameCandidate,
|
||||
goal string,
|
||||
) ([]analyzeHit, error) {
|
||||
frames, err := extractSpriteFrames(spritePath, ps)
|
||||
if err != nil {
|
||||
return nil, appErrorf("sprite frames extrahieren fehlgeschlagen: %w", err)
|
||||
}
|
||||
|
||||
hits := make([]analyzeHit, 0, len(candidates))
|
||||
|
||||
for _, c := range candidates {
|
||||
if c.Index < 0 || c.Index >= len(frames) {
|
||||
continue
|
||||
}
|
||||
|
||||
img := frames[c.Index]
|
||||
|
||||
switch goal {
|
||||
case "nsfw":
|
||||
res, err := classifyFrameForAnalyze(ctx, img)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
bestLabel, bestScore := pickBestNSFWResult(res.Results)
|
||||
if bestLabel == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
threshold := nsfwThresholdForLabel(bestLabel)
|
||||
if bestScore < threshold {
|
||||
continue
|
||||
}
|
||||
|
||||
hits = append(hits, analyzeHit{
|
||||
Time: c.Time,
|
||||
Label: bestLabel,
|
||||
Score: bestScore,
|
||||
Start: c.Time,
|
||||
End: c.Time,
|
||||
})
|
||||
|
||||
case "highlights":
|
||||
pred := predictFrameForAnalyze(ctx, img)
|
||||
if !pred.ModelAvailable {
|
||||
continue
|
||||
}
|
||||
|
||||
hits = appendHighlightHitsFromPrediction(hits, pred, c.Time)
|
||||
}
|
||||
}
|
||||
|
||||
return hits, nil
|
||||
}
|
||||
|
||||
func sameAnalyzeComboLabel(a, b string) bool {
|
||||
a = strings.ToLower(strings.TrimSpace(a))
|
||||
b = strings.ToLower(strings.TrimSpace(b))
|
||||
@ -2884,127 +2636,6 @@ func buildAnalyzeSegmentsForGoal(
|
||||
}
|
||||
}
|
||||
|
||||
func buildSpriteFrameCandidates(count int, stepSeconds, durationSec float64) []spriteFrameCandidate {
|
||||
if count <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]spriteFrameCandidate, 0, count)
|
||||
|
||||
stepLooksUsable := false
|
||||
if stepSeconds > 0 && durationSec > 0 {
|
||||
coverage := stepSeconds * math.Max(1, float64(count-1))
|
||||
stepLooksUsable = coverage >= durationSec*0.7 && coverage <= durationSec*1.3
|
||||
}
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
var t float64
|
||||
|
||||
if stepLooksUsable {
|
||||
t = float64(i) * stepSeconds
|
||||
} else if durationSec > 0 && count > 1 {
|
||||
t = (float64(i) / float64(count-1)) * durationSec
|
||||
} else if stepSeconds > 0 {
|
||||
t = float64(i) * stepSeconds
|
||||
} else {
|
||||
t = float64(i)
|
||||
}
|
||||
|
||||
out = append(out, spriteFrameCandidate{
|
||||
Index: i,
|
||||
Time: t,
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func limitSpriteFrameCandidates(in []spriteFrameCandidate, max int) []spriteFrameCandidate {
|
||||
if max <= 0 || len(in) <= max {
|
||||
return in
|
||||
}
|
||||
|
||||
out := make([]spriteFrameCandidate, 0, max)
|
||||
seen := map[int]bool{}
|
||||
|
||||
if max == 1 {
|
||||
return []spriteFrameCandidate{in[len(in)/2]}
|
||||
}
|
||||
|
||||
for i := 0; i < max; i++ {
|
||||
ratio := float64(i) / float64(max-1)
|
||||
idx := int(math.Round(ratio * float64(len(in)-1)))
|
||||
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
if idx >= len(in) {
|
||||
idx = len(in) - 1
|
||||
}
|
||||
|
||||
if seen[idx] {
|
||||
continue
|
||||
}
|
||||
seen[idx] = true
|
||||
|
||||
out = append(out, in[idx])
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return in
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func buildVideoSampleTimes(durationSec float64, sampleCount int) []float64 {
|
||||
if durationSec <= 0 || sampleCount <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Nicht exakt bei 0.0s und nicht exakt am Videoende sampeln.
|
||||
// Anfang/Ende sind häufiger schwarz, unscharf oder ffmpeg schlägt am Ende fehl.
|
||||
startPad := math.Min(1.0, durationSec*0.05)
|
||||
endPad := math.Min(1.0, durationSec*0.05)
|
||||
|
||||
start := startPad
|
||||
end := durationSec - endPad
|
||||
|
||||
if end <= start {
|
||||
start = 0
|
||||
end = durationSec
|
||||
}
|
||||
|
||||
if sampleCount == 1 {
|
||||
return []float64{(start + end) / 2}
|
||||
}
|
||||
|
||||
out := make([]float64, 0, sampleCount)
|
||||
|
||||
for i := 0; i < sampleCount; i++ {
|
||||
ratio := float64(i) / float64(sampleCount-1)
|
||||
t := start + ratio*(end-start)
|
||||
|
||||
if t < 0 {
|
||||
t = 0
|
||||
}
|
||||
if t > durationSec {
|
||||
t = durationSec
|
||||
}
|
||||
|
||||
out = append(out, t)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func inferredSpanSeconds(stepSeconds float64, fallback float64) float64 {
|
||||
if stepSeconds > 0 {
|
||||
return math.Max(2, stepSeconds*1.5)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func durationSecondsForAnalyze(ctx context.Context, outPath string) (float64, error) {
|
||||
ctx2, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@ -19,21 +19,33 @@ func trainingEmbeddedMLDir() (string, error) {
|
||||
}
|
||||
|
||||
files := []string{
|
||||
"predict_scene_model.py",
|
||||
"train_scene_model.py",
|
||||
"predict_detector_model.py",
|
||||
"train_detector_model.py",
|
||||
"detection_labels.json",
|
||||
}
|
||||
|
||||
for _, name := range files {
|
||||
srcPath := filepath.Join("ml", name)
|
||||
// Falls du die alten Scene-Skripte noch embedded hast, kannst du sie optional mitkopieren.
|
||||
optionalFiles := []string{
|
||||
"predict_scene_model.py",
|
||||
"train_scene_model.py",
|
||||
}
|
||||
|
||||
b, err := embeddedMLFiles.ReadFile(filepath.ToSlash(srcPath))
|
||||
for _, name := range append(files, optionalFiles...) {
|
||||
srcPath := filepath.ToSlash(filepath.Join("ml", name))
|
||||
|
||||
b, err := embeddedMLFiles.ReadFile(srcPath)
|
||||
if err != nil {
|
||||
// Pflichtdateien müssen vorhanden sein.
|
||||
if name == "detection_labels.json" ||
|
||||
name == "predict_detector_model.py" ||
|
||||
name == "train_detector_model.py" {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Optionale alte Dateien ignorieren.
|
||||
continue
|
||||
}
|
||||
|
||||
dstPath := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(dstPath, b, 0644); err != nil {
|
||||
return "", err
|
||||
|
||||
@ -124,7 +124,7 @@ def main():
|
||||
|
||||
print(json.dumps({
|
||||
"available": True,
|
||||
"source": "yolo_detector",
|
||||
"source": "yolo26_detector",
|
||||
"modelPath": str(model_path),
|
||||
"image": str(image_path),
|
||||
"conf": float(args.conf),
|
||||
|
||||
@ -1,177 +0,0 @@
|
||||
# backend\ml\predict_scene_model.py
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import joblib
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
from transformers import CLIPModel, CLIPProcessor
|
||||
|
||||
|
||||
CLIP_MODEL_NAME = "openai/clip-vit-base-patch32"
|
||||
|
||||
|
||||
def empty_prediction(source="no_model"):
|
||||
return {
|
||||
"modelAvailable": False,
|
||||
"source": source,
|
||||
"peopleCount": 0,
|
||||
"maleCount": 0,
|
||||
"femaleCount": 0,
|
||||
"unknownCount": 0,
|
||||
"sexPosition": "unknown",
|
||||
"sexPositionScore": 0,
|
||||
"bodyPartsPresent": [],
|
||||
"objectsPresent": [],
|
||||
"clothingPresent": [],
|
||||
"boxes": [],
|
||||
}
|
||||
|
||||
|
||||
def load_clip():
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
|
||||
processor = CLIPProcessor.from_pretrained(CLIP_MODEL_NAME)
|
||||
model = CLIPModel.from_pretrained(CLIP_MODEL_NAME)
|
||||
model.eval()
|
||||
model.to(device)
|
||||
|
||||
return model, processor, device
|
||||
|
||||
|
||||
def image_features_to_tensor(model, out):
|
||||
if torch.is_tensor(out):
|
||||
return out
|
||||
|
||||
if hasattr(out, "image_embeds") and out.image_embeds is not None:
|
||||
return out.image_embeds
|
||||
|
||||
if hasattr(out, "pooler_output") and out.pooler_output is not None:
|
||||
emb = out.pooler_output
|
||||
|
||||
# Nur projizieren, wenn pooler_output noch die erwartete Eingangsgröße hat.
|
||||
# Bei manchen Transformers-Versionen ist pooler_output bereits 512-dimensional.
|
||||
projection = getattr(model, "visual_projection", None)
|
||||
if projection is not None and hasattr(projection, "in_features"):
|
||||
if emb.shape[-1] == projection.in_features:
|
||||
emb = projection(emb)
|
||||
|
||||
return emb
|
||||
|
||||
if isinstance(out, (tuple, list)) and len(out) > 0:
|
||||
first = out[0]
|
||||
if torch.is_tensor(first):
|
||||
return first
|
||||
|
||||
raise TypeError(f"Unsupported CLIP image feature output: {type(out)!r}")
|
||||
|
||||
|
||||
def embed_image(model, processor, device, image_path: Path):
|
||||
img = Image.open(image_path).convert("RGB")
|
||||
|
||||
inputs = processor(images=img, return_tensors="pt")
|
||||
inputs = {k: v.to(device) for k, v in inputs.items()}
|
||||
|
||||
with torch.no_grad():
|
||||
try:
|
||||
out = model.get_image_features(**inputs)
|
||||
except Exception:
|
||||
out = model.vision_model(pixel_values=inputs["pixel_values"])
|
||||
|
||||
emb = image_features_to_tensor(model, out)
|
||||
|
||||
emb = emb.detach().cpu().numpy()[0].astype("float32")
|
||||
|
||||
norm = np.linalg.norm(emb)
|
||||
if norm > 0:
|
||||
emb = emb / norm
|
||||
|
||||
return emb.reshape(1, -1)
|
||||
|
||||
|
||||
def predict_with_model(model, emb):
|
||||
label = str(model.predict(emb)[0])
|
||||
|
||||
score = 0.0
|
||||
if hasattr(model, "predict_proba"):
|
||||
probs = model.predict_proba(emb)[0]
|
||||
classes = [str(x) for x in model.classes_]
|
||||
|
||||
if label in classes:
|
||||
score = float(probs[classes.index(label)])
|
||||
elif len(probs) > 0:
|
||||
score = float(np.max(probs))
|
||||
|
||||
return label, score
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", required=True)
|
||||
parser.add_argument("--image", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
root = Path(args.root)
|
||||
image_path = Path(args.image)
|
||||
|
||||
model_dir = root / "model"
|
||||
lr_path = model_dir / "scene_clip_lr.joblib"
|
||||
knn_path = model_dir / "scene_clip_knn.joblib"
|
||||
|
||||
if not lr_path.exists() and not knn_path.exists():
|
||||
print(json.dumps(empty_prediction("scene_clip_missing"), ensure_ascii=False))
|
||||
return
|
||||
|
||||
try:
|
||||
clip_model, processor, device = load_clip()
|
||||
emb = embed_image(clip_model, processor, device, image_path)
|
||||
except Exception as e:
|
||||
out = empty_prediction("scene_clip_embed_failed")
|
||||
out["error"] = repr(e)
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
return
|
||||
|
||||
# Bevorzugt Logistic Regression, weil sie stabilere Wahrscheinlichkeiten liefert.
|
||||
# KNN bleibt Fallback, wenn nur eine Klasse oder sehr wenig Daten vorhanden sind.
|
||||
source = "scene_position_clip_lr"
|
||||
|
||||
try:
|
||||
if lr_path.exists():
|
||||
model = joblib.load(lr_path)
|
||||
sex_position, score = predict_with_model(model, emb)
|
||||
else:
|
||||
source = "scene_position_clip_knn"
|
||||
model = joblib.load(knn_path)
|
||||
sex_position, score = predict_with_model(model, emb)
|
||||
except Exception as e:
|
||||
out = empty_prediction("scene_clip_predict_failed")
|
||||
out["error"] = repr(e)
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
return
|
||||
|
||||
if not sex_position:
|
||||
sex_position = "unknown"
|
||||
|
||||
pred = {
|
||||
"modelAvailable": True,
|
||||
"source": source,
|
||||
"peopleCount": 0,
|
||||
"maleCount": 0,
|
||||
"femaleCount": 0,
|
||||
"unknownCount": 0,
|
||||
"sexPosition": sex_position,
|
||||
"sexPositionScore": float(score),
|
||||
"bodyPartsPresent": [],
|
||||
"objectsPresent": [],
|
||||
"clothingPresent": [],
|
||||
"boxes": [],
|
||||
}
|
||||
|
||||
print(json.dumps(pred, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -53,7 +53,7 @@ def safe_int(value, fallback):
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", required=True)
|
||||
parser.add_argument("--base", default="yolo11n.pt")
|
||||
parser.add_argument("--base", default="yolo26n.pt")
|
||||
parser.add_argument("--epochs", default="80")
|
||||
parser.add_argument("--imgsz", default="640")
|
||||
parser.add_argument("--device", default="auto")
|
||||
|
||||
@ -1,327 +0,0 @@
|
||||
# backend\ml\train_scene_model.py
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import joblib
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
from sklearn.linear_model import LogisticRegression
|
||||
from sklearn.neighbors import KNeighborsClassifier
|
||||
from transformers import CLIPModel, CLIPProcessor
|
||||
|
||||
|
||||
CLIP_MODEL_NAME = "openai/clip-vit-base-patch32"
|
||||
|
||||
|
||||
def read_jsonl(path: Path):
|
||||
if not path.exists():
|
||||
return []
|
||||
|
||||
rows = []
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
rows.append(json.loads(line))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def prediction_target(annotation):
|
||||
pred = annotation.get("prediction") or {}
|
||||
return str(pred.get("sexPosition") or "unknown").strip() or "unknown"
|
||||
|
||||
|
||||
def correction_target(annotation):
|
||||
corr = annotation.get("correction") or {}
|
||||
return str(corr.get("sexPosition") or "unknown").strip() or "unknown"
|
||||
|
||||
|
||||
def target_from_annotation(annotation):
|
||||
if annotation.get("accepted") is True:
|
||||
return prediction_target(annotation)
|
||||
|
||||
return correction_target(annotation)
|
||||
|
||||
def emit_progress(stage, progress, message="", **extra):
|
||||
out = {
|
||||
"type": "progress",
|
||||
"stage": stage,
|
||||
"progress": max(0.0, min(1.0, float(progress))),
|
||||
"message": message,
|
||||
}
|
||||
out.update(extra)
|
||||
print(json.dumps(out, ensure_ascii=False), flush=True)
|
||||
|
||||
def load_clip():
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
|
||||
processor = CLIPProcessor.from_pretrained(CLIP_MODEL_NAME)
|
||||
model = CLIPModel.from_pretrained(CLIP_MODEL_NAME)
|
||||
model.eval()
|
||||
model.to(device)
|
||||
|
||||
return model, processor, device
|
||||
|
||||
|
||||
def image_features_to_tensor(model, out):
|
||||
if torch.is_tensor(out):
|
||||
return out
|
||||
|
||||
if hasattr(out, "image_embeds") and out.image_embeds is not None:
|
||||
return out.image_embeds
|
||||
|
||||
if hasattr(out, "pooler_output") and out.pooler_output is not None:
|
||||
emb = out.pooler_output
|
||||
|
||||
# Nur projizieren, wenn pooler_output noch die erwartete Eingangsgröße hat.
|
||||
# Bei manchen Transformers-Versionen ist pooler_output bereits 512-dimensional.
|
||||
projection = getattr(model, "visual_projection", None)
|
||||
if projection is not None and hasattr(projection, "in_features"):
|
||||
if emb.shape[-1] == projection.in_features:
|
||||
emb = projection(emb)
|
||||
|
||||
return emb
|
||||
|
||||
if isinstance(out, (tuple, list)) and len(out) > 0:
|
||||
first = out[0]
|
||||
if torch.is_tensor(first):
|
||||
return first
|
||||
|
||||
raise TypeError(f"Unsupported CLIP image feature output: {type(out)!r}")
|
||||
|
||||
|
||||
def embed_image(model, processor, device, image_path: Path):
|
||||
img = Image.open(image_path).convert("RGB")
|
||||
|
||||
inputs = processor(images=img, return_tensors="pt")
|
||||
inputs = {k: v.to(device) for k, v in inputs.items()}
|
||||
|
||||
with torch.no_grad():
|
||||
try:
|
||||
out = model.get_image_features(**inputs)
|
||||
except Exception:
|
||||
out = model.vision_model(pixel_values=inputs["pixel_values"])
|
||||
|
||||
emb = image_features_to_tensor(model, out)
|
||||
|
||||
emb = emb.detach().cpu().numpy()[0].astype("float32")
|
||||
|
||||
norm = np.linalg.norm(emb)
|
||||
if norm > 0:
|
||||
emb = emb / norm
|
||||
|
||||
return emb
|
||||
|
||||
|
||||
def train_lr_if_possible(x, y):
|
||||
classes = sorted(set(y))
|
||||
|
||||
if len(classes) < 2:
|
||||
return None
|
||||
|
||||
# Logistic Regression braucht mindestens zwei Klassen.
|
||||
# class_weight hilft bei unausgeglichenen Positionen.
|
||||
clf = LogisticRegression(
|
||||
max_iter=2000,
|
||||
class_weight="balanced",
|
||||
solver="lbfgs",
|
||||
)
|
||||
|
||||
clf.fit(x, y)
|
||||
return clf
|
||||
|
||||
|
||||
def train_knn(x, y):
|
||||
n_neighbors = min(7, len(y))
|
||||
|
||||
clf = KNeighborsClassifier(
|
||||
n_neighbors=n_neighbors,
|
||||
metric="cosine",
|
||||
weights="distance",
|
||||
algorithm="brute",
|
||||
)
|
||||
|
||||
clf.fit(x, y)
|
||||
return clf
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
root = Path(args.root)
|
||||
feedback_path = root / "feedback.jsonl"
|
||||
frames_dir = root / "frames"
|
||||
model_dir = root / "model"
|
||||
model_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
rows = read_jsonl(feedback_path)
|
||||
total = max(1, len(rows))
|
||||
|
||||
emit_progress(
|
||||
"scene",
|
||||
0.02,
|
||||
"CLIP-Modell wird geladen…",
|
||||
totalSamples=len(rows),
|
||||
)
|
||||
|
||||
clip_model, processor, device = load_clip()
|
||||
|
||||
emit_progress(
|
||||
"scene",
|
||||
0.08,
|
||||
"CLIP-Embeddings werden vorbereitet…",
|
||||
totalSamples=len(rows),
|
||||
device=device,
|
||||
)
|
||||
|
||||
embeddings = []
|
||||
labels = []
|
||||
targets = []
|
||||
used = 0
|
||||
skipped = 0
|
||||
|
||||
for idx, row in enumerate(rows, start=1):
|
||||
sample_id = str(row.get("sampleId") or "").strip()
|
||||
|
||||
try:
|
||||
if not sample_id:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
image_path = frames_dir / f"{sample_id}.jpg"
|
||||
if not image_path.exists():
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
label = target_from_annotation(row)
|
||||
if not label or label == "unknown":
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
emb = embed_image(clip_model, processor, device, image_path)
|
||||
|
||||
embeddings.append(emb)
|
||||
labels.append(label)
|
||||
targets.append({
|
||||
"sampleId": sample_id,
|
||||
"sexPosition": label,
|
||||
})
|
||||
used += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"skip {sample_id or '<missing>'}: {repr(e)}", flush=True)
|
||||
skipped += 1
|
||||
|
||||
finally:
|
||||
emit_progress(
|
||||
"scene",
|
||||
0.08 + 0.78 * (idx / total),
|
||||
f"Scene-Samples werden verarbeitet… {idx}/{len(rows)}",
|
||||
currentSample=idx,
|
||||
totalSamples=len(rows),
|
||||
usedSamples=used,
|
||||
skippedSamples=skipped,
|
||||
)
|
||||
|
||||
if used < 5:
|
||||
raise SystemExit(f"too few usable samples: {used}")
|
||||
|
||||
emit_progress(
|
||||
"scene",
|
||||
0.88,
|
||||
"Scene-Embeddings werden gespeichert…",
|
||||
usedSamples=used,
|
||||
skippedSamples=skipped,
|
||||
)
|
||||
|
||||
x = np.stack(embeddings).astype("float32")
|
||||
y = np.array(labels)
|
||||
|
||||
np.savez_compressed(
|
||||
model_dir / "scene_clip_embeddings.npz",
|
||||
embeddings=x,
|
||||
labels=y,
|
||||
)
|
||||
|
||||
with (model_dir / "scene_clip_targets.json").open("w", encoding="utf-8") as f:
|
||||
json.dump(targets, f, ensure_ascii=False, indent=2)
|
||||
|
||||
emit_progress(
|
||||
"scene",
|
||||
0.93,
|
||||
"Scene-KNN wird trainiert…",
|
||||
usedSamples=used,
|
||||
skippedSamples=skipped,
|
||||
)
|
||||
|
||||
knn = train_knn(x, y)
|
||||
joblib.dump(knn, model_dir / "scene_clip_knn.joblib")
|
||||
|
||||
emit_progress(
|
||||
"scene",
|
||||
0.96,
|
||||
"Scene-Logistic-Regression wird trainiert…",
|
||||
usedSamples=used,
|
||||
skippedSamples=skipped,
|
||||
)
|
||||
|
||||
lr_status = "skipped_single_class"
|
||||
lr = train_lr_if_possible(x, y)
|
||||
if lr is not None:
|
||||
joblib.dump(lr, model_dir / "scene_clip_lr.joblib")
|
||||
lr_status = "trained"
|
||||
else:
|
||||
old_lr = model_dir / "scene_clip_lr.joblib"
|
||||
if old_lr.exists():
|
||||
old_lr.unlink()
|
||||
|
||||
counts = {}
|
||||
for label in labels:
|
||||
counts[label] = counts.get(label, 0) + 1
|
||||
|
||||
status = {
|
||||
"ok": True,
|
||||
"usedSamples": used,
|
||||
"skippedSamples": skipped,
|
||||
"model": "scene_position_clip",
|
||||
"clipModel": CLIP_MODEL_NAME,
|
||||
"device": device,
|
||||
"classes": sorted(counts.keys()),
|
||||
"classCounts": counts,
|
||||
"logisticRegression": lr_status,
|
||||
"knn": "trained",
|
||||
"embeddingsPath": str(model_dir / "scene_clip_embeddings.npz"),
|
||||
"knnPath": str(model_dir / "scene_clip_knn.joblib"),
|
||||
"lrPath": str(model_dir / "scene_clip_lr.joblib"),
|
||||
}
|
||||
|
||||
with (model_dir / "status.json").open("w", encoding="utf-8") as f:
|
||||
json.dump(status, f, ensure_ascii=False, indent=2)
|
||||
|
||||
emit_progress(
|
||||
"scene",
|
||||
1.0,
|
||||
"CLIP-Scene-Positionsmodell fertig.",
|
||||
usedSamples=used,
|
||||
skippedSamples=skipped,
|
||||
classes=sorted(counts.keys()),
|
||||
logisticRegression=lr_status,
|
||||
knn="trained",
|
||||
)
|
||||
|
||||
print(json.dumps(status, ensure_ascii=False), flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -78,6 +78,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
||||
api.HandleFunc("/api/training/frame", trainingFrameHandler)
|
||||
api.HandleFunc("/api/training/feedback", trainingFeedbackHandler)
|
||||
api.HandleFunc("/api/training/train", trainingTrainHandler)
|
||||
api.HandleFunc("/api/training/cancel", trainingCancelHandler)
|
||||
api.HandleFunc("/api/training/status", trainingStatusHandler)
|
||||
api.HandleFunc("/api/training/stats", trainingStatsHandler)
|
||||
api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -19,9 +19,14 @@ type TrainingGroupedLabels struct {
|
||||
}
|
||||
|
||||
func trainingDetectionLabelsPath() string {
|
||||
if p, err := ensureTrainingDetectionLabelsFile(); err == nil {
|
||||
return p
|
||||
}
|
||||
|
||||
// Fallback: Temp-embedded ML.
|
||||
if dir, err := trainingEmbeddedMLDir(); err == nil {
|
||||
p := filepath.Join(dir, "detection_labels.json")
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
if st, err := os.Stat(p); err == nil && st != nil && !st.IsDir() && st.Size() > 0 {
|
||||
return p
|
||||
}
|
||||
}
|
||||
@ -37,7 +42,7 @@ func trainingDetectionLabelsPath() string {
|
||||
}
|
||||
|
||||
for _, p := range candidates {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
if st, err := os.Stat(p); err == nil && st != nil && !st.IsDir() && st.Size() > 0 {
|
||||
return p
|
||||
}
|
||||
}
|
||||
@ -45,12 +50,62 @@ func trainingDetectionLabelsPath() string {
|
||||
return filepath.Join(projectRoot, "backend", "ml", "detection_labels.json")
|
||||
}
|
||||
|
||||
func trainingGeneratedRootDirNoLabels() (string, error) {
|
||||
backendRoot, err := trainingBackendRootDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
root, err := filepath.Abs(filepath.Join(backendRoot, "generated", "training"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(root, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func ensureTrainingDetectionLabelsFile() (string, error) {
|
||||
root, err := trainingGeneratedRootDirNoLabels()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(root, 0755); err != nil {
|
||||
return "", appErrorf("generated/training konnte nicht erstellt werden: %w", err)
|
||||
}
|
||||
|
||||
dstPath := filepath.Join(root, "detection_labels.json")
|
||||
|
||||
if st, err := os.Stat(dstPath); err == nil && st != nil && !st.IsDir() && st.Size() > 0 {
|
||||
return dstPath, nil
|
||||
}
|
||||
|
||||
b, err := embeddedMLFiles.ReadFile("ml/detection_labels.json")
|
||||
if err != nil {
|
||||
return "", appErrorf("embedded detection_labels.json fehlt: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
|
||||
return "", appErrorf("generated/training konnte nicht erstellt werden: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(dstPath, b, 0644); err != nil {
|
||||
return "", appErrorf("detection_labels.json konnte nicht nach generated/training kopiert werden: %w", err)
|
||||
}
|
||||
|
||||
return dstPath, nil
|
||||
}
|
||||
|
||||
func trainingGroupedLabels() (TrainingGroupedLabels, error) {
|
||||
path := trainingDetectionLabelsPath()
|
||||
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return TrainingGroupedLabels{}, appErrorf("detection_labels.json konnte nicht gelesen werden: %w", err)
|
||||
return TrainingGroupedLabels{}, appErrorf("detection_labels.json konnte nicht gelesen werden (%s): %w", path, err)
|
||||
}
|
||||
|
||||
var grouped TrainingGroupedLabels
|
||||
@ -68,8 +123,8 @@ func trainingGroupedLabels() (TrainingGroupedLabels, error) {
|
||||
grouped.SexPositions = []string{"unknown"}
|
||||
}
|
||||
|
||||
if len(grouped.BodyParts)+len(grouped.Objects)+len(grouped.Clothing) == 0 {
|
||||
return TrainingGroupedLabels{}, appErrorf("detection_labels.json enthält keine Detection-Labels")
|
||||
if len(grouped.People)+len(grouped.SexPositions)+len(grouped.BodyParts)+len(grouped.Objects)+len(grouped.Clothing) == 0 {
|
||||
return TrainingGroupedLabels{}, appErrorf("detection_labels.json enthält keine Labels")
|
||||
}
|
||||
|
||||
if len(grouped.People)+len(grouped.BodyParts)+len(grouped.Objects)+len(grouped.Clothing) == 0 {
|
||||
@ -87,10 +142,17 @@ func trainingDetectorLabels() ([]string, error) {
|
||||
|
||||
labels := []string{}
|
||||
|
||||
// Wichtig:
|
||||
// People zuerst oder zuletzt ist egal, aber die Reihenfolge bestimmt YOLO-Class-IDs.
|
||||
// Wenn du schon ein bestehendes Detector-Modell hast, musst du danach neu trainieren.
|
||||
labels = append(labels, grouped.People...)
|
||||
|
||||
for _, label := range grouped.SexPositions {
|
||||
clean := strings.TrimSpace(label)
|
||||
if clean == "" || clean == "unknown" {
|
||||
continue
|
||||
}
|
||||
|
||||
labels = append(labels, clean)
|
||||
}
|
||||
|
||||
labels = append(labels, grouped.BodyParts...)
|
||||
labels = append(labels, grouped.Objects...)
|
||||
labels = append(labels, grouped.Clothing...)
|
||||
|
||||
Binary file not shown.
@ -685,7 +685,19 @@ function FinishedDownloadsCardsView({
|
||||
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
||||
{isSmall ? (
|
||||
!inlineActive && renderRatingOverlay ? (
|
||||
<div className="pointer-events-none absolute bottom-2 left-2 z-[34]">
|
||||
<div
|
||||
className="pointer-events-auto absolute bottom-2 left-2 z-[45]"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
{renderRatingOverlay(j)}
|
||||
</div>
|
||||
) : null
|
||||
|
||||
@ -422,7 +422,19 @@ function FinishedDownloadsGalleryCardInner({
|
||||
|
||||
{/* Mobile: Stern unten links */}
|
||||
{renderRatingOverlay ? (
|
||||
<div className="pointer-events-none absolute bottom-2 left-2 z-[34] sm:hidden">
|
||||
<div
|
||||
className="pointer-events-auto absolute bottom-2 left-2 z-[45] sm:hidden"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
{renderRatingOverlay(j)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@ -1217,10 +1217,15 @@ const SEGMENT_LABEL_META: SegmentLabelMeta[] = [
|
||||
icon: TowelIcon,
|
||||
},
|
||||
{
|
||||
match: ['vibrator','toy_position', 'toy-position', 'toy sex', 'toy_sex', 'toy_play', 'toy-play'],
|
||||
match: ['vibrator'],
|
||||
text: 'Vibrator',
|
||||
icon: ToyIcon,
|
||||
},
|
||||
{
|
||||
match: ['toy_position', 'toy-position', 'toy sex', 'toy_sex', 'toy_play', 'toy-play'],
|
||||
text: 'Toy',
|
||||
icon: ToyIcon,
|
||||
},
|
||||
|
||||
// Clothing
|
||||
{
|
||||
@ -1283,6 +1288,7 @@ function normalizeLabelKey(value?: string): string {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^(object|position|body|clothing|detector):/, '')
|
||||
.replaceAll('-', '_')
|
||||
.replaceAll(' ', '_')
|
||||
}
|
||||
|
||||
@ -583,11 +583,18 @@ export default function Player({
|
||||
}, [isLive, metaReady, job.output, job.id, buildVideoSrc])
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null)
|
||||
|
||||
const setVideoContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
containerRef.current = el
|
||||
setContainerEl(el)
|
||||
}, [])
|
||||
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 mobileSegmentsTouchYRef = useRef<number | null>(null)
|
||||
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
@ -766,7 +773,6 @@ export default function Player({
|
||||
|
||||
const isDesktop = useMediaQuery('(min-width: 640px)')
|
||||
const miniDesktop = mini && isDesktop
|
||||
const usePortal = expanded || miniDesktop
|
||||
|
||||
const WIN_KEY = 'player_window_v1'
|
||||
|
||||
@ -810,33 +816,36 @@ export default function Player({
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!usePortal) {
|
||||
setPortalTarget(null)
|
||||
return
|
||||
}
|
||||
if (!mounted) return
|
||||
|
||||
let el = document.getElementById('player-root') as HTMLElement | null
|
||||
|
||||
if (!el) {
|
||||
el = document.createElement('div')
|
||||
el.id = 'player-root'
|
||||
}
|
||||
|
||||
// Desktop / Expanded: im Top-Layer (Dialog) oder body
|
||||
let host: HTMLElement | null = null
|
||||
|
||||
if (isDesktop) {
|
||||
const dialogs = Array.from(document.querySelectorAll('dialog[open]')) as HTMLElement[]
|
||||
const dialogs = Array.from(
|
||||
document.querySelectorAll('dialog[open]')
|
||||
) as HTMLElement[]
|
||||
|
||||
host = dialogs.length ? dialogs[dialogs.length - 1] : null
|
||||
}
|
||||
|
||||
host = host ?? document.body
|
||||
|
||||
if (el.parentElement !== host) {
|
||||
host.appendChild(el)
|
||||
}
|
||||
|
||||
el.style.position = 'relative'
|
||||
el.style.zIndex = '2147483647'
|
||||
|
||||
setPortalTarget(el)
|
||||
}, [isDesktop, usePortal])
|
||||
}, [mounted, isDesktop])
|
||||
|
||||
useEffect(() => {
|
||||
const p: any = playerRef.current
|
||||
@ -876,16 +885,37 @@ export default function Player({
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!mounted) return
|
||||
if (!containerRef.current) return
|
||||
if (playerRef.current) return
|
||||
if (isLive) return // ✅ neu: für Live keinen Video.js mounten
|
||||
if (!containerEl) return
|
||||
if (isLive) return
|
||||
if (!metaReady) return
|
||||
|
||||
// Falls der Player schon existiert, wurde nur der React-Container ersetzt.
|
||||
// Dann hängen wir das bestehende Video.js-Element einfach in den neuen Container.
|
||||
const existingPlayer = playerRef.current as any
|
||||
if (existingPlayer && !existingPlayer.isDisposed?.()) {
|
||||
const playerEl = existingPlayer.el?.() as HTMLElement | null
|
||||
|
||||
if (playerEl && playerEl.parentElement !== containerEl) {
|
||||
containerEl.replaceChildren(playerEl)
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
existingPlayer.trigger?.('resize')
|
||||
existingPlayer.resize?.()
|
||||
existingPlayer.play?.()?.catch?.(() => {})
|
||||
} catch {}
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const videoEl = document.createElement('video')
|
||||
videoEl.className = 'video-js vjs-big-play-centered w-full h-full'
|
||||
videoEl.setAttribute('playsinline', 'true')
|
||||
videoEl.setAttribute('webkit-playsinline', 'true')
|
||||
|
||||
containerRef.current.appendChild(videoEl)
|
||||
containerEl.replaceChildren(videoEl)
|
||||
videoNodeRef.current = videoEl
|
||||
|
||||
const p = videojs(videoEl, {
|
||||
@ -901,6 +931,9 @@ export default function Player({
|
||||
|
||||
html5: {
|
||||
vhs: { lowLatencyMode: true },
|
||||
nativeVideoTracks: true,
|
||||
nativeAudioTracks: true,
|
||||
nativeTextTracks: true,
|
||||
},
|
||||
|
||||
inactivityTimeout: 0,
|
||||
@ -935,19 +968,25 @@ export default function Player({
|
||||
p.on('userinactive', () => p.userActive(true))
|
||||
|
||||
return () => {
|
||||
try {
|
||||
if (playerRef.current) {
|
||||
playerRef.current.dispose()
|
||||
playerRef.current = null
|
||||
// Wichtig:
|
||||
// NICHT disposen, nur weil containerEl gewechselt hat.
|
||||
// Dispose nur beim echten Component-Unmount machen wir separat unten.
|
||||
}
|
||||
} finally {
|
||||
if (videoNodeRef.current) {
|
||||
videoNodeRef.current.remove()
|
||||
}, [mounted, containerEl, startMuted, isLive, metaReady, updateIntrinsicDims])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
try {
|
||||
const p = playerRef.current as any
|
||||
if (p && !p.isDisposed?.()) {
|
||||
p.dispose()
|
||||
}
|
||||
} catch {}
|
||||
|
||||
playerRef.current = null
|
||||
videoNodeRef.current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [mounted, startMuted, isLive, metaReady, updateIntrinsicDims])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const p = playerRef.current
|
||||
@ -1167,10 +1206,28 @@ export default function Player({
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const p = playerRef.current
|
||||
if (!p || (p as any).isDisposed?.()) return
|
||||
queueMicrotask(() => p.trigger('resize'))
|
||||
}, [expanded])
|
||||
const p = playerRef.current as any
|
||||
if (!p || p.isDisposed?.()) return
|
||||
|
||||
const triggerResize = () => {
|
||||
try {
|
||||
p.trigger('resize')
|
||||
p.resize?.()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
triggerResize()
|
||||
|
||||
const r1 = requestAnimationFrame(triggerResize)
|
||||
const r2 = requestAnimationFrame(() => {
|
||||
requestAnimationFrame(triggerResize)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(r1)
|
||||
cancelAnimationFrame(r2)
|
||||
}
|
||||
}, [expanded, segmentsPanelOpen, isDesktop])
|
||||
|
||||
useEffect(() => {
|
||||
const onRelease = (ev: Event) => {
|
||||
@ -1434,7 +1491,6 @@ export default function Player({
|
||||
const draggingRef = useRef<null | { sx: number; sy: number; start: WinRect }>(null)
|
||||
|
||||
const HOLD_TO_DRAG_MS = 220
|
||||
const HOLD_MOVE_TOLERANCE = 6
|
||||
|
||||
const holdDragTimerRef = useRef<number | null>(null)
|
||||
const suppressClickUntilRef = useRef(0)
|
||||
@ -1564,15 +1620,12 @@ export default function Player({
|
||||
const startX = e.clientX
|
||||
const startY = e.clientY
|
||||
|
||||
let didStartDrag = false
|
||||
let cleanup = () => {}
|
||||
|
||||
const onMoveBeforeHold = (ev: globalThis.PointerEvent) => {
|
||||
const dx = ev.clientX - startX
|
||||
const dy = ev.clientY - startY
|
||||
|
||||
if (Math.hypot(dx, dy) > HOLD_MOVE_TOLERANCE) {
|
||||
cleanup()
|
||||
}
|
||||
const onMoveBeforeHold = () => {
|
||||
// Absichtlich leer:
|
||||
// Bewegung vor Ablauf des Hold-Timers soll den Drag nicht abbrechen.
|
||||
}
|
||||
|
||||
const onUpBeforeHold = () => {
|
||||
@ -1595,9 +1648,11 @@ export default function Player({
|
||||
holdDragTimerRef.current = window.setTimeout(() => {
|
||||
cleanup()
|
||||
|
||||
const started = startDragAt(startX, startY)
|
||||
if (started) {
|
||||
// verhindert Play/Pause-Klick nach dem Loslassen
|
||||
// Wenn die Maus in der Hold-Zeit schon minimal bewegt wurde,
|
||||
// starten wir trotzdem am ursprünglichen Punkt, damit die Position sauber bleibt.
|
||||
didStartDrag = startDragAt(startX, startY)
|
||||
|
||||
if (didStartDrag) {
|
||||
suppressClickUntilRef.current = Date.now() + 1000
|
||||
}
|
||||
}, HOLD_TO_DRAG_MS)
|
||||
@ -1746,8 +1801,64 @@ export default function Player({
|
||||
if (job.status !== 'running') setStopPending(false)
|
||||
}, [job.id, job.status])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
if (typeof document === 'undefined') return
|
||||
if (isDesktop) return
|
||||
|
||||
const shouldLock =
|
||||
!isLive &&
|
||||
hasRatingSegments &&
|
||||
segmentsPanelOpen
|
||||
|
||||
if (!shouldLock) return
|
||||
|
||||
const html = document.documentElement
|
||||
const body = document.body
|
||||
const scrollY = window.scrollY || window.pageYOffset || 0
|
||||
|
||||
const prevHtmlOverflow = html.style.overflow
|
||||
const prevHtmlOverscrollBehavior = html.style.overscrollBehavior
|
||||
const prevBodyOverflow = body.style.overflow
|
||||
const prevBodyPosition = body.style.position
|
||||
const prevBodyTop = body.style.top
|
||||
const prevBodyLeft = body.style.left
|
||||
const prevBodyRight = body.style.right
|
||||
const prevBodyWidth = body.style.width
|
||||
const prevBodyTouchAction = body.style.touchAction
|
||||
const prevBodyOverscrollBehavior = body.style.overscrollBehavior
|
||||
|
||||
html.style.overflow = 'hidden'
|
||||
html.style.overscrollBehavior = 'none'
|
||||
|
||||
body.style.overflow = 'hidden'
|
||||
body.style.position = 'fixed'
|
||||
body.style.top = `-${scrollY}px`
|
||||
body.style.left = '0'
|
||||
body.style.right = '0'
|
||||
body.style.width = '100%'
|
||||
body.style.touchAction = 'none'
|
||||
body.style.overscrollBehavior = 'none'
|
||||
|
||||
return () => {
|
||||
html.style.overflow = prevHtmlOverflow
|
||||
html.style.overscrollBehavior = prevHtmlOverscrollBehavior
|
||||
|
||||
body.style.overflow = prevBodyOverflow
|
||||
body.style.position = prevBodyPosition
|
||||
body.style.top = prevBodyTop
|
||||
body.style.left = prevBodyLeft
|
||||
body.style.right = prevBodyRight
|
||||
body.style.width = prevBodyWidth
|
||||
body.style.touchAction = prevBodyTouchAction
|
||||
body.style.overscrollBehavior = prevBodyOverscrollBehavior
|
||||
|
||||
window.scrollTo(0, scrollY)
|
||||
}
|
||||
}, [isDesktop, isLive, hasRatingSegments, segmentsPanelOpen])
|
||||
|
||||
if (!mounted) return null
|
||||
if (usePortal && !portalTarget) return null
|
||||
if (!portalTarget) return null
|
||||
|
||||
const overlayBtn =
|
||||
'inline-flex items-center justify-center rounded-md p-2 transition ' +
|
||||
@ -1885,7 +1996,7 @@ export default function Player({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn('relative w-full h-full', miniDesktop && 'vjs-mini')}
|
||||
className={cn('relative w-full h-full player-video-frame', miniDesktop && 'vjs-mini')}
|
||||
style={{ ['--vjs-controlbar-h' as any]: `${controlBarH}px` }}
|
||||
>
|
||||
{isLive ? (
|
||||
@ -1947,7 +2058,7 @@ export default function Player({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={containerRef} className="absolute inset-0" />
|
||||
<div ref={setVideoContainerRef} className="absolute inset-0" />
|
||||
)}
|
||||
|
||||
{/* ✅ Top overlay */}
|
||||
@ -2365,33 +2476,59 @@ export default function Player({
|
||||
const MOBILE_PLAYER_H = 'min(220px, 40vh)'
|
||||
const MOBILE_PLAYER_GAP = 10
|
||||
|
||||
const isMobileExpanded = expanded && !isDesktop
|
||||
|
||||
const MOBILE_EXPANDED_MARGIN_X = 8
|
||||
const MOBILE_EXPANDED_TOP = 8
|
||||
const MOBILE_EXPANDED_BOTTOM = 8
|
||||
const MOBILE_EXPANDED_GAP = 10
|
||||
const MOBILE_EXPANDED_SEGMENTS_H = 'min(38dvh, 360px)'
|
||||
|
||||
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 mobileExpandedSafeBottom =
|
||||
`calc(${bottomInset}px + env(safe-area-inset-bottom) + ${MOBILE_EXPANDED_BOTTOM}px)`
|
||||
|
||||
const mobileExpandedSegmentsVisible =
|
||||
isMobileExpanded && !isLive && hasRatingSegments && segmentsPanelOpen
|
||||
|
||||
const mobileExpandedPlayerH = mobileExpandedSegmentsVisible
|
||||
? `calc(100dvh - ${MOBILE_EXPANDED_TOP}px - ${bottomInset}px - env(safe-area-inset-bottom) - ${MOBILE_EXPANDED_BOTTOM}px - ${MOBILE_EXPANDED_SEGMENTS_H} - ${MOBILE_EXPANDED_GAP}px)`
|
||||
: `calc(100dvh - ${MOBILE_EXPANDED_TOP}px - ${bottomInset}px - env(safe-area-inset-bottom) - ${MOBILE_EXPANDED_BOTTOM}px)`
|
||||
|
||||
const mobileSegmentsBottom = isMobileExpanded
|
||||
? mobileExpandedSafeBottom
|
||||
: `calc(${mobilePlayerBottom} + ${MOBILE_PLAYER_H} + ${MOBILE_PLAYER_GAP}px)`
|
||||
|
||||
const mobileSegmentsMaxHeight = isMobileExpanded
|
||||
? MOBILE_EXPANDED_SEGMENTS_H
|
||||
: `calc(100dvh - ${mobileSegmentsBottom} - 12px)`
|
||||
|
||||
const mobileSegmentsSheetEl =
|
||||
!isDesktop && !isLive && hasRatingSegments && segmentsPanelOpen ? (
|
||||
<div
|
||||
className="
|
||||
className={cn(
|
||||
`
|
||||
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
|
||||
"
|
||||
`,
|
||||
isMobileExpanded &&
|
||||
`
|
||||
bg-white/95 backdrop-blur-md
|
||||
dark:bg-gray-950/95
|
||||
`
|
||||
)}
|
||||
style={{
|
||||
bottom: mobileSegmentsBottom,
|
||||
maxHeight: mobileSegmentsMaxHeight,
|
||||
height: mobileSegmentsMaxHeight,
|
||||
touchAction: 'pan-y',
|
||||
touchAction: 'none',
|
||||
overscrollBehavior: 'contain',
|
||||
}}
|
||||
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"
|
||||
@ -2453,25 +2590,63 @@ export default function Player({
|
||||
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',
|
||||
touchAction: 'none',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
overscrollBehavior: 'contain',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
onTouchStart={(e) => {
|
||||
mobileSegmentsTouchYRef.current = e.touches[0]?.clientY ?? null
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onTouchMove={(e) => {
|
||||
const el = mobileSegmentsScrollRef.current
|
||||
const y = e.touches[0]?.clientY ?? null
|
||||
const lastY = mobileSegmentsTouchYRef.current
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (!el || y == null || lastY == null) return
|
||||
|
||||
const dy = lastY - y
|
||||
el.scrollTop += dy
|
||||
mobileSegmentsTouchYRef.current = y
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
mobileSegmentsTouchYRef.current = null
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onWheel={(e) => {
|
||||
const el = mobileSegmentsScrollRef.current
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (!el) return
|
||||
|
||||
el.scrollTop += e.deltaY
|
||||
}}
|
||||
>
|
||||
<RatingSegmentsPanel
|
||||
segments={ratingSegments}
|
||||
onOpenAt={(seconds) => {
|
||||
seekPlayerToAbsolute(seconds)
|
||||
}}
|
||||
maxHeight="100%"
|
||||
className="h-full max-h-full shadow-none"
|
||||
maxHeight={mobileSegmentsMaxHeight}
|
||||
className="min-h-0 shadow-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
const expandedRect = {
|
||||
const expandedRect = isMobileExpanded
|
||||
? {
|
||||
left: ox + MOBILE_EXPANDED_MARGIN_X,
|
||||
top: oy + MOBILE_EXPANDED_TOP,
|
||||
width: Math.max(0, vw - MOBILE_EXPANDED_MARGIN_X * 2),
|
||||
height: mobileExpandedPlayerH,
|
||||
}
|
||||
: {
|
||||
left: ox + 16,
|
||||
top: oy + 16,
|
||||
width: Math.max(0, vw - 32),
|
||||
@ -2610,13 +2785,34 @@ export default function Player({
|
||||
.player-sidebar-segments svg {
|
||||
color: rgba(255, 255, 255, 0.85) !important;
|
||||
}
|
||||
|
||||
.player-video-frame .video-js {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.player-video-frame .video-js .vjs-tech {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
object-fit: contain !important;
|
||||
object-position: center center !important;
|
||||
}
|
||||
|
||||
.player-video-frame .video-js.vjs-fill,
|
||||
.player-video-frame .video-js .vjs-tech {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{expanded || miniDesktop ? (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed z-[2147483647]',
|
||||
!isResizing && !isDragging && 'transition-[left,top,width,height] duration-300 ease-[cubic-bezier(.2,.9,.2,1)]'
|
||||
!isResizing &&
|
||||
!isDragging &&
|
||||
'transition-[left,top,width,height] duration-300 ease-[cubic-bezier(.2,.9,.2,1)]'
|
||||
)}
|
||||
style={{
|
||||
...(wrapStyle as any),
|
||||
@ -2627,7 +2823,7 @@ export default function Player({
|
||||
|
||||
{cardEl}
|
||||
|
||||
{segmentsPanelEl}
|
||||
{!isMobileExpanded ? segmentsPanelEl : null}
|
||||
|
||||
{miniDesktop ? (
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
@ -2643,6 +2839,9 @@ export default function Player({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isMobileExpanded ? mobileSegmentsSheetEl : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{mobileSegmentsSheetEl}
|
||||
@ -2664,9 +2863,5 @@ export default function Player({
|
||||
</>
|
||||
)
|
||||
|
||||
if (usePortal) {
|
||||
return createPortal(content, portalTarget!)
|
||||
}
|
||||
|
||||
return content
|
||||
return createPortal(content, portalTarget)
|
||||
}
|
||||
|
||||
@ -4,9 +4,9 @@
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
@ -1199,12 +1199,14 @@ export function RatingSegmentsPanel({
|
||||
onOpenAt,
|
||||
className = '',
|
||||
maxHeight = 420,
|
||||
scroll = true,
|
||||
}: {
|
||||
metaRaw?: unknown
|
||||
segments?: RatingSegment[]
|
||||
onOpenAt?: (seconds: number) => void
|
||||
className?: string
|
||||
maxHeight?: number | string
|
||||
scroll?: boolean
|
||||
}) {
|
||||
const segments = providedSegments ?? readRatingSegments(metaRaw)
|
||||
|
||||
@ -1239,7 +1241,20 @@ export function RatingSegmentsPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-2 pb-4">
|
||||
<div
|
||||
className={[
|
||||
'min-h-0 flex-1 px-3 py-2 pb-4',
|
||||
scroll ? 'overflow-y-auto overscroll-contain' : 'overflow-visible',
|
||||
].join(' ')}
|
||||
style={
|
||||
scroll
|
||||
? {
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
touchAction: 'pan-y',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="space-y-2 pb-2">
|
||||
{segments.map((segment, index) => (
|
||||
<button
|
||||
@ -1284,7 +1299,7 @@ export function RatingSegmentsPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="relative z-10 px-2.5 py-2">
|
||||
<div className="relative z-10 px-3 py-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
@ -1469,47 +1484,276 @@ function RatingMobileSheet({
|
||||
onClose: () => void
|
||||
onOpenAt?: (seconds: number) => void
|
||||
}) {
|
||||
const sheetRef = useRef<HTMLDivElement | null>(null)
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null)
|
||||
const dragStartYRef = useRef(0)
|
||||
const dragCurrentYRef = useRef(0)
|
||||
const draggingRef = useRef(false)
|
||||
|
||||
const openingRafRef = useRef<number | null>(null)
|
||||
const closeTimerRef = useRef<number | null>(null)
|
||||
const closingRef = useRef(false)
|
||||
|
||||
const getDismissProgress = (deltaY: number) => {
|
||||
const sheetH =
|
||||
sheetRef.current?.getBoundingClientRect().height ??
|
||||
window.innerHeight
|
||||
|
||||
return Math.max(0, Math.min(1, deltaY / sheetH))
|
||||
}
|
||||
|
||||
const setBackdropDragProgress = (progressRaw: number) => {
|
||||
const overlay = overlayRef.current
|
||||
if (!overlay) return
|
||||
|
||||
const progress = Math.max(0, Math.min(1, progressRaw))
|
||||
|
||||
const blurPx = 4 * (1 - progress)
|
||||
const alpha = 0.5 * (1 - progress)
|
||||
|
||||
overlay.style.backgroundColor = `rgba(0, 0, 0, ${alpha})`
|
||||
overlay.style.backdropFilter = `blur(${blurPx}px)`
|
||||
}
|
||||
|
||||
const animateSheetIn = () => {
|
||||
const el = sheetRef.current
|
||||
const overlay = overlayRef.current
|
||||
if (!el || !overlay) return
|
||||
|
||||
closingRef.current = false
|
||||
|
||||
el.style.transition = 'none'
|
||||
el.style.transform = 'translateY(105%)'
|
||||
|
||||
overlay.style.transition = 'none'
|
||||
setBackdropDragProgress(1)
|
||||
|
||||
openingRafRef.current = window.requestAnimationFrame(() => {
|
||||
openingRafRef.current = window.requestAnimationFrame(() => {
|
||||
const nextEl = sheetRef.current
|
||||
const nextOverlay = overlayRef.current
|
||||
if (!nextEl || !nextOverlay) return
|
||||
|
||||
nextEl.style.transition = 'transform 260ms cubic-bezier(.2,.9,.2,1)'
|
||||
nextEl.style.transform = 'translateY(0)'
|
||||
|
||||
nextOverlay.style.transition =
|
||||
'background-color 260ms ease-out, backdrop-filter 260ms ease-out, -webkit-backdrop-filter 260ms ease-out'
|
||||
setBackdropDragProgress(0)
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (sheetRef.current) sheetRef.current.style.transition = ''
|
||||
if (overlayRef.current) overlayRef.current.style.transition = ''
|
||||
}, 280)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const closeWithAnimation = () => {
|
||||
if (closingRef.current) return
|
||||
closingRef.current = true
|
||||
|
||||
const el = sheetRef.current
|
||||
const overlay = overlayRef.current
|
||||
|
||||
if (openingRafRef.current != null) {
|
||||
cancelAnimationFrame(openingRafRef.current)
|
||||
openingRafRef.current = null
|
||||
}
|
||||
|
||||
if (overlay) {
|
||||
overlay.style.transition =
|
||||
'background-color 220ms ease-in, backdrop-filter 220ms ease-in, -webkit-backdrop-filter 220ms ease-in'
|
||||
setBackdropDragProgress(1)
|
||||
}
|
||||
|
||||
if (el) {
|
||||
el.style.transition = 'transform 220ms cubic-bezier(.4,0,1,1)'
|
||||
el.style.transform = 'translateY(105%)'
|
||||
}
|
||||
|
||||
if (closeTimerRef.current != null) {
|
||||
window.clearTimeout(closeTimerRef.current)
|
||||
}
|
||||
|
||||
closeTimerRef.current = window.setTimeout(() => {
|
||||
closeTimerRef.current = null
|
||||
onClose()
|
||||
}, 210)
|
||||
}
|
||||
|
||||
const resetSheetTransform = () => {
|
||||
const el = sheetRef.current
|
||||
const overlay = overlayRef.current
|
||||
|
||||
if (overlay) {
|
||||
overlay.style.transition =
|
||||
'background-color 180ms ease-out, backdrop-filter 180ms ease-out, -webkit-backdrop-filter 180ms ease-out'
|
||||
setBackdropDragProgress(0)
|
||||
}
|
||||
|
||||
if (el) {
|
||||
el.style.transition = 'transform 180ms ease-out'
|
||||
el.style.transform = 'translateY(0)'
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (sheetRef.current) {
|
||||
sheetRef.current.style.transition = ''
|
||||
}
|
||||
|
||||
if (overlayRef.current) {
|
||||
overlayRef.current.style.transition = ''
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
animateSheetIn()
|
||||
|
||||
return () => {
|
||||
if (openingRafRef.current != null) {
|
||||
cancelAnimationFrame(openingRafRef.current)
|
||||
openingRafRef.current = null
|
||||
}
|
||||
|
||||
if (closeTimerRef.current != null) {
|
||||
window.clearTimeout(closeTimerRef.current)
|
||||
closeTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const prev = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = prev
|
||||
}
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const onKeyDown = (e: globalThis.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'Escape') closeWithAnimation()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [open, onClose])
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!draggingRef.current) return
|
||||
|
||||
const deltaY = Math.max(0, e.clientY - dragStartYRef.current)
|
||||
dragCurrentYRef.current = deltaY
|
||||
|
||||
const progress = getDismissProgress(deltaY)
|
||||
setBackdropDragProgress(progress)
|
||||
|
||||
const el = sheetRef.current
|
||||
if (!el) return
|
||||
|
||||
el.style.transition = ''
|
||||
el.style.transform = `translateY(${deltaY}px)`
|
||||
}
|
||||
|
||||
const onPointerUp = () => {
|
||||
if (!draggingRef.current) return
|
||||
|
||||
draggingRef.current = false
|
||||
|
||||
const deltaY = dragCurrentYRef.current
|
||||
dragCurrentYRef.current = 0
|
||||
|
||||
if (deltaY > 110) {
|
||||
closeWithAnimation()
|
||||
} else {
|
||||
resetSheetTransform()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
window.addEventListener('pointercancel', onPointerUp)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('pointercancel', onPointerUp)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!open || typeof document === 'undefined') return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[2147483647] bg-black/45 backdrop-blur-sm sm:hidden"
|
||||
ref={overlayRef}
|
||||
className="fixed inset-0 z-[2147483647] sm:hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0)',
|
||||
backdropFilter: 'blur(0px)',
|
||||
WebkitBackdropFilter: 'blur(0px)',
|
||||
}}
|
||||
onClick={() => closeWithAnimation()}
|
||||
>
|
||||
<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"
|
||||
ref={sheetRef}
|
||||
className="
|
||||
absolute inset-x-0 bottom-0 flex h-[92dvh] flex-col overflow-hidden
|
||||
rounded-t-3xl border border-white/10 bg-white shadow-2xl
|
||||
dark:bg-gray-950
|
||||
"
|
||||
style={{
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
transform: 'translateY(105%)',
|
||||
}}
|
||||
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">
|
||||
{/* Drag Handle */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 touch-none select-none justify-center px-4 pb-2 pt-3"
|
||||
aria-label="Nach unten ziehen zum Schließen"
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
draggingRef.current = true
|
||||
dragStartYRef.current = e.clientY
|
||||
dragCurrentYRef.current = 0
|
||||
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId)
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<span className="h-1.5 w-12 rounded-full bg-gray-300 dark:bg-white/20" />
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="shrink-0 border-b border-gray-200 px-4 py-3 dark:border-white/10">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<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
|
||||
{toneLabel} · {stars}/5 Sterne · {segments.length} Stellen
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1518,11 +1762,14 @@ function RatingMobileSheet({
|
||||
|
||||
<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"
|
||||
className="
|
||||
inline-flex h-9 w-9 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()
|
||||
closeWithAnimation()
|
||||
}}
|
||||
aria-label="Schließen"
|
||||
title="Schließen"
|
||||
@ -1531,10 +1778,37 @@ function RatingMobileSheet({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[calc(82dvh-58px)] overflow-y-auto overscroll-contain px-3 py-3">
|
||||
{/* Content */}
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3"
|
||||
style={{
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
touchAction: 'pan-y',
|
||||
}}
|
||||
>
|
||||
{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">
|
||||
<details
|
||||
className="
|
||||
mb-3 overflow-hidden rounded-xl border border-gray-200 bg-gray-50
|
||||
dark:border-white/10 dark:bg-white/5
|
||||
"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<summary
|
||||
className="
|
||||
cursor-pointer select-none px-3 py-2 text-xs font-semibold
|
||||
text-gray-900 hover:bg-gray-100
|
||||
dark:text-white dark:hover:bg-white/10
|
||||
"
|
||||
>
|
||||
Rating-Details anzeigen
|
||||
</summary>
|
||||
|
||||
<div className="border-t border-gray-200 px-3 py-2 dark:border-white/10">
|
||||
<div className="space-y-1.5">
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
@ -1544,6 +1818,7 @@ function RatingMobileSheet({
|
||||
<span className="min-w-0 text-gray-500 dark:text-gray-400">
|
||||
{entry.label}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={[
|
||||
'shrink-0 text-right font-medium',
|
||||
@ -1558,6 +1833,7 @@ function RatingMobileSheet({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
{segments.length > 0 ? (
|
||||
@ -1566,16 +1842,20 @@ function RatingMobileSheet({
|
||||
onOpenAt={
|
||||
onOpenAt
|
||||
? (seconds) => {
|
||||
onClose()
|
||||
closeWithAnimation()
|
||||
onOpenAt(seconds)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
maxHeight="none"
|
||||
className="max-h-none shadow-none"
|
||||
scroll={false}
|
||||
className="
|
||||
max-h-none border-0 bg-transparent shadow-none ring-0
|
||||
dark:bg-transparent dark:ring-0
|
||||
"
|
||||
/>
|
||||
) : (
|
||||
<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">
|
||||
<div className="rounded-2xl 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>
|
||||
)}
|
||||
@ -1621,21 +1901,24 @@ function RatingWithPopover({
|
||||
? `Rating ${stars}/5 · Details anzeigen`
|
||||
: `Rating ${stars}/5`
|
||||
}
|
||||
onPointerDown={(e) => {
|
||||
if (!touchLike) return
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
if (!touchLike) return
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (!touchLike) return
|
||||
e.stopPropagation()
|
||||
}}
|
||||
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)
|
||||
}}
|
||||
>
|
||||
@ -1666,7 +1949,29 @@ function RatingWithPopover({
|
||||
|
||||
<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">
|
||||
<details
|
||||
className="
|
||||
mb-3 overflow-hidden rounded-xl border border-gray-200 bg-gray-50
|
||||
dark:border-white/10 dark:bg-white/5
|
||||
"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<summary
|
||||
className="
|
||||
cursor-pointer select-none px-3 py-2 text-xs font-semibold
|
||||
text-gray-900 hover:bg-gray-100
|
||||
dark:text-white dark:hover:bg-white/10
|
||||
"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
Rating-Details anzeigen
|
||||
</summary>
|
||||
|
||||
<div className="border-t border-gray-200 px-3 py-2 dark:border-white/10">
|
||||
<div className="space-y-1.5">
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
@ -1691,6 +1996,7 @@ function RatingWithPopover({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
{segments.length > 0 ? (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user