This commit is contained in:
Linrador 2026-05-06 13:48:54 +02:00
parent ec4fcea9b1
commit 19859b15be
18 changed files with 2124 additions and 2224 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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