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
|
import os
|
||||||
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
@ -8,59 +10,144 @@ from pydantic import BaseModel
|
|||||||
from ultralytics import YOLO
|
from ultralytics import YOLO
|
||||||
|
|
||||||
|
|
||||||
BODY_LABELS = {
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
"anus",
|
|
||||||
"ass",
|
|
||||||
"breasts",
|
def existing_file(path: Path) -> Optional[Path]:
|
||||||
"penis",
|
try:
|
||||||
"tongue",
|
if path.exists() and path.is_file() and path.stat().st_size > 0:
|
||||||
"pussy",
|
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 = {
|
BODY_LABELS = LABEL_GROUPS["bodyParts"]
|
||||||
"blindfold",
|
OBJECT_LABELS = LABEL_GROUPS["objects"]
|
||||||
"buttplug",
|
CLOTHING_LABELS = LABEL_GROUPS["clothing"]
|
||||||
"collar",
|
POSITION_LABELS = set()
|
||||||
"dildo",
|
|
||||||
"handcuffs",
|
PERSON_LABELS = {
|
||||||
"shower",
|
"person",
|
||||||
"strapon",
|
"person_male",
|
||||||
"towel",
|
"person_female",
|
||||||
"vibrator",
|
"person_unknown",
|
||||||
|
"male_person",
|
||||||
|
"female_person",
|
||||||
}
|
}
|
||||||
|
|
||||||
CLOTHING_LABELS = {
|
MALE_LABELS = {"person_male", "male_person"}
|
||||||
"bikini",
|
FEMALE_LABELS = {"person_female", "female_person"}
|
||||||
"bra",
|
UNKNOWN_PERSON_LABELS = {"person", "person_unknown"}
|
||||||
"dress",
|
|
||||||
"heels",
|
|
||||||
"hotpants",
|
|
||||||
"lingerie",
|
|
||||||
"panties",
|
|
||||||
"skirt",
|
|
||||||
"stockings",
|
|
||||||
"croptop",
|
|
||||||
}
|
|
||||||
|
|
||||||
POSITION_LABELS = {
|
_MODEL_PATH = ""
|
||||||
"missionary",
|
_MODEL_ERROR = ""
|
||||||
"doggy",
|
_LABEL_ERROR = ""
|
||||||
"cowgirl",
|
|
||||||
"reverse_cowgirl",
|
_DEVICE = os.environ.get("YOLO_DEVICE", "")
|
||||||
"cunnilingus",
|
_CONF = float(os.environ.get("YOLO_CONF", "0.25"))
|
||||||
"prone_bone",
|
_BATCH = int(os.environ.get("YOLO_BATCH", "16"))
|
||||||
"standing",
|
_IMGSZ = int(os.environ.get("YOLO_IMGSZ", "640"))
|
||||||
"standing_doggy",
|
_HALF = os.environ.get("YOLO_HALF", "0").lower() in {"1", "true", "yes", "on"}
|
||||||
"spooning",
|
|
||||||
"sitting",
|
model = None
|
||||||
"facesitting",
|
|
||||||
"handjob",
|
app = FastAPI()
|
||||||
"blowjob",
|
|
||||||
"toy_play",
|
|
||||||
"fingering",
|
|
||||||
"69",
|
|
||||||
"other",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class PredictBatchRequest(BaseModel):
|
class PredictBatchRequest(BaseModel):
|
||||||
@ -70,36 +157,133 @@ class PredictBatchRequest(BaseModel):
|
|||||||
model: Optional[str] = None
|
model: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI()
|
def empty_prediction(source: str = "model_missing") -> dict:
|
||||||
|
return {
|
||||||
from pathlib import Path
|
"modelAvailable": False,
|
||||||
|
"source": source,
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
"peopleCount": 0,
|
||||||
DEFAULT_MODEL_PATH = BASE_DIR / "generated" / "training" / "detector" / "model" / "best.pt"
|
"maleCount": 0,
|
||||||
|
"femaleCount": 0,
|
||||||
def resolve_model_path() -> str:
|
"unknownCount": 0,
|
||||||
env_path = os.environ.get("YOLO_MODEL", "").strip()
|
"sexPosition": "unknown",
|
||||||
if env_path:
|
"sexPositionScore": 0.0,
|
||||||
p = Path(env_path)
|
"bodyPartsPresent": [],
|
||||||
if p.exists():
|
"objectsPresent": [],
|
||||||
return str(p)
|
"clothingPresent": [],
|
||||||
raise RuntimeError(f"YOLO_MODEL not found: {p}")
|
"boxes": [],
|
||||||
|
}
|
||||||
default_path = DEFAULT_MODEL_PATH
|
|
||||||
if default_path.exists():
|
|
||||||
return str(default_path)
|
|
||||||
|
|
||||||
raise RuntimeError(f"YOLO model not found: {default_path}")
|
|
||||||
|
|
||||||
|
|
||||||
_MODEL_PATH = resolve_model_path()
|
def load_label_groups_safe() -> None:
|
||||||
_DEVICE = os.environ.get("YOLO_DEVICE", "")
|
global DETECTION_LABELS_PATH
|
||||||
_CONF = float(os.environ.get("YOLO_CONF", "0.25"))
|
global LABEL_GROUPS
|
||||||
_BATCH = int(os.environ.get("YOLO_BATCH", "16"))
|
global BODY_LABELS
|
||||||
_IMGSZ = int(os.environ.get("YOLO_IMGSZ", "640"))
|
global OBJECT_LABELS
|
||||||
_HALF = os.environ.get("YOLO_HALF", "0").lower() in {"1", "true", "yes", "on"}
|
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:
|
def scored(label: str, score: float) -> dict:
|
||||||
@ -143,6 +327,7 @@ def prediction_from_result(result) -> dict:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
cx, cy, w, h = [float(v) for v in box_xywhn]
|
cx, cy, w, h = [float(v) for v in box_xywhn]
|
||||||
|
|
||||||
x = max(0.0, min(1.0, cx - w / 2.0))
|
x = max(0.0, min(1.0, cx - w / 2.0))
|
||||||
y = max(0.0, min(1.0, cy - h / 2.0))
|
y = max(0.0, min(1.0, cy - h / 2.0))
|
||||||
w = max(0.0, min(1.0 - x, w))
|
w = max(0.0, min(1.0 - x, w))
|
||||||
@ -170,13 +355,10 @@ def prediction_from_result(result) -> dict:
|
|||||||
sex_position = label
|
sex_position = label
|
||||||
sex_position_score = score
|
sex_position_score = score
|
||||||
|
|
||||||
people_count = sum(
|
people_count = sum(1 for box in boxes_out if box["label"] in PERSON_LABELS)
|
||||||
1 for box in boxes_out
|
male_count = sum(1 for box in boxes_out if box["label"] in MALE_LABELS)
|
||||||
if box["label"] in {"person", "person_male", "person_female", "person_unknown"}
|
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)
|
||||||
male_count = sum(1 for box in boxes_out if box["label"] in {"person_male", "male_person"})
|
|
||||||
female_count = sum(1 for box in boxes_out if box["label"] in {"person_female", "female_person"})
|
|
||||||
unknown_count = max(0, people_count - male_count - female_count)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"modelAvailable": True,
|
"modelAvailable": True,
|
||||||
@ -204,10 +386,18 @@ def predict_batch(req: PredictBatchRequest):
|
|||||||
"error": "no paths supplied",
|
"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)
|
imgsz = int(req.imageSize or _IMGSZ or 640)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
results = model.predict(
|
results = current_model.predict(
|
||||||
source=paths,
|
source=paths,
|
||||||
imgsz=imgsz,
|
imgsz=imgsz,
|
||||||
conf=_CONF,
|
conf=_CONF,
|
||||||
@ -234,11 +424,19 @@ def predict_batch(req: PredictBatchRequest):
|
|||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def 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 {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
|
"ready": current_model is not None,
|
||||||
|
"modelAvailable": current_model is not None,
|
||||||
"model": _MODEL_PATH,
|
"model": _MODEL_PATH,
|
||||||
|
"modelError": _MODEL_ERROR,
|
||||||
|
"expectedModel": str(DEFAULT_MODEL_PATH),
|
||||||
|
"trainingRoot": str(TRAINING_ROOT),
|
||||||
"classCount": len(names),
|
"classCount": len(names),
|
||||||
"classes": list(names.values())[:80] if isinstance(names, dict) else 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"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/draw"
|
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -17,6 +16,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -46,11 +46,6 @@ type analyzeVideoResp struct {
|
|||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type spriteFrameCandidate struct {
|
|
||||||
Index int
|
|
||||||
Time float64
|
|
||||||
}
|
|
||||||
|
|
||||||
type videoFrameSample struct {
|
type videoFrameSample struct {
|
||||||
Index int
|
Index int
|
||||||
Time float64
|
Time float64
|
||||||
@ -62,9 +57,6 @@ const (
|
|||||||
nsfwThresholdModerate = 0.35
|
nsfwThresholdModerate = 0.35
|
||||||
nsfwThresholdStrong = 0.60
|
nsfwThresholdStrong = 0.60
|
||||||
|
|
||||||
// Sprite-Modus ist aktuell deaktiviert. Analyse läuft über Video-Frames.
|
|
||||||
analyzeMaxSpriteCandidates = 24
|
|
||||||
|
|
||||||
// Video-Modus: extrahiert 1 Frame alle N Sekunden.
|
// Video-Modus: extrahiert 1 Frame alle N Sekunden.
|
||||||
// 1 = jeder Sekunde, 3 = alle 3 Sekunden, 5 = alle 5 Sekunden.
|
// 1 = jeder Sekunde, 3 = alle 3 Sekunden, 5 = alle 5 Sekunden.
|
||||||
analyzeVideoFrameIntervalSeconds = 3
|
analyzeVideoFrameIntervalSeconds = 3
|
||||||
@ -81,56 +73,48 @@ const (
|
|||||||
analyzeAIServerDefaultURL = "http://127.0.0.1:8765"
|
analyzeAIServerDefaultURL = "http://127.0.0.1:8765"
|
||||||
)
|
)
|
||||||
|
|
||||||
var autoSelectedAILabels = map[string]struct{}{
|
func autoSelectedAILabelSet() map[string]struct{} {
|
||||||
// bodyParts aus detecton_labels.json
|
grouped, err := trainingGroupedLabels()
|
||||||
"anus": {},
|
if err != nil {
|
||||||
"ass": {},
|
appLogln("⚠️ analyze labels fallback:", err)
|
||||||
"breasts": {},
|
return map[string]struct{}{}
|
||||||
"penis": {},
|
}
|
||||||
"tongue": {},
|
|
||||||
"pussy": {},
|
|
||||||
|
|
||||||
// objects aus detecton_labels.json
|
out := map[string]struct{}{}
|
||||||
"blindfold": {},
|
|
||||||
"buttplug": {},
|
|
||||||
"collar": {},
|
|
||||||
"dildo": {},
|
|
||||||
"handcuffs": {},
|
|
||||||
"shower": {},
|
|
||||||
"strapon": {},
|
|
||||||
"towel": {},
|
|
||||||
"vibrator": {},
|
|
||||||
|
|
||||||
// clothing aus detecton_labels.json
|
add := func(values []string) {
|
||||||
"bikini": {},
|
for _, value := range values {
|
||||||
"bra": {},
|
label := strings.ToLower(strings.TrimSpace(value))
|
||||||
"dress": {},
|
if label == "" || label == "unknown" {
|
||||||
"heels": {},
|
continue
|
||||||
"hotpants": {},
|
}
|
||||||
"lingerie": {},
|
out[label] = struct{}{}
|
||||||
"panties": {},
|
}
|
||||||
"skirt": {},
|
}
|
||||||
"stockings": {},
|
|
||||||
"croptop": {},
|
|
||||||
|
|
||||||
// sexPositions aus detecton_labels.json
|
add(grouped.BodyParts)
|
||||||
"missionary": {},
|
add(grouped.Objects)
|
||||||
"doggy": {},
|
add(grouped.Clothing)
|
||||||
"cowgirl": {},
|
add(grouped.SexPositions)
|
||||||
"reverse_cowgirl": {},
|
|
||||||
"cunnilingus": {},
|
return out
|
||||||
"prone_bone": {},
|
}
|
||||||
"standing": {},
|
|
||||||
"standing_doggy": {},
|
var autoSelectedAILabelsOnce sync.Once
|
||||||
"spooning": {},
|
var autoSelectedAILabelsCache map[string]struct{}
|
||||||
"sitting": {},
|
|
||||||
"facesitting": {},
|
func shouldAutoSelectAnalyzeHit(label string) bool {
|
||||||
"handjob": {},
|
label = strings.ToLower(strings.TrimSpace(label))
|
||||||
"blowjob": {},
|
if label == "" || label == "unknown" {
|
||||||
"toy_play": {},
|
return false
|
||||||
"fingering": {},
|
}
|
||||||
"69": {},
|
|
||||||
"other": {},
|
autoSelectedAILabelsOnce.Do(func() {
|
||||||
|
autoSelectedAILabelsCache = autoSelectedAILabelSet()
|
||||||
|
})
|
||||||
|
|
||||||
|
_, ok := autoSelectedAILabelsCache[label]
|
||||||
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
var nsfwIgnoredLabels = map[string]struct{}{
|
var nsfwIgnoredLabels = map[string]struct{}{
|
||||||
@ -139,90 +123,20 @@ var nsfwIgnoredLabels = map[string]struct{}{
|
|||||||
"person_male": {},
|
"person_male": {},
|
||||||
"person_female": {},
|
"person_female": {},
|
||||||
"person_unknown": {},
|
"person_unknown": {},
|
||||||
|
"male_person": {},
|
||||||
|
"female_person": {},
|
||||||
|
|
||||||
// Falls dein Detector irgendwann diese Varianten liefert:
|
// Falls dein Detector irgendwann diese Varianten liefert:
|
||||||
"people_male": {},
|
"people_male": {},
|
||||||
"people_female": {},
|
"people_female": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldAutoSelectAnalyzeHit(label string) bool {
|
|
||||||
label = strings.ToLower(strings.TrimSpace(label))
|
|
||||||
_, ok := autoSelectedAILabels[label]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func isIgnoredNSFWLabel(label string) bool {
|
func isIgnoredNSFWLabel(label string) bool {
|
||||||
label = strings.ToLower(strings.TrimSpace(label))
|
label = strings.ToLower(strings.TrimSpace(label))
|
||||||
_, ok := nsfwIgnoredLabels[label]
|
_, ok := nsfwIgnoredLabels[label]
|
||||||
return ok
|
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) {
|
func addTrainingAnalyzeResult(best map[string]float64, label string, score float64) {
|
||||||
label = strings.ToLower(strings.TrimSpace(label))
|
label = strings.ToLower(strings.TrimSpace(label))
|
||||||
if 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 {
|
func nsfwThresholdForLabel(label string) float64 {
|
||||||
label = strings.ToLower(strings.TrimSpace(label))
|
label = strings.ToLower(strings.TrimSpace(label))
|
||||||
|
|
||||||
@ -2174,65 +1985,6 @@ func analyzeVideoFromFramesForGoal(
|
|||||||
return cleanNSFWHits, cleanHighlightHits, nil
|
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 {
|
func sameAnalyzeComboLabel(a, b string) bool {
|
||||||
a = strings.ToLower(strings.TrimSpace(a))
|
a = strings.ToLower(strings.TrimSpace(a))
|
||||||
b = strings.ToLower(strings.TrimSpace(b))
|
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) {
|
func durationSecondsForAnalyze(ctx context.Context, outPath string) (float64, error) {
|
||||||
ctx2, cancel := context.WithTimeout(ctx, 8*time.Second)
|
ctx2, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@ -19,19 +19,31 @@ func trainingEmbeddedMLDir() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
files := []string{
|
files := []string{
|
||||||
"predict_scene_model.py",
|
|
||||||
"train_scene_model.py",
|
|
||||||
"predict_detector_model.py",
|
"predict_detector_model.py",
|
||||||
"train_detector_model.py",
|
"train_detector_model.py",
|
||||||
"detection_labels.json",
|
"detection_labels.json",
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, name := range files {
|
// Falls du die alten Scene-Skripte noch embedded hast, kannst du sie optional mitkopieren.
|
||||||
srcPath := filepath.Join("ml", name)
|
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 {
|
if err != nil {
|
||||||
return "", err
|
// 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)
|
dstPath := filepath.Join(dir, name)
|
||||||
|
|||||||
@ -124,7 +124,7 @@ def main():
|
|||||||
|
|
||||||
print(json.dumps({
|
print(json.dumps({
|
||||||
"available": True,
|
"available": True,
|
||||||
"source": "yolo_detector",
|
"source": "yolo26_detector",
|
||||||
"modelPath": str(model_path),
|
"modelPath": str(model_path),
|
||||||
"image": str(image_path),
|
"image": str(image_path),
|
||||||
"conf": float(args.conf),
|
"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():
|
def main():
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--root", required=True)
|
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("--epochs", default="80")
|
||||||
parser.add_argument("--imgsz", default="640")
|
parser.add_argument("--imgsz", default="640")
|
||||||
parser.add_argument("--device", default="auto")
|
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/frame", trainingFrameHandler)
|
||||||
api.HandleFunc("/api/training/feedback", trainingFeedbackHandler)
|
api.HandleFunc("/api/training/feedback", trainingFeedbackHandler)
|
||||||
api.HandleFunc("/api/training/train", trainingTrainHandler)
|
api.HandleFunc("/api/training/train", trainingTrainHandler)
|
||||||
|
api.HandleFunc("/api/training/cancel", trainingCancelHandler)
|
||||||
api.HandleFunc("/api/training/status", trainingStatusHandler)
|
api.HandleFunc("/api/training/status", trainingStatusHandler)
|
||||||
api.HandleFunc("/api/training/stats", trainingStatsHandler)
|
api.HandleFunc("/api/training/stats", trainingStatsHandler)
|
||||||
api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler)
|
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 {
|
func trainingDetectionLabelsPath() string {
|
||||||
|
if p, err := ensureTrainingDetectionLabelsFile(); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Temp-embedded ML.
|
||||||
if dir, err := trainingEmbeddedMLDir(); err == nil {
|
if dir, err := trainingEmbeddedMLDir(); err == nil {
|
||||||
p := filepath.Join(dir, "detection_labels.json")
|
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
|
return p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -37,7 +42,7 @@ func trainingDetectionLabelsPath() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, p := range candidates {
|
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
|
return p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -45,12 +50,62 @@ func trainingDetectionLabelsPath() string {
|
|||||||
return filepath.Join(projectRoot, "backend", "ml", "detection_labels.json")
|
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) {
|
func trainingGroupedLabels() (TrainingGroupedLabels, error) {
|
||||||
path := trainingDetectionLabelsPath()
|
path := trainingDetectionLabelsPath()
|
||||||
|
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
if err != nil {
|
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
|
var grouped TrainingGroupedLabels
|
||||||
@ -68,8 +123,8 @@ func trainingGroupedLabels() (TrainingGroupedLabels, error) {
|
|||||||
grouped.SexPositions = []string{"unknown"}
|
grouped.SexPositions = []string{"unknown"}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(grouped.BodyParts)+len(grouped.Objects)+len(grouped.Clothing) == 0 {
|
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 Detection-Labels")
|
return TrainingGroupedLabels{}, appErrorf("detection_labels.json enthält keine Labels")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(grouped.People)+len(grouped.BodyParts)+len(grouped.Objects)+len(grouped.Clothing) == 0 {
|
if len(grouped.People)+len(grouped.BodyParts)+len(grouped.Objects)+len(grouped.Clothing) == 0 {
|
||||||
@ -87,10 +142,17 @@ func trainingDetectorLabels() ([]string, error) {
|
|||||||
|
|
||||||
labels := []string{}
|
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...)
|
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.BodyParts...)
|
||||||
labels = append(labels, grouped.Objects...)
|
labels = append(labels, grouped.Objects...)
|
||||||
labels = append(labels, grouped.Clothing...)
|
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">
|
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
||||||
{isSmall ? (
|
{isSmall ? (
|
||||||
!inlineActive && renderRatingOverlay ? (
|
!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)}
|
{renderRatingOverlay(j)}
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
|
|||||||
@ -422,7 +422,19 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
|
|
||||||
{/* Mobile: Stern unten links */}
|
{/* Mobile: Stern unten links */}
|
||||||
{renderRatingOverlay ? (
|
{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)}
|
{renderRatingOverlay(j)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@ -1217,10 +1217,15 @@ const SEGMENT_LABEL_META: SegmentLabelMeta[] = [
|
|||||||
icon: TowelIcon,
|
icon: TowelIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
match: ['vibrator','toy_position', 'toy-position', 'toy sex', 'toy_sex', 'toy_play', 'toy-play'],
|
match: ['vibrator'],
|
||||||
text: 'Vibrator',
|
text: 'Vibrator',
|
||||||
icon: ToyIcon,
|
icon: ToyIcon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
match: ['toy_position', 'toy-position', 'toy sex', 'toy_sex', 'toy_play', 'toy-play'],
|
||||||
|
text: 'Toy',
|
||||||
|
icon: ToyIcon,
|
||||||
|
},
|
||||||
|
|
||||||
// Clothing
|
// Clothing
|
||||||
{
|
{
|
||||||
@ -1283,6 +1288,7 @@ function normalizeLabelKey(value?: string): string {
|
|||||||
return String(value || '')
|
return String(value || '')
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
.replace(/^(object|position|body|clothing|detector):/, '')
|
||||||
.replaceAll('-', '_')
|
.replaceAll('-', '_')
|
||||||
.replaceAll(' ', '_')
|
.replaceAll(' ', '_')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -583,11 +583,18 @@ export default function Player({
|
|||||||
}, [isLive, metaReady, job.output, job.id, buildVideoSrc])
|
}, [isLive, metaReady, job.output, job.id, buildVideoSrc])
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
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 playerRef = useRef<VideoJsPlayer | null>(null)
|
||||||
const videoNodeRef = useRef<HTMLVideoElement | null>(null)
|
const videoNodeRef = useRef<HTMLVideoElement | null>(null)
|
||||||
|
|
||||||
const mobileSegmentsScrollRef = useRef<HTMLDivElement | null>(null)
|
const mobileSegmentsScrollRef = useRef<HTMLDivElement | null>(null)
|
||||||
const mobileHeaderTouchYRef = useRef<number | null>(null)
|
const mobileHeaderTouchYRef = useRef<number | null>(null)
|
||||||
|
const mobileSegmentsTouchYRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
@ -766,7 +773,6 @@ export default function Player({
|
|||||||
|
|
||||||
const isDesktop = useMediaQuery('(min-width: 640px)')
|
const isDesktop = useMediaQuery('(min-width: 640px)')
|
||||||
const miniDesktop = mini && isDesktop
|
const miniDesktop = mini && isDesktop
|
||||||
const usePortal = expanded || miniDesktop
|
|
||||||
|
|
||||||
const WIN_KEY = 'player_window_v1'
|
const WIN_KEY = 'player_window_v1'
|
||||||
|
|
||||||
@ -810,33 +816,36 @@ export default function Player({
|
|||||||
useEffect(() => setMounted(true), [])
|
useEffect(() => setMounted(true), [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!usePortal) {
|
if (!mounted) return
|
||||||
setPortalTarget(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let el = document.getElementById('player-root') as HTMLElement | null
|
let el = document.getElementById('player-root') as HTMLElement | null
|
||||||
|
|
||||||
if (!el) {
|
if (!el) {
|
||||||
el = document.createElement('div')
|
el = document.createElement('div')
|
||||||
el.id = 'player-root'
|
el.id = 'player-root'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Desktop / Expanded: im Top-Layer (Dialog) oder body
|
|
||||||
let host: HTMLElement | null = null
|
let host: HTMLElement | null = null
|
||||||
|
|
||||||
if (isDesktop) {
|
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 = dialogs.length ? dialogs[dialogs.length - 1] : null
|
||||||
}
|
}
|
||||||
|
|
||||||
host = host ?? document.body
|
host = host ?? document.body
|
||||||
host.appendChild(el)
|
|
||||||
|
if (el.parentElement !== host) {
|
||||||
|
host.appendChild(el)
|
||||||
|
}
|
||||||
|
|
||||||
el.style.position = 'relative'
|
el.style.position = 'relative'
|
||||||
el.style.zIndex = '2147483647'
|
el.style.zIndex = '2147483647'
|
||||||
|
|
||||||
setPortalTarget(el)
|
setPortalTarget(el)
|
||||||
}, [isDesktop, usePortal])
|
}, [mounted, isDesktop])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const p: any = playerRef.current
|
const p: any = playerRef.current
|
||||||
@ -875,17 +884,38 @@ export default function Player({
|
|||||||
}, [job.output, isLive])
|
}, [job.output, isLive])
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!mounted) return
|
if (!mounted) return
|
||||||
if (!containerRef.current) return
|
if (!containerEl) return
|
||||||
if (playerRef.current) return
|
if (isLive) return
|
||||||
if (isLive) return // ✅ neu: für Live keinen Video.js mounten
|
if (!metaReady) return
|
||||||
if (!metaReady) return
|
|
||||||
|
|
||||||
const videoEl = document.createElement('video')
|
// 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.className = 'video-js vjs-big-play-centered w-full h-full'
|
||||||
videoEl.setAttribute('playsinline', 'true')
|
videoEl.setAttribute('playsinline', 'true')
|
||||||
|
videoEl.setAttribute('webkit-playsinline', 'true')
|
||||||
|
|
||||||
containerRef.current.appendChild(videoEl)
|
containerEl.replaceChildren(videoEl)
|
||||||
videoNodeRef.current = videoEl
|
videoNodeRef.current = videoEl
|
||||||
|
|
||||||
const p = videojs(videoEl, {
|
const p = videojs(videoEl, {
|
||||||
@ -901,6 +931,9 @@ export default function Player({
|
|||||||
|
|
||||||
html5: {
|
html5: {
|
||||||
vhs: { lowLatencyMode: true },
|
vhs: { lowLatencyMode: true },
|
||||||
|
nativeVideoTracks: true,
|
||||||
|
nativeAudioTracks: true,
|
||||||
|
nativeTextTracks: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
inactivityTimeout: 0,
|
inactivityTimeout: 0,
|
||||||
@ -935,19 +968,25 @@ export default function Player({
|
|||||||
p.on('userinactive', () => p.userActive(true))
|
p.on('userinactive', () => p.userActive(true))
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
try {
|
// Wichtig:
|
||||||
if (playerRef.current) {
|
// NICHT disposen, nur weil containerEl gewechselt hat.
|
||||||
playerRef.current.dispose()
|
// Dispose nur beim echten Component-Unmount machen wir separat unten.
|
||||||
playerRef.current = null
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (videoNodeRef.current) {
|
|
||||||
videoNodeRef.current.remove()
|
|
||||||
videoNodeRef.current = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [mounted, startMuted, isLive, metaReady, updateIntrinsicDims])
|
}, [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
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const p = playerRef.current
|
const p = playerRef.current
|
||||||
@ -1167,10 +1206,28 @@ export default function Player({
|
|||||||
])
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const p = playerRef.current
|
const p = playerRef.current as any
|
||||||
if (!p || (p as any).isDisposed?.()) return
|
if (!p || p.isDisposed?.()) return
|
||||||
queueMicrotask(() => p.trigger('resize'))
|
|
||||||
}, [expanded])
|
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(() => {
|
useEffect(() => {
|
||||||
const onRelease = (ev: Event) => {
|
const onRelease = (ev: Event) => {
|
||||||
@ -1434,7 +1491,6 @@ export default function Player({
|
|||||||
const draggingRef = useRef<null | { sx: number; sy: number; start: WinRect }>(null)
|
const draggingRef = useRef<null | { sx: number; sy: number; start: WinRect }>(null)
|
||||||
|
|
||||||
const HOLD_TO_DRAG_MS = 220
|
const HOLD_TO_DRAG_MS = 220
|
||||||
const HOLD_MOVE_TOLERANCE = 6
|
|
||||||
|
|
||||||
const holdDragTimerRef = useRef<number | null>(null)
|
const holdDragTimerRef = useRef<number | null>(null)
|
||||||
const suppressClickUntilRef = useRef(0)
|
const suppressClickUntilRef = useRef(0)
|
||||||
@ -1564,15 +1620,12 @@ export default function Player({
|
|||||||
const startX = e.clientX
|
const startX = e.clientX
|
||||||
const startY = e.clientY
|
const startY = e.clientY
|
||||||
|
|
||||||
|
let didStartDrag = false
|
||||||
let cleanup = () => {}
|
let cleanup = () => {}
|
||||||
|
|
||||||
const onMoveBeforeHold = (ev: globalThis.PointerEvent) => {
|
const onMoveBeforeHold = () => {
|
||||||
const dx = ev.clientX - startX
|
// Absichtlich leer:
|
||||||
const dy = ev.clientY - startY
|
// Bewegung vor Ablauf des Hold-Timers soll den Drag nicht abbrechen.
|
||||||
|
|
||||||
if (Math.hypot(dx, dy) > HOLD_MOVE_TOLERANCE) {
|
|
||||||
cleanup()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onUpBeforeHold = () => {
|
const onUpBeforeHold = () => {
|
||||||
@ -1595,9 +1648,11 @@ export default function Player({
|
|||||||
holdDragTimerRef.current = window.setTimeout(() => {
|
holdDragTimerRef.current = window.setTimeout(() => {
|
||||||
cleanup()
|
cleanup()
|
||||||
|
|
||||||
const started = startDragAt(startX, startY)
|
// Wenn die Maus in der Hold-Zeit schon minimal bewegt wurde,
|
||||||
if (started) {
|
// starten wir trotzdem am ursprünglichen Punkt, damit die Position sauber bleibt.
|
||||||
// verhindert Play/Pause-Klick nach dem Loslassen
|
didStartDrag = startDragAt(startX, startY)
|
||||||
|
|
||||||
|
if (didStartDrag) {
|
||||||
suppressClickUntilRef.current = Date.now() + 1000
|
suppressClickUntilRef.current = Date.now() + 1000
|
||||||
}
|
}
|
||||||
}, HOLD_TO_DRAG_MS)
|
}, HOLD_TO_DRAG_MS)
|
||||||
@ -1746,8 +1801,64 @@ export default function Player({
|
|||||||
if (job.status !== 'running') setStopPending(false)
|
if (job.status !== 'running') setStopPending(false)
|
||||||
}, [job.id, job.status])
|
}, [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 (!mounted) return null
|
||||||
if (usePortal && !portalTarget) return null
|
if (!portalTarget) return null
|
||||||
|
|
||||||
const overlayBtn =
|
const overlayBtn =
|
||||||
'inline-flex items-center justify-center rounded-md p-2 transition ' +
|
'inline-flex items-center justify-center rounded-md p-2 transition ' +
|
||||||
@ -1885,7 +1996,7 @@ export default function Player({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<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` }}
|
style={{ ['--vjs-controlbar-h' as any]: `${controlBarH}px` }}
|
||||||
>
|
>
|
||||||
{isLive ? (
|
{isLive ? (
|
||||||
@ -1947,7 +2058,7 @@ export default function Player({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div ref={containerRef} className="absolute inset-0" />
|
<div ref={setVideoContainerRef} className="absolute inset-0" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ✅ Top overlay */}
|
{/* ✅ Top overlay */}
|
||||||
@ -2365,118 +2476,182 @@ export default function Player({
|
|||||||
const MOBILE_PLAYER_H = 'min(220px, 40vh)'
|
const MOBILE_PLAYER_H = 'min(220px, 40vh)'
|
||||||
const MOBILE_PLAYER_GAP = 10
|
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 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 =
|
const mobileSegmentsSheetEl =
|
||||||
!isDesktop && !isLive && hasRatingSegments && segmentsPanelOpen ? (
|
!isDesktop && !isLive && hasRatingSegments && segmentsPanelOpen ? (
|
||||||
<div
|
|
||||||
className="
|
|
||||||
fixed inset-x-2 z-[2147483647]
|
|
||||||
flex min-h-0 flex-col overflow-hidden rounded-2xl
|
|
||||||
border border-white/10 bg-white shadow-2xl ring-1 ring-black/10
|
|
||||||
dark:bg-gray-950 dark:ring-white/10
|
|
||||||
sm:hidden
|
|
||||||
"
|
|
||||||
style={{
|
|
||||||
bottom: mobileSegmentsBottom,
|
|
||||||
maxHeight: mobileSegmentsMaxHeight,
|
|
||||||
height: mobileSegmentsMaxHeight,
|
|
||||||
touchAction: 'pan-y',
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onTouchMoveCapture={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
}}
|
|
||||||
onWheelCapture={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className="shrink-0 flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-white/10"
|
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={{
|
style={{
|
||||||
|
bottom: mobileSegmentsBottom,
|
||||||
|
maxHeight: mobileSegmentsMaxHeight,
|
||||||
|
height: mobileSegmentsMaxHeight,
|
||||||
touchAction: 'none',
|
touchAction: 'none',
|
||||||
|
overscrollBehavior: 'contain',
|
||||||
}}
|
}}
|
||||||
onTouchStart={(e) => {
|
onClick={(e) => e.stopPropagation()}
|
||||||
mobileHeaderTouchYRef.current = e.touches[0]?.clientY ?? null
|
|
||||||
e.stopPropagation()
|
|
||||||
}}
|
|
||||||
onTouchMove={(e) => {
|
|
||||||
const y = e.touches[0]?.clientY ?? null
|
|
||||||
const lastY = mobileHeaderTouchYRef.current
|
|
||||||
|
|
||||||
if (y != null && lastY != null) {
|
|
||||||
const dy = lastY - y
|
|
||||||
mobileSegmentsScrollRef.current?.scrollBy({ top: dy })
|
|
||||||
mobileHeaderTouchYRef.current = y
|
|
||||||
}
|
|
||||||
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
}}
|
|
||||||
onTouchEnd={(e) => {
|
|
||||||
mobileHeaderTouchYRef.current = null
|
|
||||||
e.stopPropagation()
|
|
||||||
}}
|
|
||||||
onWheel={(e) => {
|
|
||||||
mobileSegmentsScrollRef.current?.scrollBy({ top: e.deltaY })
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div
|
||||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
className="shrink-0 flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-white/10"
|
||||||
Interessante Stellen
|
style={{
|
||||||
</div>
|
touchAction: 'none',
|
||||||
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
}}
|
||||||
{ratingSegments.length} Segmente
|
onTouchStart={(e) => {
|
||||||
</div>
|
mobileHeaderTouchYRef.current = e.touches[0]?.clientY ?? null
|
||||||
</div>
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
onTouchMove={(e) => {
|
||||||
|
const y = e.touches[0]?.clientY ?? null
|
||||||
|
const lastY = mobileHeaderTouchYRef.current
|
||||||
|
|
||||||
|
if (y != null && lastY != null) {
|
||||||
|
const dy = lastY - y
|
||||||
|
mobileSegmentsScrollRef.current?.scrollBy({ top: dy })
|
||||||
|
mobileHeaderTouchYRef.current = y
|
||||||
|
}
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-white/10 dark:text-gray-200 dark:hover:bg-white/15"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setSegmentsPanelOpen(false)
|
|
||||||
}}
|
}}
|
||||||
aria-label="Segmente schließen"
|
onTouchEnd={(e) => {
|
||||||
title="Schließen"
|
mobileHeaderTouchYRef.current = null
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
onWheel={(e) => {
|
||||||
|
mobileSegmentsScrollRef.current?.scrollBy({ top: e.deltaY })
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
×
|
<div className="min-w-0">
|
||||||
</button>
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
</div>
|
Interessante Stellen
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{ratingSegments.length} Segmente
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<button
|
||||||
ref={mobileSegmentsScrollRef}
|
type="button"
|
||||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3 [-webkit-overflow-scrolling:touch]"
|
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"
|
||||||
style={{
|
onClick={(e) => {
|
||||||
touchAction: 'pan-y',
|
e.preventDefault()
|
||||||
}}
|
e.stopPropagation()
|
||||||
onClick={(e) => {
|
setSegmentsPanelOpen(false)
|
||||||
e.stopPropagation()
|
}}
|
||||||
}}
|
aria-label="Segmente schließen"
|
||||||
>
|
title="Schließen"
|
||||||
<RatingSegmentsPanel
|
>
|
||||||
segments={ratingSegments}
|
×
|
||||||
onOpenAt={(seconds) => {
|
</button>
|
||||||
seekPlayerToAbsolute(seconds)
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={mobileSegmentsScrollRef}
|
||||||
|
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3 [-webkit-overflow-scrolling:touch]"
|
||||||
|
style={{
|
||||||
|
touchAction: 'none',
|
||||||
|
WebkitOverflowScrolling: 'touch',
|
||||||
|
overscrollBehavior: 'contain',
|
||||||
}}
|
}}
|
||||||
maxHeight="100%"
|
onTouchStart={(e) => {
|
||||||
className="h-full max-h-full shadow-none"
|
mobileSegmentsTouchYRef.current = e.touches[0]?.clientY ?? null
|
||||||
/>
|
e.stopPropagation()
|
||||||
</div>
|
}}
|
||||||
</div>
|
onTouchMove={(e) => {
|
||||||
) : null
|
const el = mobileSegmentsScrollRef.current
|
||||||
|
const y = e.touches[0]?.clientY ?? null
|
||||||
|
const lastY = mobileSegmentsTouchYRef.current
|
||||||
|
|
||||||
const expandedRect = {
|
e.preventDefault()
|
||||||
left: ox + 16,
|
e.stopPropagation()
|
||||||
top: oy + 16,
|
|
||||||
width: Math.max(0, vw - 32),
|
if (!el || y == null || lastY == null) return
|
||||||
height: Math.max(0, vh - 32),
|
|
||||||
}
|
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={mobileSegmentsMaxHeight}
|
||||||
|
className="min-h-0 shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
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),
|
||||||
|
height: Math.max(0, vh - 32),
|
||||||
|
}
|
||||||
|
|
||||||
const wrapStyle = expanded
|
const wrapStyle = expanded
|
||||||
? expandedRect
|
? expandedRect
|
||||||
@ -2610,39 +2785,63 @@ export default function Player({
|
|||||||
.player-sidebar-segments svg {
|
.player-sidebar-segments svg {
|
||||||
color: rgba(255, 255, 255, 0.85) !important;
|
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>
|
`}</style>
|
||||||
|
|
||||||
{expanded || miniDesktop ? (
|
{expanded || miniDesktop ? (
|
||||||
<div
|
<>
|
||||||
className={cn(
|
<div
|
||||||
'fixed z-[2147483647]',
|
className={cn(
|
||||||
!isResizing && !isDragging && 'transition-[left,top,width,height] duration-300 ease-[cubic-bezier(.2,.9,.2,1)]'
|
'fixed z-[2147483647]',
|
||||||
)}
|
!isResizing &&
|
||||||
style={{
|
!isDragging &&
|
||||||
...(wrapStyle as any),
|
'transition-[left,top,width,height] duration-300 ease-[cubic-bezier(.2,.9,.2,1)]'
|
||||||
willChange: isResizing ? 'left, top, width, height' : undefined,
|
)}
|
||||||
}}
|
style={{
|
||||||
>
|
...(wrapStyle as any),
|
||||||
{snapGhostEl}
|
willChange: isResizing ? 'left, top, width, height' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{snapGhostEl}
|
||||||
|
|
||||||
{cardEl}
|
{cardEl}
|
||||||
|
|
||||||
{segmentsPanelEl}
|
{!isMobileExpanded ? segmentsPanelEl : null}
|
||||||
|
|
||||||
{miniDesktop ? (
|
{miniDesktop ? (
|
||||||
<div className="pointer-events-none absolute inset-0">
|
<div className="pointer-events-none absolute inset-0">
|
||||||
<div className="pointer-events-auto absolute -left-1 bottom-2 top-2 w-3 cursor-ew-resize" onPointerDown={beginResize('w')} />
|
<div className="pointer-events-auto absolute -left-1 bottom-2 top-2 w-3 cursor-ew-resize" onPointerDown={beginResize('w')} />
|
||||||
<div className="pointer-events-auto absolute -right-1 bottom-2 top-2 w-3 cursor-ew-resize" onPointerDown={beginResize('e')} />
|
<div className="pointer-events-auto absolute -right-1 bottom-2 top-2 w-3 cursor-ew-resize" onPointerDown={beginResize('e')} />
|
||||||
<div className="pointer-events-auto absolute left-2 right-2 -top-1 h-3 cursor-ns-resize" onPointerDown={beginResize('n')} />
|
<div className="pointer-events-auto absolute left-2 right-2 -top-1 h-3 cursor-ns-resize" onPointerDown={beginResize('n')} />
|
||||||
<div className="pointer-events-auto absolute left-2 right-2 -bottom-1 h-3 cursor-ns-resize" onPointerDown={beginResize('s')} />
|
<div className="pointer-events-auto absolute left-2 right-2 -bottom-1 h-3 cursor-ns-resize" onPointerDown={beginResize('s')} />
|
||||||
|
|
||||||
<div className="pointer-events-auto absolute -left-1 -top-1 h-4 w-4 cursor-nwse-resize" onPointerDown={beginResize('nw')} />
|
<div className="pointer-events-auto absolute -left-1 -top-1 h-4 w-4 cursor-nwse-resize" onPointerDown={beginResize('nw')} />
|
||||||
<div className="pointer-events-auto absolute -right-1 -top-1 h-4 w-4 cursor-nesw-resize" onPointerDown={beginResize('ne')} />
|
<div className="pointer-events-auto absolute -right-1 -top-1 h-4 w-4 cursor-nesw-resize" onPointerDown={beginResize('ne')} />
|
||||||
<div className="pointer-events-auto absolute -left-1 -bottom-1 h-4 w-4 cursor-nesw-resize" onPointerDown={beginResize('sw')} />
|
<div className="pointer-events-auto absolute -left-1 -bottom-1 h-4 w-4 cursor-nesw-resize" onPointerDown={beginResize('sw')} />
|
||||||
<div className="pointer-events-auto absolute -right-1 -bottom-1 h-4 w-4 cursor-nwse-resize" onPointerDown={beginResize('se')} />
|
<div className="pointer-events-auto absolute -right-1 -bottom-1 h-4 w-4 cursor-nwse-resize" onPointerDown={beginResize('se')} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>
|
{isMobileExpanded ? mobileSegmentsSheetEl : null}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{mobileSegmentsSheetEl}
|
{mobileSegmentsSheetEl}
|
||||||
@ -2664,9 +2863,5 @@ export default function Player({
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (usePortal) {
|
return createPortal(content, portalTarget)
|
||||||
return createPortal(content, portalTarget!)
|
|
||||||
}
|
|
||||||
|
|
||||||
return content
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
type KeyboardEvent,
|
|
||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
@ -1199,12 +1199,14 @@ export function RatingSegmentsPanel({
|
|||||||
onOpenAt,
|
onOpenAt,
|
||||||
className = '',
|
className = '',
|
||||||
maxHeight = 420,
|
maxHeight = 420,
|
||||||
|
scroll = true,
|
||||||
}: {
|
}: {
|
||||||
metaRaw?: unknown
|
metaRaw?: unknown
|
||||||
segments?: RatingSegment[]
|
segments?: RatingSegment[]
|
||||||
onOpenAt?: (seconds: number) => void
|
onOpenAt?: (seconds: number) => void
|
||||||
className?: string
|
className?: string
|
||||||
maxHeight?: number | string
|
maxHeight?: number | string
|
||||||
|
scroll?: boolean
|
||||||
}) {
|
}) {
|
||||||
const segments = providedSegments ?? readRatingSegments(metaRaw)
|
const segments = providedSegments ?? readRatingSegments(metaRaw)
|
||||||
|
|
||||||
@ -1239,7 +1241,20 @@ export function RatingSegmentsPanel({
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="space-y-2 pb-2">
|
||||||
{segments.map((segment, index) => (
|
{segments.map((segment, index) => (
|
||||||
<button
|
<button
|
||||||
@ -1284,7 +1299,7 @@ export function RatingSegmentsPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div
|
<div
|
||||||
@ -1469,95 +1484,356 @@ function RatingMobileSheet({
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
onOpenAt?: (seconds: number) => 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(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
||||||
const onKeyDown = (e: globalThis.KeyboardEvent) => {
|
const onKeyDown = (e: globalThis.KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose()
|
if (e.key === 'Escape') closeWithAnimation()
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', onKeyDown)
|
document.addEventListener('keydown', onKeyDown)
|
||||||
return () => document.removeEventListener('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
|
if (!open || typeof document === 'undefined') return null
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<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"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
onClick={(e) => {
|
style={{
|
||||||
e.preventDefault()
|
backgroundColor: 'rgba(0, 0, 0, 0)',
|
||||||
e.stopPropagation()
|
backdropFilter: 'blur(0px)',
|
||||||
onClose()
|
WebkitBackdropFilter: 'blur(0px)',
|
||||||
}}
|
}}
|
||||||
|
onClick={() => closeWithAnimation()}
|
||||||
>
|
>
|
||||||
<div
|
<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={{
|
style={{
|
||||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||||
|
transform: 'translateY(105%)',
|
||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
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 */}
|
||||||
<div className="min-w-0">
|
<button
|
||||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
type="button"
|
||||||
AI Rating
|
className="flex shrink-0 touch-none select-none justify-center px-4 pb-2 pt-3"
|
||||||
</div>
|
aria-label="Nach unten ziehen zum Schließen"
|
||||||
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
onPointerDown={(e) => {
|
||||||
{toneLabel} · {stars}/5 Sterne
|
e.preventDefault()
|
||||||
</div>
|
e.stopPropagation()
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
draggingRef.current = true
|
||||||
<RatingStar stars={stars} className="h-8 w-8" />
|
dragStartYRef.current = e.clientY
|
||||||
|
dragCurrentYRef.current = 0
|
||||||
|
|
||||||
<button
|
e.currentTarget.setPointerCapture?.(e.pointerId)
|
||||||
type="button"
|
}}
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-white/10 dark:text-gray-200 dark:hover:bg-white/15"
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.stopPropagation()
|
||||||
e.preventDefault()
|
}}
|
||||||
e.stopPropagation()
|
>
|
||||||
onClose()
|
<span className="h-1.5 w-12 rounded-full bg-gray-300 dark:bg-white/20" />
|
||||||
}}
|
</button>
|
||||||
aria-label="Schließen"
|
|
||||||
title="Schließen"
|
{/* 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">
|
||||||
</button>
|
<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 · {segments.length} Stellen
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<RatingStar stars={stars} className="h-8 w-8" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="
|
||||||
|
inline-flex h-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.stopPropagation()
|
||||||
|
closeWithAnimation()
|
||||||
|
}}
|
||||||
|
aria-label="Schließen"
|
||||||
|
title="Schließen"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</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 ? (
|
{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
|
||||||
<div className="space-y-1.5">
|
className="
|
||||||
{entries.map((entry) => (
|
mb-3 overflow-hidden rounded-xl border border-gray-200 bg-gray-50
|
||||||
<div
|
dark:border-white/10 dark:bg-white/5
|
||||||
key={entry.key}
|
"
|
||||||
className="flex items-start justify-between gap-3 text-[12px]"
|
onClick={(e) => {
|
||||||
>
|
e.stopPropagation()
|
||||||
<span className="min-w-0 text-gray-500 dark:text-gray-400">
|
}}
|
||||||
{entry.label}
|
>
|
||||||
</span>
|
<summary
|
||||||
<span
|
className="
|
||||||
className={[
|
cursor-pointer select-none px-3 py-2 text-xs font-semibold
|
||||||
'shrink-0 text-right font-medium',
|
text-gray-900 hover:bg-gray-100
|
||||||
entry.tone === 'strong'
|
dark:text-white dark:hover:bg-white/10
|
||||||
? 'text-gray-950 dark:text-white'
|
"
|
||||||
: 'text-gray-800 dark:text-gray-100',
|
>
|
||||||
].join(' ')}
|
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
|
||||||
|
key={entry.key}
|
||||||
|
className="flex items-start justify-between gap-3 text-[12px]"
|
||||||
>
|
>
|
||||||
{entry.value}
|
<span className="min-w-0 text-gray-500 dark:text-gray-400">
|
||||||
</span>
|
{entry.label}
|
||||||
</div>
|
</span>
|
||||||
))}
|
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'shrink-0 text-right font-medium',
|
||||||
|
entry.tone === 'strong'
|
||||||
|
? 'text-gray-950 dark:text-white'
|
||||||
|
: 'text-gray-800 dark:text-gray-100',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{entry.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{segments.length > 0 ? (
|
{segments.length > 0 ? (
|
||||||
@ -1566,16 +1842,20 @@ function RatingMobileSheet({
|
|||||||
onOpenAt={
|
onOpenAt={
|
||||||
onOpenAt
|
onOpenAt
|
||||||
? (seconds) => {
|
? (seconds) => {
|
||||||
onClose()
|
closeWithAnimation()
|
||||||
onOpenAt(seconds)
|
onOpenAt(seconds)
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
maxHeight="none"
|
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.
|
Keine interessanten Stellen vorhanden.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -1621,21 +1901,24 @@ function RatingWithPopover({
|
|||||||
? `Rating ${stars}/5 · Details anzeigen`
|
? `Rating ${stars}/5 · Details anzeigen`
|
||||||
: `Rating ${stars}/5`
|
: `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>) => {
|
onClick={(e: MouseEvent<HTMLSpanElement>) => {
|
||||||
if (!touchLike) return
|
if (!touchLike) return
|
||||||
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
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)
|
setTapOpen((v) => !v)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -1666,31 +1949,54 @@ function RatingWithPopover({
|
|||||||
|
|
||||||
<div className="max-h-[520px] overflow-y-auto overscroll-contain p-3">
|
<div className="max-h-[520px] overflow-y-auto overscroll-contain p-3">
|
||||||
{entries.length > 0 ? (
|
{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
|
||||||
<div className="space-y-1.5">
|
className="
|
||||||
{entries.map((entry) => (
|
mb-3 overflow-hidden rounded-xl border border-gray-200 bg-gray-50
|
||||||
<div
|
dark:border-white/10 dark:bg-white/5
|
||||||
key={entry.key}
|
"
|
||||||
className="flex items-start justify-between gap-3 text-[12px]"
|
onClick={(e) => {
|
||||||
>
|
e.stopPropagation()
|
||||||
<span className="min-w-0 text-gray-500 dark:text-gray-400">
|
}}
|
||||||
{entry.label}
|
>
|
||||||
</span>
|
<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>
|
||||||
|
|
||||||
<span
|
<div className="border-t border-gray-200 px-3 py-2 dark:border-white/10">
|
||||||
className={[
|
<div className="space-y-1.5">
|
||||||
'shrink-0 text-right font-medium',
|
{entries.map((entry) => (
|
||||||
entry.tone === 'strong'
|
<div
|
||||||
? 'text-gray-950 dark:text-white'
|
key={entry.key}
|
||||||
: 'text-gray-800 dark:text-gray-100',
|
className="flex items-start justify-between gap-3 text-[12px]"
|
||||||
].join(' ')}
|
|
||||||
>
|
>
|
||||||
{entry.value}
|
<span className="min-w-0 text-gray-500 dark:text-gray-400">
|
||||||
</span>
|
{entry.label}
|
||||||
</div>
|
</span>
|
||||||
))}
|
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'shrink-0 text-right font-medium',
|
||||||
|
entry.tone === 'strong'
|
||||||
|
? 'text-gray-950 dark:text-white'
|
||||||
|
: 'text-gray-800 dark:text-gray-100',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{entry.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{segments.length > 0 ? (
|
{segments.length > 0 ? (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user