added heatmap + more filters + bugfixes
This commit is contained in:
parent
2fb3d4f00b
commit
f310804b4d
@ -63,7 +63,7 @@ const (
|
|||||||
|
|
||||||
// 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 = 1
|
||||||
|
|
||||||
// AI-Server nicht mit tausenden Pfaden auf einmal fluten.
|
// AI-Server nicht mit tausenden Pfaden auf einmal fluten.
|
||||||
analyzeFramePredictBatchSize = 32
|
analyzeFramePredictBatchSize = 32
|
||||||
|
|||||||
3
backend/data/pending-autostart/admin.json
Normal file
3
backend/data/pending-autostart/admin.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
@ -14,7 +14,6 @@
|
|||||||
"standing",
|
"standing",
|
||||||
"standing_doggy",
|
"standing_doggy",
|
||||||
"spooning",
|
"spooning",
|
||||||
"sitting",
|
|
||||||
"facesitting",
|
"facesitting",
|
||||||
"handjob",
|
"handjob",
|
||||||
"blowjob",
|
"blowjob",
|
||||||
|
|||||||
@ -122,7 +122,6 @@ func isKnownPositionLabel(label string) bool {
|
|||||||
"standing",
|
"standing",
|
||||||
"standing_doggy",
|
"standing_doggy",
|
||||||
"spooning",
|
"spooning",
|
||||||
"sitting",
|
|
||||||
"facesitting",
|
"facesitting",
|
||||||
"handjob",
|
"handjob",
|
||||||
"blowjob",
|
"blowjob",
|
||||||
@ -153,7 +152,7 @@ func positionSeverityWeight(label string) float64 {
|
|||||||
return 0.84
|
return 0.84
|
||||||
case "spooning":
|
case "spooning":
|
||||||
return 0.78
|
return 0.78
|
||||||
case "standing", "sitting":
|
case "standing":
|
||||||
return 0.42
|
return 0.42
|
||||||
default:
|
default:
|
||||||
return 0.00
|
return 0.00
|
||||||
|
|||||||
@ -13,8 +13,8 @@
|
|||||||
"useMyFreeCamsApi": true,
|
"useMyFreeCamsApi": true,
|
||||||
"autoDeleteSmallDownloads": true,
|
"autoDeleteSmallDownloads": true,
|
||||||
"autoDeleteSmallDownloadsBelowMB": 300,
|
"autoDeleteSmallDownloadsBelowMB": 300,
|
||||||
"autoDeleteSmallDownloadsKeepFavorites": true,
|
"autoDeleteSmallDownloadsKeepFavorites": false,
|
||||||
"autoDeleteLowRatedDownloads": false,
|
"autoDeleteLowRatedDownloads": true,
|
||||||
"autoDeleteLowRatedDownloadsMaxStars": 3,
|
"autoDeleteLowRatedDownloadsMaxStars": 3,
|
||||||
"lowDiskPauseBelowGB": 5,
|
"lowDiskPauseBelowGB": 5,
|
||||||
"blurPreviews": false,
|
"blurPreviews": false,
|
||||||
|
|||||||
@ -257,6 +257,123 @@ func findAIServerScriptDir() (string, error) {
|
|||||||
return embeddedDir, nil
|
return embeddedDir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func existingFile(path string) bool {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
return err == nil && fi != nil && !fi.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func existingDir(path string) bool {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
return err == nil && fi != nil && fi.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parentDir(path string, levels int) string {
|
||||||
|
path = filepath.Clean(strings.TrimSpace(path))
|
||||||
|
if path == "." || path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < levels; i++ {
|
||||||
|
next := filepath.Dir(path)
|
||||||
|
if next == path || next == "." || next == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
path = next
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func findTrainingRootDir(scriptDir string) (trainingRoot string, appBaseDir string) {
|
||||||
|
// 1) Explizite ENV gewinnt immer.
|
||||||
|
if raw := strings.TrimSpace(os.Getenv("TRAINING_ROOT")); raw != "" {
|
||||||
|
root := filepath.Clean(raw)
|
||||||
|
return root, parentDir(root, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
|
||||||
|
exePath, _ := os.Executable()
|
||||||
|
exeDir := ""
|
||||||
|
if exePath != "" {
|
||||||
|
exeDir = filepath.Dir(exePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
scriptDir = strings.TrimSpace(scriptDir)
|
||||||
|
|
||||||
|
type candidate struct {
|
||||||
|
root string
|
||||||
|
base string
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := []candidate{}
|
||||||
|
|
||||||
|
add := func(base string) {
|
||||||
|
base = strings.TrimSpace(base)
|
||||||
|
if base == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
base = filepath.Clean(base)
|
||||||
|
|
||||||
|
candidates = append(candidates,
|
||||||
|
candidate{
|
||||||
|
root: filepath.Join(base, "generated", "training"),
|
||||||
|
base: base,
|
||||||
|
},
|
||||||
|
candidate{
|
||||||
|
root: filepath.Join(base, "backend", "generated", "training"),
|
||||||
|
base: filepath.Join(base, "backend"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wichtig für Entwicklung:
|
||||||
|
// findAIServerScriptDir() findet meistens dein backend-Verzeichnis.
|
||||||
|
add(scriptDir)
|
||||||
|
|
||||||
|
// Falls du aus repo-root oder backend startest.
|
||||||
|
add(cwd)
|
||||||
|
|
||||||
|
// Wichtig für gebaute EXE.
|
||||||
|
add(exeDir)
|
||||||
|
|
||||||
|
// Bevorzugt den Pfad, wo detection_labels.json liegt.
|
||||||
|
for _, c := range candidates {
|
||||||
|
labels := filepath.Join(c.root, "detection_labels.json")
|
||||||
|
if existingFile(labels) {
|
||||||
|
return filepath.Clean(c.root), filepath.Clean(c.base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Danach einen Pfad, wo zumindest der Training-Ordner existiert.
|
||||||
|
for _, c := range candidates {
|
||||||
|
if existingDir(c.root) {
|
||||||
|
return filepath.Clean(c.root), filepath.Clean(c.base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback:
|
||||||
|
// Wenn nichts existiert, nimm scriptDir/generated/training.
|
||||||
|
if scriptDir != "" {
|
||||||
|
root := filepath.Join(scriptDir, "generated", "training")
|
||||||
|
return filepath.Clean(root), filepath.Clean(scriptDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
root := filepath.Join(cwd, "generated", "training")
|
||||||
|
return filepath.Clean(root), filepath.Clean(cwd)
|
||||||
|
}
|
||||||
|
|
||||||
func waitForAIServer(ctx context.Context, url string, timeout time.Duration) error {
|
func waitForAIServer(ctx context.Context, url string, timeout time.Duration) error {
|
||||||
deadline := time.Now().Add(timeout)
|
deadline := time.Now().Add(timeout)
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
@ -345,19 +462,15 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
exePath, _ := os.Executable()
|
trainingRoot, appBaseDir := findTrainingRootDir(scriptDir)
|
||||||
exeDir := ""
|
|
||||||
if exePath != "" {
|
detectionLabelsPath := strings.TrimSpace(os.Getenv("DETECTION_LABELS_PATH"))
|
||||||
exeDir = filepath.Dir(exePath)
|
if detectionLabelsPath == "" {
|
||||||
|
detectionLabelsPath = filepath.Join(trainingRoot, "detection_labels.json")
|
||||||
|
} else {
|
||||||
|
detectionLabelsPath = filepath.Clean(detectionLabelsPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
appBaseDir := exeDir
|
|
||||||
if strings.TrimSpace(appBaseDir) == "" {
|
|
||||||
appBaseDir, _ = os.Getwd()
|
|
||||||
}
|
|
||||||
|
|
||||||
trainingRoot := filepath.Join(appBaseDir, "generated", "training")
|
|
||||||
detectionLabelsPath := filepath.Join(trainingRoot, "detection_labels.json")
|
|
||||||
defaultModelPath := filepath.Join(trainingRoot, "detector", "model", "best.pt")
|
defaultModelPath := filepath.Join(trainingRoot, "detector", "model", "best.pt")
|
||||||
|
|
||||||
pythonPath := aiServerPythonPath()
|
pythonPath := aiServerPythonPath()
|
||||||
@ -392,7 +505,10 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
|
|||||||
|
|
||||||
appLogln("🔐 AI Server Auth aktiv.")
|
appLogln("🔐 AI Server Auth aktiv.")
|
||||||
|
|
||||||
// Immer App-Root/generated/training verwenden.
|
// Training-Root robust auflösen:
|
||||||
|
// - Entwicklung: backend/generated/training
|
||||||
|
// - Build/Release: exeDir/generated/training
|
||||||
|
// - ENV TRAINING_ROOT / DETECTION_LABELS_PATH darf überschreiben.
|
||||||
env = upsertEnv(env, "APP_BASE_DIR", appBaseDir)
|
env = upsertEnv(env, "APP_BASE_DIR", appBaseDir)
|
||||||
env = upsertEnv(env, "TRAINING_ROOT", trainingRoot)
|
env = upsertEnv(env, "TRAINING_ROOT", trainingRoot)
|
||||||
env = upsertEnv(env, "DETECTION_LABELS_PATH", detectionLabelsPath)
|
env = upsertEnv(env, "DETECTION_LABELS_PATH", detectionLabelsPath)
|
||||||
|
|||||||
@ -4152,7 +4152,6 @@ export default function App() {
|
|||||||
: isTrainingTab && trainingImageExpanded
|
: isTrainingTab && trainingImageExpanded
|
||||||
? 'max-w-none px-2 sm:px-3 lg:px-4'
|
? 'max-w-none px-2 sm:px-3 lg:px-4'
|
||||||
: 'mx-auto max-w-7xl',
|
: 'mx-auto max-w-7xl',
|
||||||
isTrainingTab ? '' : 'space-y-2',
|
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{selectedTab === 'running' ? (
|
{selectedTab === 'running' ? (
|
||||||
|
|||||||
453
frontend/src/aiLabels.ts
Normal file
453
frontend/src/aiLabels.ts
Normal file
@ -0,0 +1,453 @@
|
|||||||
|
export type AiLabelGroup =
|
||||||
|
| 'people'
|
||||||
|
| 'position'
|
||||||
|
| 'body'
|
||||||
|
| 'object'
|
||||||
|
| 'clothing'
|
||||||
|
| 'unknown'
|
||||||
|
|
||||||
|
export const AI_POSITION_LABELS = [
|
||||||
|
'unknown',
|
||||||
|
'missionary',
|
||||||
|
'doggy',
|
||||||
|
'doggystyle',
|
||||||
|
'cowgirl',
|
||||||
|
'reverse_cowgirl',
|
||||||
|
'cunnilingus',
|
||||||
|
'prone_bone',
|
||||||
|
'standing',
|
||||||
|
'standing_doggy',
|
||||||
|
'spooning',
|
||||||
|
'sitting',
|
||||||
|
'facesitting',
|
||||||
|
'handjob',
|
||||||
|
'blowjob',
|
||||||
|
'toy_play',
|
||||||
|
'fingering',
|
||||||
|
'69',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const AI_BODY_LABELS = [
|
||||||
|
'anus',
|
||||||
|
'ass',
|
||||||
|
'breasts',
|
||||||
|
'penis',
|
||||||
|
'tongue',
|
||||||
|
'pussy',
|
||||||
|
'vulva',
|
||||||
|
'buttocks',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const AI_OBJECT_LABELS = [
|
||||||
|
'blindfold',
|
||||||
|
'buttplug',
|
||||||
|
'collar',
|
||||||
|
'dildo',
|
||||||
|
'handcuffs',
|
||||||
|
'shower',
|
||||||
|
'strapon',
|
||||||
|
'towel',
|
||||||
|
'vibrator',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const AI_CLOTHING_LABELS = [
|
||||||
|
'bikini',
|
||||||
|
'bra',
|
||||||
|
'dress',
|
||||||
|
'heels',
|
||||||
|
'hotpants',
|
||||||
|
'lingerie',
|
||||||
|
'panties',
|
||||||
|
'skirt',
|
||||||
|
'stockings',
|
||||||
|
'croptop',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const AI_PERSON_LABELS = [
|
||||||
|
'person',
|
||||||
|
'person_unknown',
|
||||||
|
'person_male',
|
||||||
|
'person_female',
|
||||||
|
'male_person',
|
||||||
|
'female_person',
|
||||||
|
'people_male',
|
||||||
|
'people_female',
|
||||||
|
'frau',
|
||||||
|
'mann',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const AI_LABEL_FALLBACKS: Record<string, string> = {
|
||||||
|
vulva: 'Vagina',
|
||||||
|
pussy: 'Vagina',
|
||||||
|
penis: 'Penis',
|
||||||
|
anus: 'Anus',
|
||||||
|
ass: 'Hintern',
|
||||||
|
buttocks: 'Hintern',
|
||||||
|
breasts: 'Brüste',
|
||||||
|
tongue: 'Zunge',
|
||||||
|
|
||||||
|
blindfold: 'Augenbinde',
|
||||||
|
buttplug: 'Buttplug',
|
||||||
|
collar: 'Halsband',
|
||||||
|
dildo: 'Dildo',
|
||||||
|
handcuffs: 'Handschellen',
|
||||||
|
shower: 'Dusche',
|
||||||
|
strapon: 'Strapon',
|
||||||
|
towel: 'Handtuch',
|
||||||
|
vibrator: 'Vibrator',
|
||||||
|
|
||||||
|
bikini: 'Bikini',
|
||||||
|
bra: 'BH',
|
||||||
|
dress: 'Kleid',
|
||||||
|
heels: 'High Heels',
|
||||||
|
hotpants: 'Hotpants',
|
||||||
|
lingerie: 'Lingerie',
|
||||||
|
panties: 'Slip',
|
||||||
|
skirt: 'Rock',
|
||||||
|
stockings: 'Strümpfe',
|
||||||
|
croptop: 'Crop Top',
|
||||||
|
|
||||||
|
unknown: 'Unbekannt',
|
||||||
|
missionary: 'Missionary',
|
||||||
|
doggy: 'Doggy',
|
||||||
|
doggystyle: 'Doggy',
|
||||||
|
cowgirl: 'Cowgirl',
|
||||||
|
reverse_cowgirl: 'Reverse Cowgirl',
|
||||||
|
cunnilingus: 'Cunnilingus',
|
||||||
|
prone_bone: 'Prone Bone',
|
||||||
|
standing: 'Stehend',
|
||||||
|
standing_doggy: 'Standing Doggy',
|
||||||
|
spooning: 'Spooning',
|
||||||
|
sitting: 'Sitzend',
|
||||||
|
facesitting: 'Facesitting',
|
||||||
|
handjob: 'Handjob',
|
||||||
|
blowjob: 'Blowjob',
|
||||||
|
toy_play: 'Toy Play',
|
||||||
|
fingering: 'Fingering',
|
||||||
|
'69': '69',
|
||||||
|
|
||||||
|
person: 'Person',
|
||||||
|
person_unknown: 'Person',
|
||||||
|
person_male: 'Mann',
|
||||||
|
person_female: 'Frau',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RATING_VALUE_LABELS: Record<string, string> = {
|
||||||
|
stars: 'Sterne',
|
||||||
|
score: 'Score',
|
||||||
|
confidence: 'Konfidenz',
|
||||||
|
probability: 'Wahrscheinlichkeit',
|
||||||
|
nsfw: 'NSFW',
|
||||||
|
adult: 'Adult',
|
||||||
|
explicit: 'Explizit',
|
||||||
|
explicitness: 'Explizitheit',
|
||||||
|
nudity: 'Nacktheit',
|
||||||
|
sexual: 'Sexuell',
|
||||||
|
sexualActivity: 'Sexuelle Aktivität',
|
||||||
|
visibility: 'Sichtbarkeit',
|
||||||
|
quality: 'Qualität',
|
||||||
|
reason: 'Begründung',
|
||||||
|
summary: 'Zusammenfassung',
|
||||||
|
label: 'Label',
|
||||||
|
category: 'Kategorie',
|
||||||
|
model: 'Modell',
|
||||||
|
version: 'Version',
|
||||||
|
}
|
||||||
|
|
||||||
|
const POSITION_SET = new Set<string>(AI_POSITION_LABELS)
|
||||||
|
const BODY_SET = new Set<string>(AI_BODY_LABELS)
|
||||||
|
const OBJECT_SET = new Set<string>(AI_OBJECT_LABELS)
|
||||||
|
const CLOTHING_SET = new Set<string>(AI_CLOTHING_LABELS)
|
||||||
|
const PERSON_SET = new Set<string>(AI_PERSON_LABELS)
|
||||||
|
|
||||||
|
export function normalizeAiLabel(value: unknown): string {
|
||||||
|
return String(value ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^detector:/, '')
|
||||||
|
.replace(/^position:/, '')
|
||||||
|
.replace(/^body:/, '')
|
||||||
|
.replace(/^object:/, '')
|
||||||
|
.replace(/^clothing:/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function titleCaseLabel(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/[_-]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.replace(/\b\w/g, (m) => m.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prettyAiLabel(value: unknown): string {
|
||||||
|
const raw = String(value ?? '').trim()
|
||||||
|
if (!raw) return ''
|
||||||
|
|
||||||
|
const key = raw.toLowerCase()
|
||||||
|
|
||||||
|
if (key.startsWith('position:')) {
|
||||||
|
return prettyAiLabel(key.slice('position:'.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.startsWith('object:')) {
|
||||||
|
return prettyAiLabel(key.slice('object:'.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.startsWith('clothing:')) {
|
||||||
|
return prettyAiLabel(key.slice('clothing:'.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.startsWith('body:')) {
|
||||||
|
return prettyAiLabel(key.slice('body:'.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.startsWith('detector:')) {
|
||||||
|
return prettyAiLabel(key.slice('detector:'.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.startsWith('combo:')) {
|
||||||
|
const rest = key.slice('combo:'.length)
|
||||||
|
return rest
|
||||||
|
.split('+')
|
||||||
|
.map((part) => prettyAiLabel(part))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' + ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const direct = AI_LABEL_FALLBACKS[key]
|
||||||
|
if (direct) return direct
|
||||||
|
|
||||||
|
return isAiLikeLabel(raw) ? titleCaseLabel(raw) : raw
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aiLabelGroup(value: unknown): AiLabelGroup {
|
||||||
|
const label = normalizeAiLabel(value)
|
||||||
|
|
||||||
|
if (!label || label === 'unknown') return 'unknown'
|
||||||
|
if (PERSON_SET.has(label)) return 'people'
|
||||||
|
if (POSITION_SET.has(label)) return 'position'
|
||||||
|
if (BODY_SET.has(label)) return 'body'
|
||||||
|
if (OBJECT_SET.has(label)) return 'object'
|
||||||
|
if (CLOTHING_SET.has(label)) return 'clothing'
|
||||||
|
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isKnownFrontendPositionLabel(value: unknown): boolean {
|
||||||
|
return POSITION_SET.has(normalizeAiLabel(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isKnownAiContentLabel(value: unknown): boolean {
|
||||||
|
const group = aiLabelGroup(value)
|
||||||
|
return (
|
||||||
|
group === 'position' ||
|
||||||
|
group === 'body' ||
|
||||||
|
group === 'object' ||
|
||||||
|
group === 'clothing'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPersonSegmentLabel(value: unknown): boolean {
|
||||||
|
return PERSON_SET.has(normalizeAiLabel(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitAiComboOrList(value: unknown): string[] {
|
||||||
|
return String(value ?? '')
|
||||||
|
.split(/[+,;|]+/g)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rawAiLabelParts(value: unknown): string[] {
|
||||||
|
const raw = String(value ?? '').trim().toLowerCase()
|
||||||
|
if (!raw) return []
|
||||||
|
|
||||||
|
if (raw.startsWith('combo:')) {
|
||||||
|
return raw
|
||||||
|
.slice('combo:'.length)
|
||||||
|
.split('+')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [raw]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAiLikeLabel(value: string): boolean {
|
||||||
|
const s = value.trim()
|
||||||
|
if (!s) return false
|
||||||
|
|
||||||
|
return (
|
||||||
|
s.toLowerCase() in AI_LABEL_FALLBACKS ||
|
||||||
|
/^[a-z0-9]+(?:_[a-z0-9]+)+$/i.test(s) ||
|
||||||
|
/^[a-z0-9]+(?:-[a-z0-9]+)+$/i.test(s)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNonPersonHighlightLabel(value: unknown): boolean {
|
||||||
|
const parts = splitAiComboOrList(value)
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const normalized = normalizeAiLabel(part)
|
||||||
|
if (!normalized) continue
|
||||||
|
|
||||||
|
if (!isPersonSegmentLabel(normalized)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function segmentObjectHasNonPersonContext(
|
||||||
|
obj: Record<string, unknown>
|
||||||
|
): boolean {
|
||||||
|
const values: unknown[] = [
|
||||||
|
obj.tags,
|
||||||
|
obj.labels,
|
||||||
|
obj.classes,
|
||||||
|
obj.flags,
|
||||||
|
obj.summary,
|
||||||
|
obj.reason,
|
||||||
|
obj.description,
|
||||||
|
obj.text,
|
||||||
|
obj.caption,
|
||||||
|
obj.note,
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const value of values) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.some((item) => isNonPersonHighlightLabel(item))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string' && isNonPersonHighlightLabel(value)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPersonSegmentObject(obj: Record<string, unknown>): boolean {
|
||||||
|
const rawLabel =
|
||||||
|
obj.label ??
|
||||||
|
obj.title ??
|
||||||
|
obj.category ??
|
||||||
|
obj.type ??
|
||||||
|
obj.kind ??
|
||||||
|
obj.name ??
|
||||||
|
''
|
||||||
|
|
||||||
|
if (!isPersonSegmentLabel(rawLabel)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segmentObjectHasNonPersonContext(obj)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tagsFromAiLabel(
|
||||||
|
value: unknown,
|
||||||
|
opts?: {
|
||||||
|
prefixPositions?: boolean
|
||||||
|
}
|
||||||
|
): string[] {
|
||||||
|
const out: string[] = []
|
||||||
|
const prefixPositions = opts?.prefixPositions ?? true
|
||||||
|
|
||||||
|
for (const part of rawAiLabelParts(value)) {
|
||||||
|
const clean = part.trim()
|
||||||
|
if (!clean) continue
|
||||||
|
|
||||||
|
const normalized = normalizeAiLabel(clean)
|
||||||
|
|
||||||
|
if (clean.startsWith('position:') || isKnownFrontendPositionLabel(normalized)) {
|
||||||
|
const label = prettyAiLabel(clean)
|
||||||
|
if (label) out.push(prefixPositions ? `Position: ${label}` : label)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
clean.startsWith('body:') ||
|
||||||
|
clean.startsWith('object:') ||
|
||||||
|
clean.startsWith('clothing:') ||
|
||||||
|
clean.startsWith('detector:')
|
||||||
|
) {
|
||||||
|
const label = prettyAiLabel(clean)
|
||||||
|
if (label) out.push(label)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = prettyAiLabel(clean)
|
||||||
|
if (label) out.push(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function segmentTitleFromLabel(raw: string): string {
|
||||||
|
const parts = rawAiLabelParts(raw)
|
||||||
|
|
||||||
|
if (parts.length <= 1) {
|
||||||
|
return prettyAiLabel(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
const positionPart = parts.find((part) => {
|
||||||
|
const clean = normalizeAiLabel(part)
|
||||||
|
return part.startsWith('position:') || isKnownFrontendPositionLabel(clean)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (positionPart) return prettyAiLabel(positionPart)
|
||||||
|
|
||||||
|
const objectPart = parts.find((part) => part.startsWith('object:'))
|
||||||
|
if (objectPart) return prettyAiLabel(objectPart)
|
||||||
|
|
||||||
|
const bodyPart = parts.find((part) => part.startsWith('body:'))
|
||||||
|
if (bodyPart) return prettyAiLabel(bodyPart)
|
||||||
|
|
||||||
|
const clothingPart = parts.find((part) => part.startsWith('clothing:'))
|
||||||
|
if (clothingPart) return prettyAiLabel(clothingPart)
|
||||||
|
|
||||||
|
return prettyAiLabel(parts[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizedPlainTagKey(tag: unknown): string {
|
||||||
|
return String(tag ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^detector:/, '')
|
||||||
|
.replace(/^position:/, '')
|
||||||
|
.replace(/^body:/, '')
|
||||||
|
.replace(/^object:/, '')
|
||||||
|
.replace(/^clothing:/, '')
|
||||||
|
.replace(/[_-]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDerivedFilterTag(tag: unknown): boolean {
|
||||||
|
const key = normalizedPlainTagKey(tag)
|
||||||
|
|
||||||
|
if (!key) return true
|
||||||
|
|
||||||
|
if (/^[1-5]\s*sterne?$/.test(key)) return true
|
||||||
|
if (/^[1-5]\s*stars?$/.test(key)) return true
|
||||||
|
if (/^[1-5]\s*\/\s*5$/.test(key)) return true
|
||||||
|
|
||||||
|
return [
|
||||||
|
'high rating',
|
||||||
|
'low rating',
|
||||||
|
'4k',
|
||||||
|
'full hd',
|
||||||
|
'hd',
|
||||||
|
'sd',
|
||||||
|
'kurz',
|
||||||
|
'lang',
|
||||||
|
].includes(key)
|
||||||
|
}
|
||||||
@ -17,6 +17,10 @@ import type {
|
|||||||
FinishedPostworkStepSummary,
|
FinishedPostworkStepSummary,
|
||||||
FinishedPostworkSummary,
|
FinishedPostworkSummary,
|
||||||
DoneSortMode,
|
DoneSortMode,
|
||||||
|
SplitProgressOverlay,
|
||||||
|
RatingSidebarFilter,
|
||||||
|
ResolutionSidebarFilter,
|
||||||
|
PhaseSidebarFilter,
|
||||||
} from '../../types'
|
} from '../../types'
|
||||||
import ButtonGroup from './ButtonGroup'
|
import ButtonGroup from './ButtonGroup'
|
||||||
import {
|
import {
|
||||||
@ -60,6 +64,13 @@ import {
|
|||||||
} from './finishedSelectionStore'
|
} from './finishedSelectionStore'
|
||||||
import { formatResolution, formatDuration, formatBytes } from './formatters'
|
import { formatResolution, formatDuration, formatBytes } from './formatters'
|
||||||
import RatingOverlay from './RatingOverlay'
|
import RatingOverlay from './RatingOverlay'
|
||||||
|
import FinishedReportModal from './FinishedReportModal'
|
||||||
|
import {
|
||||||
|
autoTagsForJob,
|
||||||
|
mergeTags,
|
||||||
|
ratingStarsForJob,
|
||||||
|
resolutionBucketForJob,
|
||||||
|
} from './autoTags'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
jobs: RecordJob[]
|
jobs: RecordJob[]
|
||||||
@ -569,23 +580,229 @@ function postworkBadgeTone(summary?: FinishedPostworkSummary): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type SplitProgressOverlay = {
|
const PHASE_SIDEBAR_FILTER_OPTIONS: Array<{
|
||||||
phase: 'preparing' | 'splitting' | 'done' | 'error'
|
value: PhaseSidebarFilter
|
||||||
total: number
|
label: string
|
||||||
current: number
|
}> = [
|
||||||
completed: number
|
{ value: 'all', label: 'Alle' },
|
||||||
segmentProgress?: number
|
{ value: 'meta', label: 'Meta' },
|
||||||
overallProgress?: number
|
{ value: 'thumb', label: 'Bild' },
|
||||||
message?: string
|
{ value: 'teaser', label: 'Teaser' },
|
||||||
segment?: {
|
{ value: 'sprites', label: 'Sprites' },
|
||||||
index: number
|
{ value: 'analyze', label: 'Analyse' },
|
||||||
label: string
|
]
|
||||||
start: number
|
|
||||||
end: number
|
function phaseKeyForStep(
|
||||||
duration: number
|
step: Pick<FinishedPostworkStepSummary, 'queue' | 'phase'>
|
||||||
|
): PhaseSidebarFilter | '' {
|
||||||
|
const queue = step.queue === 'enrich' ? 'enrich' : 'postwork'
|
||||||
|
const phase = normalizeFinishedPhase(queue, step.phase)
|
||||||
|
|
||||||
|
if (queue === 'enrich' && phase === 'analyze') return 'analyze'
|
||||||
|
|
||||||
|
if (
|
||||||
|
queue === 'postwork' &&
|
||||||
|
(phase === 'meta' ||
|
||||||
|
phase === 'thumb' ||
|
||||||
|
phase === 'teaser' ||
|
||||||
|
phase === 'sprites')
|
||||||
|
) {
|
||||||
|
return phase as PhaseSidebarFilter
|
||||||
}
|
}
|
||||||
startedAtMs?: number
|
|
||||||
sourceFile?: string
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesPhaseSidebarFilter(
|
||||||
|
summary: FinishedPostworkSummary | undefined,
|
||||||
|
filter: PhaseSidebarFilter
|
||||||
|
): boolean {
|
||||||
|
if (filter === 'all') return true
|
||||||
|
|
||||||
|
const steps = Array.isArray(summary?.steps) ? summary.steps : []
|
||||||
|
|
||||||
|
// Kein Status bedeutet: keine abgeschlossene Phase bekannt.
|
||||||
|
if (steps.length === 0) return false
|
||||||
|
|
||||||
|
const step = steps.find((s) => phaseKeyForStep(s) === filter)
|
||||||
|
|
||||||
|
// Wenn die konkrete Phase fehlt, ist sie nicht abgeschlossen.
|
||||||
|
if (!step) return false
|
||||||
|
|
||||||
|
return step.state === 'done'
|
||||||
|
}
|
||||||
|
|
||||||
|
const FINISHED_RATING_STAR_PATH =
|
||||||
|
'M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 9.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z'
|
||||||
|
|
||||||
|
function finishedRatingTone(stars: number) {
|
||||||
|
switch (stars) {
|
||||||
|
case 1:
|
||||||
|
return 'text-yellow-300 opacity-55'
|
||||||
|
case 2:
|
||||||
|
return 'text-yellow-300 opacity-65'
|
||||||
|
case 3:
|
||||||
|
return 'text-yellow-300 opacity-80'
|
||||||
|
case 4:
|
||||||
|
return 'text-yellow-300 opacity-95'
|
||||||
|
default:
|
||||||
|
return 'text-yellow-200 opacity-100'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function RatingFilterStar({
|
||||||
|
stars,
|
||||||
|
filled,
|
||||||
|
}: {
|
||||||
|
stars: number
|
||||||
|
filled: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="relative inline-grid h-5 w-5 shrink-0 place-items-center leading-none">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={[
|
||||||
|
'h-5 w-5 shrink-0 transition duration-150',
|
||||||
|
filled
|
||||||
|
? `fill-current ${finishedRatingTone(stars)}`
|
||||||
|
: 'fill-transparent text-gray-300 dark:text-white/25',
|
||||||
|
].join(' ')}
|
||||||
|
style={{
|
||||||
|
filter: filled
|
||||||
|
? 'drop-shadow(0 1px 1px rgba(0,0,0,0.65)) drop-shadow(0 0 2px rgba(0,0,0,0.30))'
|
||||||
|
: 'drop-shadow(0 1px 1px rgba(0,0,0,0.18))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d={FINISHED_RATING_STAR_PATH}
|
||||||
|
stroke={filled ? 'rgba(0,0,0,0.75)' : 'currentColor'}
|
||||||
|
strokeWidth="1.1"
|
||||||
|
paintOrder="stroke fill"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RatingStarsFilter({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: RatingSidebarFilter
|
||||||
|
onChange: (next: RatingSidebarFilter) => void
|
||||||
|
}) {
|
||||||
|
const selected = ratingFilterStars(value)
|
||||||
|
const [hoverStars, setHoverStars] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const previewStars = hoverStars ?? selected ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Bewertung nach Sternen filtern"
|
||||||
|
className={[
|
||||||
|
'flex items-center justify-center gap-1 rounded-xl px-3 py-2 ring-1 transition',
|
||||||
|
selected != null
|
||||||
|
? 'bg-amber-50 ring-amber-200 dark:bg-amber-400/10 dark:ring-amber-400/30'
|
||||||
|
: 'bg-white ring-gray-200 dark:bg-white/5 dark:ring-white/10',
|
||||||
|
].join(' ')}
|
||||||
|
onMouseLeave={() => setHoverStars(null)}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5].map((stars) => {
|
||||||
|
const checked = selected === stars
|
||||||
|
const filled = stars <= previewStars
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={stars}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={checked}
|
||||||
|
title={`${stars} Stern${stars === 1 ? '' : 'e'} anzeigen`}
|
||||||
|
onMouseEnter={() => setHoverStars(stars)}
|
||||||
|
onFocus={() => setHoverStars(stars)}
|
||||||
|
onBlur={() => setHoverStars(null)}
|
||||||
|
onClick={() => onChange(ratingFilterFromStars(stars))}
|
||||||
|
className={[
|
||||||
|
'rounded-md p-0.5 transition',
|
||||||
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-950',
|
||||||
|
checked ? 'scale-110' : '',
|
||||||
|
'hover:scale-110 active:scale-95',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<RatingFilterStar
|
||||||
|
stars={stars}
|
||||||
|
filled={filled}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratingFilterStars(filter: RatingSidebarFilter): number | null {
|
||||||
|
if (!filter.startsWith('stars_')) return null
|
||||||
|
|
||||||
|
const stars = Number(filter.slice('stars_'.length))
|
||||||
|
if (!Number.isFinite(stars)) return null
|
||||||
|
|
||||||
|
return Math.max(1, Math.min(5, Math.round(stars)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratingFilterFromStars(stars: number): RatingSidebarFilter {
|
||||||
|
const value = Math.max(1, Math.min(5, Math.round(Number(stars || 1))))
|
||||||
|
return `stars_${value}` as RatingSidebarFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESOLUTION_SIDEBAR_FILTER_OPTIONS: Array<{
|
||||||
|
value: ResolutionSidebarFilter
|
||||||
|
label: string
|
||||||
|
}> = [
|
||||||
|
{ value: 'all', label: 'Alle' },
|
||||||
|
{ value: '4k', label: '4K' },
|
||||||
|
{ value: 'fullhd', label: 'Full HD' },
|
||||||
|
{ value: 'hd', label: 'HD' },
|
||||||
|
{ value: 'sd', label: 'SD' },
|
||||||
|
{ value: 'unknown', label: 'Unbekannt' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function matchesRatingSidebarFilter(
|
||||||
|
job: RecordJob,
|
||||||
|
filter: RatingSidebarFilter
|
||||||
|
): boolean {
|
||||||
|
if (filter === 'all') return true
|
||||||
|
|
||||||
|
const stars = ratingStarsForJob(job)
|
||||||
|
|
||||||
|
if (filter === 'unrated') {
|
||||||
|
return stars == null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stars == null) return false
|
||||||
|
|
||||||
|
if (filter.startsWith('stars_')) {
|
||||||
|
const wanted = Number(filter.slice('stars_'.length))
|
||||||
|
return Number.isFinite(wanted) && stars === wanted
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesResolutionSidebarFilter(
|
||||||
|
job: RecordJob,
|
||||||
|
filter: ResolutionSidebarFilter
|
||||||
|
): boolean {
|
||||||
|
if (filter === 'all') return true
|
||||||
|
|
||||||
|
const bucket = resolutionBucketForJob(job)
|
||||||
|
|
||||||
|
if (filter === 'unknown') {
|
||||||
|
return bucket === ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return bucket === filter
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadIntegrityBadgeInfo = {
|
type DownloadIntegrityBadgeInfo = {
|
||||||
@ -2034,6 +2251,17 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
const [showOnlyCorrupt, setShowOnlyCorrupt] = useState(false)
|
const [showOnlyCorrupt, setShowOnlyCorrupt] = useState(false)
|
||||||
|
|
||||||
|
const [reportOpen, setReportOpen] = useState(false)
|
||||||
|
|
||||||
|
const [ratingFilter, setRatingFilter] =
|
||||||
|
useState<RatingSidebarFilter>('all')
|
||||||
|
|
||||||
|
const [resolutionFilter, setResolutionFilter] =
|
||||||
|
useState<ResolutionSidebarFilter>('all')
|
||||||
|
|
||||||
|
const [phaseFilter, setPhaseFilter] =
|
||||||
|
useState<PhaseSidebarFilter>('all')
|
||||||
|
|
||||||
const [postworkByFile, setPostworkByFile] =
|
const [postworkByFile, setPostworkByFile] =
|
||||||
useState<Record<string, FinishedPostworkBadge[]>>({})
|
useState<Record<string, FinishedPostworkBadge[]>>({})
|
||||||
|
|
||||||
@ -2226,18 +2454,41 @@ export default function FinishedDownloads({
|
|||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const out: string[] = []
|
const out: string[] = []
|
||||||
|
|
||||||
|
const add = (tag: unknown) => {
|
||||||
|
const clean = String(tag ?? '').trim()
|
||||||
|
if (!clean) return
|
||||||
|
|
||||||
|
const key = lowerTag(clean)
|
||||||
|
if (!key || seen.has(key)) return
|
||||||
|
|
||||||
|
seen.add(key)
|
||||||
|
out.push(clean)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Manuelle Model-Tags
|
||||||
for (const flags of Object.values(modelsByKey ?? {})) {
|
for (const flags of Object.values(modelsByKey ?? {})) {
|
||||||
for (const tag of parseTags(flags?.tags)) {
|
for (const tag of parseTags(flags?.tags)) {
|
||||||
const k = lowerTag(tag)
|
add(tag)
|
||||||
if (!k || seen.has(k)) continue
|
}
|
||||||
seen.add(k)
|
}
|
||||||
out.push(tag)
|
|
||||||
|
// 2. Auto-Tags aus Download-Meta/Analyse
|
||||||
|
// Wenn allDoneJobs geladen ist, nutzen wir alle Downloads.
|
||||||
|
// Sonst zumindest die aktuelle Seite.
|
||||||
|
const rowsForAutoTags =
|
||||||
|
Array.isArray(allDoneJobs) && allDoneJobs.length > 0
|
||||||
|
? allDoneJobs
|
||||||
|
: doneJobs
|
||||||
|
|
||||||
|
for (const job of rowsForAutoTags) {
|
||||||
|
for (const tag of autoTagsForJob(job)) {
|
||||||
|
add(tag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
out.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
|
out.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
|
||||||
return out
|
return out
|
||||||
}, [modelsByKey])
|
}, [modelsByKey, allDoneJobs, doneJobs])
|
||||||
|
|
||||||
const teaserState = useMemo<FinishedDownloadsTeaserState>(
|
const teaserState = useMemo<FinishedDownloadsTeaserState>(
|
||||||
() => ({
|
() => ({
|
||||||
@ -2263,6 +2514,26 @@ export default function FinishedDownloads({
|
|||||||
return { tagsByModelKey, tagSetByModelKey }
|
return { tagsByModelKey, tagSetByModelKey }
|
||||||
}, [modelsByKey])
|
}, [modelsByKey])
|
||||||
|
|
||||||
|
const tagsForJob = useCallback(
|
||||||
|
(job: RecordJob): string[] => {
|
||||||
|
const modelKey = lowerTag(modelNameFromOutput(job.output))
|
||||||
|
const manualTags = modelTags.tagsByModelKey[modelKey] ?? []
|
||||||
|
|
||||||
|
return mergeTags(
|
||||||
|
manualTags,
|
||||||
|
autoTagsForJob(job)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[modelTags]
|
||||||
|
)
|
||||||
|
|
||||||
|
const tagSetForJob = useCallback(
|
||||||
|
(job: RecordJob): Set<string> => {
|
||||||
|
return new Set(tagsForJob(job).map(lowerTag))
|
||||||
|
},
|
||||||
|
[tagsForJob]
|
||||||
|
)
|
||||||
|
|
||||||
const searchTokens = useMemo(
|
const searchTokens = useMemo(
|
||||||
() => lowerTag(deferredSearchQuery).split(/\s+/g).map((s) => s.trim()).filter(Boolean),
|
() => lowerTag(deferredSearchQuery).split(/\s+/g).map((s) => s.trim()).filter(Boolean),
|
||||||
[deferredSearchQuery]
|
[deferredSearchQuery]
|
||||||
@ -2271,7 +2542,10 @@ export default function FinishedDownloads({
|
|||||||
const searchActiveForGlobalFetch =
|
const searchActiveForGlobalFetch =
|
||||||
activeTagSet.size > 0 ||
|
activeTagSet.size > 0 ||
|
||||||
searchTokens.length > 0 ||
|
searchTokens.length > 0 ||
|
||||||
showOnlyCorrupt
|
showOnlyCorrupt ||
|
||||||
|
ratingFilter !== 'all' ||
|
||||||
|
resolutionFilter !== 'all' ||
|
||||||
|
phaseFilter !== 'all'
|
||||||
|
|
||||||
const globalFilterActive = searchActiveForGlobalFetch
|
const globalFilterActive = searchActiveForGlobalFetch
|
||||||
const mobileCardStackMode = isSmall && view === 'cards'
|
const mobileCardStackMode = isSmall && view === 'cards'
|
||||||
@ -2506,14 +2780,20 @@ export default function FinishedDownloads({
|
|||||||
? base.filter((j) => {
|
? base.filter((j) => {
|
||||||
const file = baseName(j.output || '')
|
const file = baseName(j.output || '')
|
||||||
const model = modelNameFromOutput(j.output)
|
const model = modelNameFromOutput(j.output)
|
||||||
const modelKey = lowerTag(model)
|
const tags = tagsForJob(j)
|
||||||
const tags = modelTags.tagsByModelKey[modelKey] ?? []
|
|
||||||
|
|
||||||
const hay = lowerTag([file, stripHotPrefix(file), model, j.id, tags.join(' ')].join(' '))
|
const hay = lowerTag([
|
||||||
|
file,
|
||||||
|
stripHotPrefix(file),
|
||||||
|
model,
|
||||||
|
j.id,
|
||||||
|
tags.join(' '),
|
||||||
|
].join(' '))
|
||||||
|
|
||||||
for (const t of searchTokens) {
|
for (const t of searchTokens) {
|
||||||
if (!hay.includes(t)) return false
|
if (!hay.includes(t)) return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
: base
|
: base
|
||||||
@ -2522,13 +2802,13 @@ export default function FinishedDownloads({
|
|||||||
activeTagSet.size === 0
|
activeTagSet.size === 0
|
||||||
? searched
|
? searched
|
||||||
: searched.filter((j) => {
|
: searched.filter((j) => {
|
||||||
const modelKey = lowerTag(modelNameFromOutput(j.output))
|
const have = tagSetForJob(j)
|
||||||
const have = modelTags.tagSetByModelKey[modelKey]
|
|
||||||
if (!have || have.size === 0) return false
|
if (!have || have.size === 0) return false
|
||||||
|
|
||||||
for (const t of activeTagSet) {
|
for (const t of activeTagSet) {
|
||||||
if (!have.has(t)) return false
|
if (!have.has(t)) return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -2536,14 +2816,36 @@ export default function FinishedDownloads({
|
|||||||
return matchesCorruptOnlyFilter((j as any)?.meta, showOnlyCorrupt)
|
return matchesCorruptOnlyFilter((j as any)?.meta, showOnlyCorrupt)
|
||||||
})
|
})
|
||||||
|
|
||||||
return corruptFiltered
|
const ratingFiltered = corruptFiltered.filter((j) => {
|
||||||
|
return matchesRatingSidebarFilter(j, ratingFilter)
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolutionFiltered = ratingFiltered.filter((j) => {
|
||||||
|
return matchesResolutionSidebarFilter(j, resolutionFilter)
|
||||||
|
})
|
||||||
|
|
||||||
|
const phaseFiltered = resolutionFiltered.filter((j) => {
|
||||||
|
const summary = getPostworkSummaryForOutput(
|
||||||
|
j.output,
|
||||||
|
effectivePostworkSummaryByFile
|
||||||
|
)
|
||||||
|
|
||||||
|
return matchesPhaseSidebarFilter(summary, phaseFilter)
|
||||||
|
})
|
||||||
|
|
||||||
|
return phaseFiltered
|
||||||
}, [
|
}, [
|
||||||
viewRows,
|
viewRows,
|
||||||
deletedKeys,
|
deletedKeys,
|
||||||
activeTagSet,
|
activeTagSet,
|
||||||
modelTags,
|
tagsForJob,
|
||||||
|
tagSetForJob,
|
||||||
searchTokens,
|
searchTokens,
|
||||||
showOnlyCorrupt,
|
showOnlyCorrupt,
|
||||||
|
ratingFilter,
|
||||||
|
resolutionFilter,
|
||||||
|
phaseFilter,
|
||||||
|
effectivePostworkSummaryByFile,
|
||||||
])
|
])
|
||||||
|
|
||||||
const finishedDownloadsLoading = allDoneJobsLoading
|
const finishedDownloadsLoading = allDoneJobsLoading
|
||||||
@ -3911,7 +4213,15 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onPageChange(1)
|
onPageChange(1)
|
||||||
}, [deferredSearchQuery, tagFilter, showOnlyCorrupt, onPageChange])
|
}, [
|
||||||
|
deferredSearchQuery,
|
||||||
|
tagFilter,
|
||||||
|
showOnlyCorrupt,
|
||||||
|
ratingFilter,
|
||||||
|
resolutionFilter,
|
||||||
|
phaseFilter,
|
||||||
|
onPageChange,
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (view !== 'gallery') return
|
if (view !== 'gallery') return
|
||||||
@ -4778,14 +5088,164 @@ export default function FinishedDownloads({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const renderRatingResolutionFilters = () => {
|
||||||
|
const anyAnalysisFilterActive =
|
||||||
|
ratingFilter !== 'all' ||
|
||||||
|
resolutionFilter !== 'all' ||
|
||||||
|
phaseFilter !== 'all'
|
||||||
|
|
||||||
|
const chipClass = (active: boolean) =>
|
||||||
|
[
|
||||||
|
'rounded-lg px-2.5 py-2 text-xs font-semibold ring-1 transition',
|
||||||
|
active
|
||||||
|
? 'bg-sky-50 text-sky-700 ring-sky-200 shadow-sm dark:bg-sky-400/10 dark:text-sky-200 dark:ring-sky-400/30'
|
||||||
|
: 'bg-white text-gray-700 ring-gray-200 hover:bg-gray-50 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10',
|
||||||
|
].join(' ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-gray-200/80 bg-white/80 p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
|
||||||
|
<div className="mb-3 flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Analysefilter
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
Bewertung, abgeschlossene Phasen und Auflösung
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{anyAnalysisFilterActive ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 rounded-full px-2 py-1 text-[11px] font-semibold text-gray-600 ring-1 ring-gray-200 transition hover:bg-gray-50 hover:text-gray-900 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10 dark:hover:text-white"
|
||||||
|
onClick={() => {
|
||||||
|
if (page !== 1) onPageChange(1)
|
||||||
|
setRatingFilter('all')
|
||||||
|
setResolutionFilter('all')
|
||||||
|
setPhaseFilter('all')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<section>
|
||||||
|
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Bewertung
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (page !== 1) onPageChange(1)
|
||||||
|
setRatingFilter('all')
|
||||||
|
}}
|
||||||
|
className={chipClass(ratingFilter === 'all')}
|
||||||
|
>
|
||||||
|
Alle
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (page !== 1) onPageChange(1)
|
||||||
|
setRatingFilter('unrated')
|
||||||
|
}}
|
||||||
|
className={chipClass(ratingFilter === 'unrated')}
|
||||||
|
>
|
||||||
|
Ohne
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 rounded-xl border border-gray-200/70 bg-gray-50/80 p-2 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<RatingStarsFilter
|
||||||
|
value={ratingFilter}
|
||||||
|
onChange={(next) => {
|
||||||
|
if (page !== 1) onPageChange(1)
|
||||||
|
setRatingFilter(next)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="mb-1.5">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Phase
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{PHASE_SIDEBAR_FILTER_OPTIONS.map((option) => {
|
||||||
|
const active = phaseFilter === option.value
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (page !== 1) onPageChange(1)
|
||||||
|
setPhaseFilter(option.value)
|
||||||
|
}}
|
||||||
|
className={chipClass(active)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="mb-1.5">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Auflösung
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{RESOLUTION_SIDEBAR_FILTER_OPTIONS.map((option) => {
|
||||||
|
const active = resolutionFilter === option.value
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (page !== 1) onPageChange(1)
|
||||||
|
setResolutionFilter(option.value)
|
||||||
|
}}
|
||||||
|
className={chipClass(active)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// render
|
// render
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<FinishedReportModal
|
||||||
|
open={reportOpen}
|
||||||
|
onClose={() => setReportOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid w-full gap-4 px-4 sm:px-6 lg:grid-cols-[18rem_minmax(0,1fr)] lg:items-start lg:pl-8 lg:pr-[max(2rem,calc((100vw-80rem)/2+2rem))] xl:grid-cols-[19rem_minmax(0,1fr)]">
|
<div className="grid w-full gap-4 px-4 sm:px-6 lg:grid-cols-[18rem_minmax(0,1fr)] lg:items-start lg:pl-8 lg:pr-[max(2rem,calc((100vw-80rem)/2+2rem))] xl:grid-cols-[19rem_minmax(0,1fr)]">
|
||||||
{/* Toolbar links neben dem Content, nicht per Transform aus dem Container geschoben */}
|
{/* Toolbar links neben dem Content, nicht per Transform aus dem Container geschoben */}
|
||||||
<aside className="relative z-40 min-w-0 lg:sticky lg:top-0 lg:col-start-1 lg:row-start-1 lg:z-[60] lg:w-[18rem] lg:justify-self-end lg:self-start lg:max-h-[calc(100dvh-14rem)] lg:overflow-y-auto lg:overflow-x-hidden xl:w-[19rem]">
|
<aside className="relative z-40 min-w-0 lg:sticky lg:top-0 lg:col-start-1 lg:row-start-1 lg:z-[60] lg:w-[18rem] lg:justify-self-end lg:self-start lg:max-h-[calc(100dvh-15rem)] lg:overflow-y-auto lg:overflow-x-hidden xl:w-[19rem]">
|
||||||
<div
|
<div
|
||||||
className="
|
className="
|
||||||
rounded-xl border border-gray-200/70 bg-white/95 shadow-sm
|
rounded-xl border border-gray-200/70 bg-white/95 shadow-sm
|
||||||
@ -5023,6 +5483,17 @@ export default function FinishedDownloads({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setReportOpen(true)}
|
||||||
|
leadingIcon={<ClockIcon className="size-4 shrink-0" />}
|
||||||
|
className="w-full justify-center"
|
||||||
|
title="Report"
|
||||||
|
>
|
||||||
|
Report
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className={desktopToolbarControlsClass}>
|
<div className={desktopToolbarControlsClass}>
|
||||||
<div className={desktopToolbarLeftClass}>
|
<div className={desktopToolbarLeftClass}>
|
||||||
<div className="grid w-full grid-cols-1 gap-2">
|
<div className="grid w-full grid-cols-1 gap-2">
|
||||||
@ -5060,6 +5531,8 @@ export default function FinishedDownloads({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{renderRatingResolutionFilters()}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size={isCompactDesktop ? 'sm' : 'md'}
|
size={isCompactDesktop ? 'sm' : 'md'}
|
||||||
variant={lastAction ? 'soft' : 'secondary'}
|
variant={lastAction ? 'soft' : 'secondary'}
|
||||||
@ -5215,6 +5688,17 @@ export default function FinishedDownloads({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setReportOpen(true)}
|
||||||
|
title="Report"
|
||||||
|
aria-label="Report"
|
||||||
|
>
|
||||||
|
<ClockIcon className="size-4 shrink-0" aria-hidden="true" />
|
||||||
|
<span className="sr-only">Report</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
{selectedCount > 0 ? (
|
{selectedCount > 0 ? (
|
||||||
<Button
|
<Button
|
||||||
size="md"
|
size="md"
|
||||||
@ -5414,6 +5898,8 @@ export default function FinishedDownloads({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{renderRatingResolutionFilters()}
|
||||||
|
|
||||||
{view !== 'table' ? (
|
{view !== 'table' ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{view === 'gallery' ? (
|
{view === 'gallery' ? (
|
||||||
@ -5523,7 +6009,10 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
{tagFilter.length > 0 ||
|
{tagFilter.length > 0 ||
|
||||||
(searchQuery || '').trim() !== '' ||
|
(searchQuery || '').trim() !== '' ||
|
||||||
showOnlyCorrupt ? (
|
showOnlyCorrupt ||
|
||||||
|
ratingFilter !== 'all' ||
|
||||||
|
resolutionFilter !== 'all' ||
|
||||||
|
phaseFilter !== 'all' ? (
|
||||||
<div className="mt-2 flex flex-wrap gap-3">
|
<div className="mt-2 flex flex-wrap gap-3">
|
||||||
{tagFilter.length > 0 ? (
|
{tagFilter.length > 0 ? (
|
||||||
<button
|
<button
|
||||||
@ -5544,6 +6033,22 @@ export default function FinishedDownloads({
|
|||||||
Suche zurücksetzen
|
Suche zurücksetzen
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{ratingFilter !== 'all' ||
|
||||||
|
resolutionFilter !== 'all' ||
|
||||||
|
phaseFilter !== 'all' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-sm font-medium text-gray-700 hover:underline dark:text-gray-200"
|
||||||
|
onClick={() => {
|
||||||
|
setRatingFilter('all')
|
||||||
|
setResolutionFilter('all')
|
||||||
|
setPhaseFilter('all')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Analysefilter zurücksetzen
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@ -36,6 +36,10 @@ import {
|
|||||||
readPreviewSpriteInfo,
|
readPreviewSpriteInfo,
|
||||||
scrubProgressRatioFromIndex,
|
scrubProgressRatioFromIndex,
|
||||||
} from './previewSprite'
|
} from './previewSprite'
|
||||||
|
import {
|
||||||
|
buildVideoHeatmapSegments,
|
||||||
|
heatmapDurationForJob,
|
||||||
|
} from './videoHeatmap'
|
||||||
import ModelGenderIcon from './ModelGenderIcon'
|
import ModelGenderIcon from './ModelGenderIcon'
|
||||||
import { parseFinishedTags, resolveFinishedModelFlags } from './finishedModelFlags'
|
import { parseFinishedTags, resolveFinishedModelFlags } from './finishedModelFlags'
|
||||||
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
||||||
@ -47,6 +51,7 @@ import FinishedDownloadsDesktopCard from './FinishedDownloadsDesktopCard'
|
|||||||
import PreviewRatingOverlay from './PreviewRatingOverlay'
|
import PreviewRatingOverlay from './PreviewRatingOverlay'
|
||||||
import PreviewMetaOverlay from './PreviewMetaOverlay'
|
import PreviewMetaOverlay from './PreviewMetaOverlay'
|
||||||
import DeletingPreviewOverlay from './DeletingPreviewOverlay'
|
import DeletingPreviewOverlay from './DeletingPreviewOverlay'
|
||||||
|
import { autoTagsForJob, mergeTags } from './autoTags'
|
||||||
|
|
||||||
|
|
||||||
type SwipeRefs = {
|
type SwipeRefs = {
|
||||||
@ -516,6 +521,8 @@ function FinishedDownloadsCardsView({
|
|||||||
const hasSpriteScrubber = sprite.hasSpriteScrubber
|
const hasSpriteScrubber = sprite.hasSpriteScrubber
|
||||||
const scrubberCount = hasScrubberUi ? sprite.count : 0
|
const scrubberCount = hasScrubberUi ? sprite.count : 0
|
||||||
const scrubberStepSeconds = hasScrubberUi ? sprite.stepSeconds : 0
|
const scrubberStepSeconds = hasScrubberUi ? sprite.stepSeconds : 0
|
||||||
|
const heatmapDuration = heatmapDurationForJob(j, durations[k] ?? (j as any)?.durationSeconds)
|
||||||
|
const heatmapSegments = buildVideoHeatmapSegments(j, heatmapDuration)
|
||||||
|
|
||||||
const teaserPreloadEnabled =
|
const teaserPreloadEnabled =
|
||||||
opts?.mobileStackTopOnlyVideo
|
opts?.mobileStackTopOnlyVideo
|
||||||
@ -543,7 +550,10 @@ function FinishedDownloadsCardsView({
|
|||||||
const isLiked = flags?.liked === true
|
const isLiked = flags?.liked === true
|
||||||
const isWatching = Boolean(flags?.watching)
|
const isWatching = Boolean(flags?.watching)
|
||||||
|
|
||||||
const tags = parseFinishedTags(flags?.tags)
|
const tags = mergeTags(
|
||||||
|
parseFinishedTags(flags?.tags),
|
||||||
|
autoTagsForJob(j)
|
||||||
|
)
|
||||||
|
|
||||||
const modelCountry = resolveFinishedCountry(flags, j)
|
const modelCountry = resolveFinishedCountry(flags, j)
|
||||||
|
|
||||||
@ -835,6 +845,8 @@ function FinishedDownloadsCardsView({
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
stepSeconds={scrubberStepSeconds}
|
stepSeconds={scrubberStepSeconds}
|
||||||
|
durationSeconds={heatmapDuration}
|
||||||
|
heatmapSegments={heatmapSegments}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@ -1145,6 +1157,7 @@ function FinishedDownloadsCardsView({
|
|||||||
formatBytes={formatBytes}
|
formatBytes={formatBytes}
|
||||||
lower={lower}
|
lower={lower}
|
||||||
jobForDetails={jobForDetails}
|
jobForDetails={jobForDetails}
|
||||||
|
handleScrubberClickIndex={handleScrubberClickIndex}
|
||||||
handleDuration={handleDuration}
|
handleDuration={handleDuration}
|
||||||
handleResolution={handleResolution}
|
handleResolution={handleResolution}
|
||||||
resolutionLabelOf={resolutionLabelOf}
|
resolutionLabelOf={resolutionLabelOf}
|
||||||
@ -1311,6 +1324,10 @@ function FinishedDownloadsCardsView({
|
|||||||
doubleTapMaxMovePx={48}
|
doubleTapMaxMovePx={48}
|
||||||
pressDelayMs={220}
|
pressDelayMs={220}
|
||||||
enablePressHold={canSpeedHold}
|
enablePressHold={canSpeedHold}
|
||||||
|
verticalThresholdPx={92}
|
||||||
|
verticalThresholdRatio={0.20}
|
||||||
|
upAction={{ label: 'Öffnen' }}
|
||||||
|
downAction={{ label: 'Weiter' }}
|
||||||
onDoubleTap={async () => {
|
onDoubleTap={async () => {
|
||||||
if (isHot) return
|
if (isHot) return
|
||||||
await onToggleHot?.(j)
|
await onToggleHot?.(j)
|
||||||
@ -1344,12 +1361,20 @@ function FinishedDownloadsCardsView({
|
|||||||
setMobileTopTeaserEnabled(false)
|
setMobileTopTeaserEnabled(false)
|
||||||
return await keepVideo(j)
|
return await keepVideo(j)
|
||||||
}}
|
}}
|
||||||
|
onSwipeUp={() => {
|
||||||
|
setMobileTopTeaserEnabled(false)
|
||||||
|
openPlayer(j)
|
||||||
|
return false
|
||||||
|
}}
|
||||||
|
onSwipeDown={() => {
|
||||||
|
setMobileTopTeaserEnabled(false)
|
||||||
|
skipTopMobileCard()
|
||||||
|
return true
|
||||||
|
}}
|
||||||
onPressStart={(info) => {
|
onPressStart={(info) => {
|
||||||
if (info.pointerType === 'mouse' && info.button !== 0) return
|
if (info.pointerType === 'mouse' && info.button !== 0) return
|
||||||
if (!canSpeedHold) return
|
if (!canSpeedHold) return
|
||||||
|
|
||||||
// Wichtig im Auswahl-Modus:
|
|
||||||
// Falls der Teaser noch nicht läuft, beim Gedrückthalten aktivieren.
|
|
||||||
setMobileTopTeaserEnabled(true)
|
setMobileTopTeaserEnabled(true)
|
||||||
setSpeedHoldKey(k)
|
setSpeedHoldKey(k)
|
||||||
}}
|
}}
|
||||||
@ -1370,16 +1395,22 @@ function FinishedDownloadsCardsView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mobileTopRow && mobileOrderedRows.length > 1 ? (
|
{mobileTopRow ? (
|
||||||
<div className="mt-3 flex justify-center">
|
<div className="mt-3 space-y-2">
|
||||||
<Button
|
<div className="text-center text-[11px] font-medium text-gray-500 dark:text-gray-400">
|
||||||
size="sm"
|
← Löschen · → Behalten · ↑ Öffnen · ↓ Weiter · Doppeltipp HOT · Halten schneller
|
||||||
variant="soft"
|
</div>
|
||||||
onClick={skipTopMobileCard}
|
|
||||||
className="w-full"
|
{mobileOrderedRows.length > 1 ? (
|
||||||
>
|
<Button
|
||||||
Überspringen
|
size="sm"
|
||||||
</Button>
|
variant="soft"
|
||||||
|
onClick={skipTopMobileCard}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Überspringen
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { memo, useCallback, useRef, type ReactNode } from 'react'
|
import { memo, useCallback, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||||
import Card from './Card'
|
import Card from './Card'
|
||||||
import Checkbox from './Checkbox'
|
import Checkbox from './Checkbox'
|
||||||
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
||||||
@ -27,9 +27,21 @@ import {
|
|||||||
shouldAnimateTeaser,
|
shouldAnimateTeaser,
|
||||||
shouldEnableTeaserAudio,
|
shouldEnableTeaserAudio,
|
||||||
} from './teaserPlayback'
|
} from './teaserPlayback'
|
||||||
import { parseJobMeta } from './previewSprite'
|
import {
|
||||||
|
buildSpriteFrameStyle,
|
||||||
|
parseJobMeta,
|
||||||
|
readPreviewSpriteInfo,
|
||||||
|
scrubProgressRatioFromIndex,
|
||||||
|
} from './previewSprite'
|
||||||
|
import PreviewScrubber from './PreviewScrubber'
|
||||||
|
import SpritePreloader from './SpritePreloader'
|
||||||
|
import {
|
||||||
|
buildVideoHeatmapSegments,
|
||||||
|
heatmapDurationForJob,
|
||||||
|
} from './videoHeatmap'
|
||||||
import PreviewRatingOverlay from './PreviewRatingOverlay'
|
import PreviewRatingOverlay from './PreviewRatingOverlay'
|
||||||
import PreviewMetaOverlay from './PreviewMetaOverlay'
|
import PreviewMetaOverlay from './PreviewMetaOverlay'
|
||||||
|
import { autoTagsForJob, mergeTags } from './autoTags'
|
||||||
|
|
||||||
export type FinishedDownloadsDesktopCardProps = {
|
export type FinishedDownloadsDesktopCardProps = {
|
||||||
job: RecordJob
|
job: RecordJob
|
||||||
@ -55,6 +67,11 @@ export type FinishedDownloadsDesktopCardProps = {
|
|||||||
lower: (s: string) => string
|
lower: (s: string) => string
|
||||||
|
|
||||||
jobForDetails: (job: RecordJob) => RecordJob
|
jobForDetails: (job: RecordJob) => RecordJob
|
||||||
|
handleScrubberClickIndex: (
|
||||||
|
job: RecordJob,
|
||||||
|
segmentIndex: number,
|
||||||
|
segmentCount: number
|
||||||
|
) => void
|
||||||
handleDuration: (job: RecordJob, seconds: number) => void
|
handleDuration: (job: RecordJob, seconds: number) => void
|
||||||
handleResolution: (job: RecordJob, w: number, h: number) => void
|
handleResolution: (job: RecordJob, w: number, h: number) => void
|
||||||
resolutionLabelOf: (job: RecordJob) => string
|
resolutionLabelOf: (job: RecordJob) => string
|
||||||
@ -138,6 +155,7 @@ const FinishedDownloadsDesktopCard = memo(
|
|||||||
formatBytes,
|
formatBytes,
|
||||||
lower,
|
lower,
|
||||||
jobForDetails,
|
jobForDetails,
|
||||||
|
handleScrubberClickIndex,
|
||||||
handleDuration,
|
handleDuration,
|
||||||
handleResolution,
|
handleResolution,
|
||||||
resolutionLabelOf,
|
resolutionLabelOf,
|
||||||
@ -170,6 +188,9 @@ const FinishedDownloadsDesktopCard = memo(
|
|||||||
const inlineActive = inlinePlay?.key === k
|
const inlineActive = inlinePlay?.key === k
|
||||||
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
|
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
|
||||||
|
|
||||||
|
const [scrubActiveIndex, setScrubActiveIndex] = useState<number | undefined>()
|
||||||
|
const [scrubHovering, setScrubHovering] = useState(false)
|
||||||
|
|
||||||
const allowSound = shouldEnableTeaserAudio({
|
const allowSound = shouldEnableTeaserAudio({
|
||||||
state: teaserState,
|
state: teaserState,
|
||||||
itemKey: k,
|
itemKey: k,
|
||||||
@ -217,7 +238,10 @@ const FinishedDownloadsDesktopCard = memo(
|
|||||||
lower
|
lower
|
||||||
)
|
)
|
||||||
|
|
||||||
const tags = parseFinishedTags(flags?.tags)
|
const tags = mergeTags(
|
||||||
|
parseFinishedTags(flags?.tags),
|
||||||
|
autoTagsForJob(j)
|
||||||
|
)
|
||||||
const modelCountry = resolveFinishedCountry(flags, j)
|
const modelCountry = resolveFinishedCountry(flags, j)
|
||||||
const dur = runtimeOf(j)
|
const dur = runtimeOf(j)
|
||||||
const size = formatBytes(sizeBytesOf(j))
|
const size = formatBytes(sizeBytesOf(j))
|
||||||
@ -241,6 +265,57 @@ const FinishedDownloadsDesktopCard = memo(
|
|||||||
const resLabelRaw = resolutionLabelOf(j)
|
const resLabelRaw = resolutionLabelOf(j)
|
||||||
const resLabel = resLabelRaw && resLabelRaw !== '—' ? resLabelRaw : ''
|
const resLabel = resLabelRaw && resLabelRaw !== '—' ? resLabelRaw : ''
|
||||||
|
|
||||||
|
const sprite = useMemo(
|
||||||
|
() =>
|
||||||
|
readPreviewSpriteInfo((j as any)?.meta, {
|
||||||
|
fallbackPath: flags?.previewScrubberPath,
|
||||||
|
fallbackCount: flags?.previewScrubberCount,
|
||||||
|
versionFallback: assetNonce ?? 0,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
(j as any)?.meta,
|
||||||
|
flags?.previewScrubberPath,
|
||||||
|
flags?.previewScrubberCount,
|
||||||
|
assetNonce,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const spriteUrl = sprite.url
|
||||||
|
const hasScrubberUi = sprite.hasScrubberUi
|
||||||
|
const hasSpriteScrubber = sprite.hasSpriteScrubber
|
||||||
|
const scrubberCount = hasScrubberUi ? sprite.count : 0
|
||||||
|
const scrubberStepSeconds = hasScrubberUi ? sprite.stepSeconds : 0
|
||||||
|
|
||||||
|
const heatmapDuration = useMemo(
|
||||||
|
() => heatmapDurationForJob(j, durationSeconds ?? (j as any)?.durationSeconds),
|
||||||
|
[(j as any)?.meta, (j as any)?.durationSeconds, durationSeconds]
|
||||||
|
)
|
||||||
|
|
||||||
|
const heatmapSegments = useMemo(
|
||||||
|
() => buildVideoHeatmapSegments(j, heatmapDuration),
|
||||||
|
[(j as any)?.meta, heatmapDuration]
|
||||||
|
)
|
||||||
|
|
||||||
|
const spriteScrubbingActive =
|
||||||
|
hasSpriteScrubber && scrubHovering && typeof scrubActiveIndex === 'number'
|
||||||
|
|
||||||
|
const scrubProgressRatio = scrubProgressRatioFromIndex(
|
||||||
|
scrubActiveIndex,
|
||||||
|
scrubberCount
|
||||||
|
)
|
||||||
|
|
||||||
|
const spriteFrameStyle = useMemo(
|
||||||
|
() =>
|
||||||
|
buildSpriteFrameStyle({
|
||||||
|
spriteUrl,
|
||||||
|
spriteCols: sprite.cols,
|
||||||
|
spriteRows: sprite.rows,
|
||||||
|
spriteCount: sprite.count,
|
||||||
|
activeIndex: scrubActiveIndex,
|
||||||
|
}),
|
||||||
|
[spriteUrl, sprite.cols, sprite.rows, sprite.count, scrubActiveIndex]
|
||||||
|
)
|
||||||
|
|
||||||
const inlineDomId = `inline-prev-${encodeURIComponent(k)}`
|
const inlineDomId = `inline-prev-${encodeURIComponent(k)}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -276,6 +351,8 @@ const FinishedDownloadsDesktopCard = memo(
|
|||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
clearLongPressTimer()
|
clearLongPressTimer()
|
||||||
onHoverPreviewKeyChange?.(null)
|
onHoverPreviewKeyChange?.(null)
|
||||||
|
setScrubActiveIndex(undefined)
|
||||||
|
setScrubHovering(false)
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
@ -352,8 +429,14 @@ const FinishedDownloadsDesktopCard = memo(
|
|||||||
animated={shouldAnimateTeaser({
|
animated={shouldAnimateTeaser({
|
||||||
state: teaserState,
|
state: teaserState,
|
||||||
itemKey: k,
|
itemKey: k,
|
||||||
disabled: false,
|
disabled: spriteScrubbingActive,
|
||||||
})}
|
})}
|
||||||
|
scrubProgressRatio={hasSpriteScrubber ? undefined : scrubProgressRatio}
|
||||||
|
preferScrubProgress={
|
||||||
|
!hasSpriteScrubber &&
|
||||||
|
scrubHovering &&
|
||||||
|
typeof scrubActiveIndex === 'number'
|
||||||
|
}
|
||||||
animatedMode="teaser"
|
animatedMode="teaser"
|
||||||
animatedTrigger="always"
|
animatedTrigger="always"
|
||||||
inlineVideo={inlineActive ? 'always' : false}
|
inlineVideo={inlineActive ? 'always' : false}
|
||||||
@ -364,6 +447,44 @@ const FinishedDownloadsDesktopCard = memo(
|
|||||||
popoverMuted={previewMuted}
|
popoverMuted={previewMuted}
|
||||||
assetNonce={assetNonce ?? 0}
|
assetNonce={assetNonce ?? 0}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SpritePreloader
|
||||||
|
src={spriteUrl}
|
||||||
|
enabled={Boolean(hasSpriteScrubber && spriteUrl)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasSpriteScrubber && spriteFrameStyle && !inlineActive ? (
|
||||||
|
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
|
||||||
|
<div className="h-full w-full" style={spriteFrameStyle} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!inlineActive && scrubberCount > 1 ? (
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<PreviewScrubber
|
||||||
|
className="pointer-events-auto px-1"
|
||||||
|
imageCount={scrubberCount}
|
||||||
|
activeIndex={scrubActiveIndex}
|
||||||
|
onActiveIndexChange={(idx) => {
|
||||||
|
setScrubActiveIndex(idx)
|
||||||
|
setScrubHovering(typeof idx === 'number')
|
||||||
|
}}
|
||||||
|
onIndexClick={(index) => {
|
||||||
|
setScrubActiveIndex(index)
|
||||||
|
setScrubHovering(true)
|
||||||
|
handleScrubberClickIndex(j, index, scrubberCount)
|
||||||
|
}}
|
||||||
|
stepSeconds={scrubberStepSeconds}
|
||||||
|
durationSeconds={heatmapDuration}
|
||||||
|
heatmapSegments={heatmapSegments}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -497,7 +618,8 @@ const FinishedDownloadsDesktopCard = memo(
|
|||||||
prevInline.nonce === nextInline.nonce &&
|
prevInline.nonce === nextInline.nonce &&
|
||||||
prev.modelsByKey === next.modelsByKey &&
|
prev.modelsByKey === next.modelsByKey &&
|
||||||
prev.activeTagSet === next.activeTagSet &&
|
prev.activeTagSet === next.activeTagSet &&
|
||||||
prev.renderPostworkBadge === next.renderPostworkBadge
|
prev.renderPostworkBadge === next.renderPostworkBadge &&
|
||||||
|
prev.handleScrubberClickIndex === next.handleScrubberClickIndex
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -26,13 +26,18 @@ import {
|
|||||||
readPreviewSpriteInfo,
|
readPreviewSpriteInfo,
|
||||||
scrubProgressRatioFromIndex,
|
scrubProgressRatioFromIndex,
|
||||||
} from './previewSprite'
|
} from './previewSprite'
|
||||||
|
import {
|
||||||
|
buildVideoHeatmapSegments,
|
||||||
|
heatmapDurationForJob,
|
||||||
|
} from './videoHeatmap'
|
||||||
import ModelGenderIcon from './ModelGenderIcon'
|
import ModelGenderIcon from './ModelGenderIcon'
|
||||||
import { resolveFinishedModelFlags } from './finishedModelFlags'
|
import { parseFinishedTags, resolveFinishedModelFlags } from './finishedModelFlags'
|
||||||
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
||||||
import SpritePreloader from './SpritePreloader'
|
import SpritePreloader from './SpritePreloader'
|
||||||
import PreviewRatingOverlay from './PreviewRatingOverlay'
|
import PreviewRatingOverlay from './PreviewRatingOverlay'
|
||||||
import PreviewMetaOverlay from './PreviewMetaOverlay'
|
import PreviewMetaOverlay from './PreviewMetaOverlay'
|
||||||
import DeletingPreviewOverlay from './DeletingPreviewOverlay'
|
import DeletingPreviewOverlay from './DeletingPreviewOverlay'
|
||||||
|
import { autoTagsForJob, mergeTags } from './autoTags'
|
||||||
|
|
||||||
function normalizeDurationSeconds(value: unknown): number | undefined {
|
function normalizeDurationSeconds(value: unknown): number | undefined {
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
||||||
@ -258,19 +263,11 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const tags = useMemo(() => {
|
const tags = useMemo(() => {
|
||||||
const s = String(flags?.tags ?? '').trim()
|
return mergeTags(
|
||||||
if (!s) return [] as string[]
|
parseFinishedTags(flags?.tags),
|
||||||
const parts = s.split(/[\n,;|]+/g).map((p) => p.trim()).filter(Boolean)
|
autoTagsForJob(j)
|
||||||
const seen = new Set<string>()
|
)
|
||||||
const out: string[] = []
|
}, [flags?.tags, (j as any)?.meta])
|
||||||
for (const p of parts) {
|
|
||||||
const kk = p.toLowerCase()
|
|
||||||
if (seen.has(kk)) continue
|
|
||||||
seen.add(kk)
|
|
||||||
out.push(p)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}, [flags?.tags])
|
|
||||||
|
|
||||||
const isFav = Boolean(flags?.favorite)
|
const isFav = Boolean(flags?.favorite)
|
||||||
const isLiked = flags?.liked === true
|
const isLiked = flags?.liked === true
|
||||||
@ -350,6 +347,16 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
normalizeDurationSeconds(meta?.media?.durationSeconds) ??
|
normalizeDurationSeconds(meta?.media?.durationSeconds) ??
|
||||||
normalizeDurationSeconds(meta?.file?.durationSeconds)
|
normalizeDurationSeconds(meta?.file?.durationSeconds)
|
||||||
|
|
||||||
|
const heatmapDuration = useMemo(
|
||||||
|
() => heatmapDurationForJob(j, previewDurationSeconds),
|
||||||
|
[(j as any)?.meta, (j as any)?.durationSeconds, previewDurationSeconds]
|
||||||
|
)
|
||||||
|
|
||||||
|
const heatmapSegments = useMemo(
|
||||||
|
() => buildVideoHeatmapSegments(j, heatmapDuration),
|
||||||
|
[(j as any)?.meta, heatmapDuration]
|
||||||
|
)
|
||||||
|
|
||||||
const spriteScrubbingActive =
|
const spriteScrubbingActive =
|
||||||
hasSpriteScrubber && typeof activeScrubIndex === 'number'
|
hasSpriteScrubber && typeof activeScrubIndex === 'number'
|
||||||
|
|
||||||
@ -546,6 +553,8 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
handleScrubberClickIndex(j, index, scrubberCount)
|
handleScrubberClickIndex(j, index, scrubberCount)
|
||||||
}}
|
}}
|
||||||
stepSeconds={scrubberStepSeconds}
|
stepSeconds={scrubberStepSeconds}
|
||||||
|
durationSeconds={heatmapDuration}
|
||||||
|
heatmapSegments={heatmapSegments}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
846
frontend/src/components/ui/FinishedReportModal.tsx
Normal file
846
frontend/src/components/ui/FinishedReportModal.tsx
Normal file
@ -0,0 +1,846 @@
|
|||||||
|
// frontend\src\components\ui\FinishedReportModal.tsx
|
||||||
|
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
|
import {
|
||||||
|
ArchiveBoxIcon,
|
||||||
|
ArrowPathIcon,
|
||||||
|
CalendarDaysIcon,
|
||||||
|
ChartBarIcon,
|
||||||
|
ClockIcon,
|
||||||
|
FireIcon,
|
||||||
|
StarIcon,
|
||||||
|
} from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FinishedReportDailyItem,
|
||||||
|
FinishedReportMode,
|
||||||
|
FinishedReportStats,
|
||||||
|
FinishedReportTopItem,
|
||||||
|
RecordJob,
|
||||||
|
} from '../../types'
|
||||||
|
import Modal from './Modal'
|
||||||
|
import Button from './Button'
|
||||||
|
import ButtonGroup from './ButtonGroup'
|
||||||
|
import LoadingSpinner from './LoadingSpinner'
|
||||||
|
import { autoTagsForJob, ratingStarsForJob } from './autoTags'
|
||||||
|
import { SegmentLabelIcon } from './Icons'
|
||||||
|
|
||||||
|
const REPORT_PAGE_SIZE = 1000
|
||||||
|
|
||||||
|
const nf = new Intl.NumberFormat('de-DE')
|
||||||
|
|
||||||
|
function fmtInt(value: number | undefined | null) {
|
||||||
|
if (value == null || !Number.isFinite(value)) return '—'
|
||||||
|
return nf.format(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtBytes(value: number | undefined | null) {
|
||||||
|
if (value == null || !Number.isFinite(value)) return '—'
|
||||||
|
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
let v = value
|
||||||
|
let i = 0
|
||||||
|
|
||||||
|
while (v >= 1024 && i < units.length - 1) {
|
||||||
|
v /= 1024
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
const digits = i <= 1 ? 0 : 1
|
||||||
|
return `${v.toFixed(digits)} ${units[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDuration(totalSeconds: number | undefined | null) {
|
||||||
|
if (totalSeconds == null || !Number.isFinite(totalSeconds) || totalSeconds <= 0) {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = Math.floor(totalSeconds)
|
||||||
|
const h = Math.floor(s / 3600)
|
||||||
|
const m = Math.floor((s % 3600) / 60)
|
||||||
|
const sec = s % 60
|
||||||
|
|
||||||
|
if (h > 0) {
|
||||||
|
return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${m}:${String(sec).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMeta(metaRaw: unknown): Record<string, any> | null {
|
||||||
|
if (!metaRaw) return null
|
||||||
|
|
||||||
|
if (typeof metaRaw === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(metaRaw)
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||||
|
? parsed as Record<string, any>
|
||||||
|
: null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof metaRaw === 'object' && !Array.isArray(metaRaw)
|
||||||
|
? metaRaw as Record<string, any>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumberLike(...values: unknown[]): number | null {
|
||||||
|
for (const value of values) {
|
||||||
|
const n = Number(value)
|
||||||
|
if (Number.isFinite(n) && n > 0) return n
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJobDurationSeconds(job: RecordJob): number | null {
|
||||||
|
const meta = parseMeta((job as any)?.meta)
|
||||||
|
|
||||||
|
return readNumberLike(
|
||||||
|
(job as any)?.durationSeconds,
|
||||||
|
(job as any)?.durationSec,
|
||||||
|
meta?.media?.durationSeconds,
|
||||||
|
meta?.media?.durationSec,
|
||||||
|
meta?.file?.durationSeconds,
|
||||||
|
meta?.file?.durationSec,
|
||||||
|
meta?.durationSeconds,
|
||||||
|
meta?.durationSec
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJobSizeBytes(job: RecordJob): number | null {
|
||||||
|
const meta = parseMeta((job as any)?.meta)
|
||||||
|
|
||||||
|
return readNumberLike(
|
||||||
|
(job as any)?.sizeBytes,
|
||||||
|
(job as any)?.fileSizeBytes,
|
||||||
|
(job as any)?.bytes,
|
||||||
|
(job as any)?.size,
|
||||||
|
meta?.file?.sizeBytes,
|
||||||
|
meta?.file?.bytes,
|
||||||
|
meta?.sizeBytes,
|
||||||
|
meta?.bytes,
|
||||||
|
meta?.size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseName(path: string) {
|
||||||
|
return String(path || '').split(/[\\/]/).pop() || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHotPrefix(name: string) {
|
||||||
|
return String(name || '').startsWith('HOT ')
|
||||||
|
? String(name || '').slice(4)
|
||||||
|
: String(name || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHotOutput(output: string) {
|
||||||
|
return baseName(output).startsWith('HOT ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKeepOutput(output: string) {
|
||||||
|
return String(output || '').replaceAll('\\', '/').includes('/keep/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelNameFromOutput(output?: string) {
|
||||||
|
const fileRaw = baseName(output || '')
|
||||||
|
const file = stripHotPrefix(fileRaw)
|
||||||
|
if (!file) return '—'
|
||||||
|
|
||||||
|
const stem = file.replace(/\.[^.]+$/, '')
|
||||||
|
|
||||||
|
const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/)
|
||||||
|
if (m?.[1]) return m[1]
|
||||||
|
|
||||||
|
const i = stem.lastIndexOf('_')
|
||||||
|
return i > 0 ? stem.slice(0, i) : stem
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJobDate(job: RecordJob): Date | null {
|
||||||
|
const raw =
|
||||||
|
(job as any)?.endedAt ??
|
||||||
|
(job as any)?.completedAt ??
|
||||||
|
(job as any)?.startedAt ??
|
||||||
|
null
|
||||||
|
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
const d = new Date(raw)
|
||||||
|
return Number.isFinite(d.getTime()) ? d : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfDay(value: Date) {
|
||||||
|
const d = new Date(value)
|
||||||
|
d.setHours(0, 0, 0, 0)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(value: Date, days: number) {
|
||||||
|
const d = new Date(value)
|
||||||
|
d.setDate(d.getDate() + days)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfWeekMonday(value: Date) {
|
||||||
|
const d = startOfDay(value)
|
||||||
|
const day = d.getDay()
|
||||||
|
const diff = day === 0 ? -6 : 1 - day
|
||||||
|
d.setDate(d.getDate() + diff)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportPeriod(mode: FinishedReportMode, now = new Date()) {
|
||||||
|
if (mode === 'week') {
|
||||||
|
const start = startOfWeekMonday(now)
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
end: addDays(start, 7),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = startOfDay(now)
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
end: addDays(start, 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateKey(value: Date) {
|
||||||
|
return [
|
||||||
|
value.getFullYear(),
|
||||||
|
String(value.getMonth() + 1).padStart(2, '0'),
|
||||||
|
String(value.getDate()).padStart(2, '0'),
|
||||||
|
].join('-')
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDateLabel(value: Date) {
|
||||||
|
return value.toLocaleDateString('de-DE', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function longDateLabel(value: Date) {
|
||||||
|
return value.toLocaleDateString('de-DE', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeLabel(start: Date, end: Date) {
|
||||||
|
const last = addDays(end, -1)
|
||||||
|
|
||||||
|
if (dateKey(start) === dateKey(last)) {
|
||||||
|
return longDateLabel(start)
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${start.toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
})} – ${last.toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
})}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function incrementMap(map: Map<string, FinishedReportTopItem>, label: string, iconLabel?: string) {
|
||||||
|
const clean = String(label || '').trim()
|
||||||
|
if (!clean) return
|
||||||
|
|
||||||
|
const key = clean.toLowerCase()
|
||||||
|
const current = map.get(key)
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
current.count += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
map.set(key, {
|
||||||
|
label: clean,
|
||||||
|
iconLabel,
|
||||||
|
count: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function topItems(map: Map<string, FinishedReportTopItem>, limit = 8) {
|
||||||
|
return Array.from(map.values())
|
||||||
|
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'de'))
|
||||||
|
.slice(0, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReportStats(rows: RecordJob[], mode: FinishedReportMode): FinishedReportStats {
|
||||||
|
const { start, end } = reportPeriod(mode)
|
||||||
|
|
||||||
|
const modelCounts = new Map<string, FinishedReportTopItem>()
|
||||||
|
const tagCounts = new Map<string, FinishedReportTopItem>()
|
||||||
|
|
||||||
|
const dailyMap = new Map<string, FinishedReportDailyItem>()
|
||||||
|
for (let d = new Date(start); d < end; d = addDays(d, 1)) {
|
||||||
|
const key = dateKey(d)
|
||||||
|
dailyMap.set(key, {
|
||||||
|
key,
|
||||||
|
label: shortDateLabel(d),
|
||||||
|
count: 0,
|
||||||
|
durationSeconds: 0,
|
||||||
|
sizeBytes: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratingCounts: Record<1 | 2 | 3 | 4 | 5, number> = {
|
||||||
|
1: 0,
|
||||||
|
2: 0,
|
||||||
|
3: 0,
|
||||||
|
4: 0,
|
||||||
|
5: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalCount = 0
|
||||||
|
let totalDurationSeconds = 0
|
||||||
|
let totalSizeBytes = 0
|
||||||
|
let knownSizeCount = 0
|
||||||
|
let hotCount = 0
|
||||||
|
let keepCount = 0
|
||||||
|
let ratedCount = 0
|
||||||
|
|
||||||
|
for (const job of rows) {
|
||||||
|
const d = readJobDate(job)
|
||||||
|
if (!d || d < start || d >= end) continue
|
||||||
|
|
||||||
|
totalCount++
|
||||||
|
|
||||||
|
const output = String((job as any)?.output ?? '')
|
||||||
|
if (isHotOutput(output)) hotCount++
|
||||||
|
if (isKeepOutput(output)) keepCount++
|
||||||
|
|
||||||
|
const duration = readJobDurationSeconds(job)
|
||||||
|
if (duration != null) {
|
||||||
|
totalDurationSeconds += duration
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeBytes = readJobSizeBytes(job)
|
||||||
|
if (sizeBytes != null) {
|
||||||
|
totalSizeBytes += sizeBytes
|
||||||
|
knownSizeCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = dailyMap.get(dateKey(d))
|
||||||
|
if (bucket) {
|
||||||
|
bucket.count += 1
|
||||||
|
bucket.durationSeconds += duration ?? 0
|
||||||
|
bucket.sizeBytes += sizeBytes ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = modelNameFromOutput(output)
|
||||||
|
if (model && model !== '—') {
|
||||||
|
incrementMap(modelCounts, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tag of autoTagsForJob(job)) {
|
||||||
|
incrementMap(tagCounts, tag, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stars = ratingStarsForJob(job)
|
||||||
|
if (stars != null) {
|
||||||
|
ratedCount++
|
||||||
|
ratingCounts[stars as 1 | 2 | 3 | 4 | 5] += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
totalCount,
|
||||||
|
totalDurationSeconds,
|
||||||
|
totalSizeBytes,
|
||||||
|
knownSizeCount,
|
||||||
|
hotCount,
|
||||||
|
keepCount,
|
||||||
|
ratedCount,
|
||||||
|
ratingCounts,
|
||||||
|
topModels: topItems(modelCounts, 8),
|
||||||
|
topTags: topItems(tagCounts, 10),
|
||||||
|
daily: Array.from(dailyMap.values()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportMetric({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
icon,
|
||||||
|
tone = 'slate',
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: ReactNode
|
||||||
|
sub?: ReactNode
|
||||||
|
icon: ReactNode
|
||||||
|
tone?: 'slate' | 'sky' | 'amber' | 'emerald' | 'rose'
|
||||||
|
}) {
|
||||||
|
const toneCls =
|
||||||
|
tone === 'sky'
|
||||||
|
? 'border-sky-200 bg-sky-50/80 text-sky-700 dark:border-sky-400/20 dark:bg-sky-400/10 dark:text-sky-200'
|
||||||
|
: tone === 'amber'
|
||||||
|
? 'border-amber-200 bg-amber-50/80 text-amber-700 dark:border-amber-400/20 dark:bg-amber-400/10 dark:text-amber-200'
|
||||||
|
: tone === 'emerald'
|
||||||
|
? 'border-emerald-200 bg-emerald-50/80 text-emerald-700 dark:border-emerald-400/20 dark:bg-emerald-400/10 dark:text-emerald-200'
|
||||||
|
: tone === 'rose'
|
||||||
|
? 'border-rose-200 bg-rose-50/80 text-rose-700 dark:border-rose-400/20 dark:bg-rose-400/10 dark:text-rose-200'
|
||||||
|
: 'border-gray-200 bg-gray-50/90 text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-300'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={['rounded-2xl border p-3 shadow-sm', toneCls].join(' ')}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[10px] font-black uppercase tracking-wide opacity-80">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 min-w-0 text-2xl font-black leading-tight text-gray-950 dark:text-white">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sub ? (
|
||||||
|
<div className="mt-1 truncate text-[11px] font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
{sub}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-white/70 ring-1 ring-black/5 dark:bg-white/10 dark:ring-white/10">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportBarList({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
empty,
|
||||||
|
icons = false,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
items: FinishedReportTopItem[]
|
||||||
|
empty: string
|
||||||
|
icons?: boolean
|
||||||
|
}) {
|
||||||
|
const max = Math.max(1, items[0]?.count ?? 1)
|
||||||
|
const total = items.reduce((sum, item) => sum + item.count, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="overflow-hidden rounded-2xl border border-gray-200/70 bg-white/80 shadow-sm dark:border-white/10 dark:bg-white/[0.04]">
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b border-gray-200/70 bg-gray-50/80 px-3 py-2.5 dark:border-white/10 dark:bg-white/[0.03]">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-gray-900 dark:text-white">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
{items.length > 0 ? `${fmtInt(total)} Treffer gesamt` : 'Keine Daten'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="rounded-full bg-white px-2 py-0.5 text-[11px] font-bold text-gray-600 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10">
|
||||||
|
Top {fmtInt(items.length)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="rounded-xl bg-gray-50 p-3 text-sm text-gray-500 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10">
|
||||||
|
{empty}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const width = `${Math.max(8, Math.round((item.count / max) * 100))}%`
|
||||||
|
const share = total > 0 ? Math.round((item.count / total) * 100) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
className="rounded-2xl bg-gray-50 p-2.5 ring-1 ring-black/5 transition hover:bg-gray-100/80 dark:bg-white/5 dark:ring-white/10 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-white text-[11px] font-black text-gray-500 ring-1 ring-gray-200 dark:bg-gray-950/40 dark:text-gray-400 dark:ring-white/10">
|
||||||
|
#{index + 1}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{icons ? (
|
||||||
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-100 dark:ring-indigo-300/30">
|
||||||
|
<SegmentLabelIcon
|
||||||
|
label={item.iconLabel ?? item.label}
|
||||||
|
className="h-4.5 w-4.5"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-bold text-gray-900 dark:text-white">
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
{share}% Anteil
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="shrink-0 rounded-full bg-indigo-50 px-2 py-0.5 text-[11px] font-black text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-100 dark:ring-indigo-300/30">
|
||||||
|
{fmtInt(item.count)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-indigo-500 dark:bg-indigo-400"
|
||||||
|
style={{ width }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DailyBars({ items }: { items: FinishedReportDailyItem[] }) {
|
||||||
|
const max = Math.max(1, ...items.map((item) => item.count))
|
||||||
|
const total = items.reduce((sum, item) => sum + item.count, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="overflow-hidden rounded-2xl border border-gray-200/70 bg-white/80 shadow-sm dark:border-white/10 dark:bg-white/[0.04]">
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b border-gray-200/70 bg-gray-50/80 px-3 py-2.5 dark:border-white/10 dark:bg-white/[0.03]">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-gray-900 dark:text-white">
|
||||||
|
Verlauf
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
Downloads pro Tag
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChartBarIcon className="h-5 w-5 text-sky-500 dark:text-sky-300" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{items.map((item) => {
|
||||||
|
const width = `${Math.max(item.count > 0 ? 8 : 0, Math.round((item.count / max) * 100))}%`
|
||||||
|
const share = total > 0 ? Math.round((item.count / total) * 100) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.key}>
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
|
||||||
|
<span className="font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
{fmtInt(item.count)} · {fmtDuration(item.durationSeconds)} · {share}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-2.5 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-sky-500 dark:bg-sky-400"
|
||||||
|
style={{ width }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RatingDistribution({ stats }: { stats: FinishedReportStats }) {
|
||||||
|
const max = Math.max(1, ...([1, 2, 3, 4, 5] as const).map((n) => stats.ratingCounts[n]))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="overflow-hidden rounded-2xl border border-gray-200/70 bg-white/80 shadow-sm dark:border-white/10 dark:bg-white/[0.04]">
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b border-gray-200/70 bg-gray-50/80 px-3 py-2.5 dark:border-white/10 dark:bg-white/[0.03]">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-gray-900 dark:text-white">
|
||||||
|
Bewertung
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
{fmtInt(stats.ratedCount)} bewertete Downloads
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StarIcon className="h-5 w-5 text-amber-500 dark:text-amber-300" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{([5, 4, 3, 2, 1] as const).map((stars) => {
|
||||||
|
const count = stats.ratingCounts[stars]
|
||||||
|
const width = `${Math.max(count > 0 ? 8 : 0, Math.round((count / max) * 100))}%`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={stars}>
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
|
||||||
|
<span className="font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
{'★'.repeat(stars)}
|
||||||
|
<span className="ml-1 text-gray-400">
|
||||||
|
{'★'.repeat(5 - stars)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
{fmtInt(count)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-2.5 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-amber-400 dark:bg-amber-300"
|
||||||
|
style={{ width }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FinishedReportModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [mode, setMode] = useState<FinishedReportMode>('day')
|
||||||
|
const [rows, setRows] = useState<RecordJob[]>([])
|
||||||
|
const [availableCount, setAvailableCount] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const loadReportRows = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url =
|
||||||
|
`/api/record/done?page=1&pageSize=${REPORT_PAGE_SIZE}` +
|
||||||
|
`&sort=completed_desc&includeKeep=1&withCount=1`
|
||||||
|
|
||||||
|
const r = await fetch(url, { cache: 'no-store' })
|
||||||
|
const data = await r.json().catch(() => null)
|
||||||
|
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error(String(data?.error || data?.message || `HTTP ${r.status}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = Array.isArray(data?.items) ? data.items as RecordJob[] : []
|
||||||
|
const count = Number(data?.count ?? items.length)
|
||||||
|
|
||||||
|
setRows(items)
|
||||||
|
setAvailableCount(Number.isFinite(count) ? count : items.length)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || 'Report konnte nicht geladen werden.')
|
||||||
|
setRows([])
|
||||||
|
setAvailableCount(0)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
void loadReportRows()
|
||||||
|
}, [open, loadReportRows])
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
return buildReportStats(rows, mode)
|
||||||
|
}, [rows, mode])
|
||||||
|
|
||||||
|
const partialHint =
|
||||||
|
availableCount > rows.length
|
||||||
|
? `Es werden die neuesten ${fmtInt(rows.length)} von ${fmtInt(availableCount)} Downloads berücksichtigt.`
|
||||||
|
: `${fmtInt(rows.length)} Downloads geladen.`
|
||||||
|
|
||||||
|
const avgDuration =
|
||||||
|
stats.totalCount > 0 ? stats.totalDurationSeconds / stats.totalCount : 0
|
||||||
|
|
||||||
|
const avgSize =
|
||||||
|
stats.knownSizeCount > 0 ? stats.totalSizeBytes / stats.knownSizeCount : 0
|
||||||
|
|
||||||
|
const reportModeSwitch = (
|
||||||
|
<ButtonGroup
|
||||||
|
value={mode}
|
||||||
|
onChange={(id) => setMode(id as FinishedReportMode)}
|
||||||
|
size="md"
|
||||||
|
ariaLabel="Report-Zeitraum"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: 'day',
|
||||||
|
label: 'Heute',
|
||||||
|
icon: <CalendarDaysIcon className="size-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'week',
|
||||||
|
label: 'Diese Woche',
|
||||||
|
icon: <ChartBarIcon className="size-4" />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const refreshButton = (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
rounded="md"
|
||||||
|
variant="secondary"
|
||||||
|
color="indigo"
|
||||||
|
onClick={() => void loadReportRows()}
|
||||||
|
isLoading={loading}
|
||||||
|
leadingIcon={<ArrowPathIcon className="h-4 w-4" />}
|
||||||
|
>
|
||||||
|
Aktualisieren
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Tages-/Wochenreport"
|
||||||
|
width="max-w-6xl"
|
||||||
|
scroll="body"
|
||||||
|
rightClassName="bg-gray-50/60 dark:bg-gray-950/20"
|
||||||
|
titleRight={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{reportModeSwitch}
|
||||||
|
{refreshButton}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4 p-4 sm:p-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 sm:hidden">
|
||||||
|
{reportModeSwitch}
|
||||||
|
{refreshButton}
|
||||||
|
</div>
|
||||||
|
<section className="overflow-hidden rounded-3xl border border-gray-200/70 bg-gradient-to-br from-white via-sky-50/70 to-indigo-50/70 shadow-sm dark:border-white/10 dark:from-white/[0.06] dark:via-sky-400/10 dark:to-indigo-400/10">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full bg-white/80 px-2.5 py-1 text-[11px] font-bold text-indigo-700 ring-1 ring-indigo-200 dark:bg-white/10 dark:text-indigo-100 dark:ring-indigo-300/30">
|
||||||
|
<CalendarDaysIcon className="h-4 w-4" />
|
||||||
|
{mode === 'day' ? 'Tagesreport' : 'Wochenreport'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="mt-3 text-2xl font-black tracking-tight text-gray-950 dark:text-white">
|
||||||
|
{mode === 'day' ? 'Heute' : 'Diese Woche'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{rangeLabel(stats.start, stats.end)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
|
{loading ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/80 px-2.5 py-1 text-[11px] font-medium text-gray-500 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10">
|
||||||
|
<LoadingSpinner size="sm" />
|
||||||
|
Lädt…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full bg-white/80 px-2.5 py-1 text-[11px] font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10">
|
||||||
|
{partialHint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="rounded-full bg-white/80 px-2.5 py-1 text-[11px] font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10">
|
||||||
|
Ø Dauer: {fmtDuration(avgDuration)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="rounded-full bg-white/80 px-2.5 py-1 text-[11px] font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10">
|
||||||
|
Ø Größe: {avgSize > 0 ? fmtBytes(avgSize) : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="mt-4 rounded-xl bg-red-50 p-3 text-sm text-red-700 ring-1 ring-red-200 dark:bg-red-500/10 dark:text-red-200 dark:ring-red-400/25">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3 xl:grid-cols-4">
|
||||||
|
<ReportMetric
|
||||||
|
label="Downloads"
|
||||||
|
value={fmtInt(stats.totalCount)}
|
||||||
|
sub="fertiggestellt"
|
||||||
|
icon={<CalendarDaysIcon className="h-5 w-5" />}
|
||||||
|
tone="sky"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ReportMetric
|
||||||
|
label="Laufzeit"
|
||||||
|
value={fmtDuration(stats.totalDurationSeconds)}
|
||||||
|
sub="Videodauer"
|
||||||
|
icon={<ClockIcon className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ReportMetric
|
||||||
|
label="Speicher"
|
||||||
|
value={stats.knownSizeCount > 0 ? fmtBytes(stats.totalSizeBytes) : '—'}
|
||||||
|
sub={stats.knownSizeCount > 0 ? `${fmtInt(stats.knownSizeCount)} Dateien` : 'keine Größen'}
|
||||||
|
icon={<ArchiveBoxIcon className="h-5 w-5" />}
|
||||||
|
tone="emerald"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ReportMetric
|
||||||
|
label="Markiert"
|
||||||
|
value={`${fmtInt(stats.hotCount)} / ${fmtInt(stats.keepCount)}`}
|
||||||
|
sub="HOT / Keep"
|
||||||
|
icon={<FireIcon className="h-5 w-5" />}
|
||||||
|
tone="amber"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[0.9fr_1.1fr]">
|
||||||
|
<DailyBars items={stats.daily} />
|
||||||
|
<RatingDistribution stats={stats} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-2">
|
||||||
|
<ReportBarList
|
||||||
|
title="Top Models"
|
||||||
|
items={stats.topModels}
|
||||||
|
empty="In diesem Zeitraum wurden keine Models gefunden."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ReportBarList
|
||||||
|
title="Top Inhalte"
|
||||||
|
items={stats.topTags}
|
||||||
|
empty="In diesem Zeitraum wurden keine Auto-Tags gefunden."
|
||||||
|
icons
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -256,32 +256,6 @@ export function ProneBoneIcon(props: IconProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SittingSexIcon(props: IconProps) {
|
|
||||||
return (
|
|
||||||
<IconBase
|
|
||||||
{...props}
|
|
||||||
className={iconClassName(props.className, 'origin-center scale-[1.15]')}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
{/* sitzende Person */}
|
|
||||||
<circle cx="12" cy="4.1" r="1.6" fill="currentColor" stroke="none" />
|
|
||||||
<path d="M10.2 6.7C9.6 8.1 9.3 9.6 9.6 11.1" />
|
|
||||||
<path d="M13.8 6.7C14.4 8.1 14.7 9.6 14.4 11.1" />
|
|
||||||
<path d="M8.2 11.7C10.1 12.8 13.9 12.8 15.8 11.7" />
|
|
||||||
<path d="M9 12.4L6.3 16.5" />
|
|
||||||
<path d="M15 12.4L17.7 16.5" />
|
|
||||||
|
|
||||||
{/* zweite Person unten / Schoß */}
|
|
||||||
<circle cx="12" cy="18.5" r="1.5" fill="currentColor" stroke="none" />
|
|
||||||
<path d="M8 18.1C9.2 16.6 10.5 15.9 12 15.9C13.5 15.9 14.8 16.6 16 18.1" />
|
|
||||||
<path d="M8.8 20.6H15.2" />
|
|
||||||
|
|
||||||
{/* Kontakt */}
|
|
||||||
<path d="M10.4 14.4C11.2 13.9 12.8 13.9 13.6 14.4" />
|
|
||||||
</IconBase>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SpooningSexIcon(props: IconProps) {
|
export function SpooningSexIcon(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<IconBase
|
<IconBase
|
||||||
@ -1051,11 +1025,6 @@ const SEGMENT_LABEL_META: SegmentLabelMeta[] = [
|
|||||||
text: 'Prone Bone',
|
text: 'Prone Bone',
|
||||||
icon: ProneBoneIcon,
|
icon: ProneBoneIcon,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
match: ['sitting', 'sitting_sex', 'sitting-sex', 'seated', 'seated_sex'],
|
|
||||||
text: 'Sitting',
|
|
||||||
icon: SittingSexIcon,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
match: ['spooning', 'spoons', 'spoon'],
|
match: ['spooning', 'spoons', 'spoon'],
|
||||||
text: 'Spooning',
|
text: 'Spooning',
|
||||||
|
|||||||
@ -4,7 +4,29 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState, useMemo, useCallback, type ReactNode } from 'react'
|
import { useEffect, useRef, useState, useMemo, useCallback, type ReactNode } from 'react'
|
||||||
|
|
||||||
import type { RecordJob } from '../../types'
|
import type {
|
||||||
|
BioCacheEntry,
|
||||||
|
BioContext,
|
||||||
|
BioResp,
|
||||||
|
BioSocial,
|
||||||
|
ChaturbateRoom,
|
||||||
|
ModelProfileLabelGroupKey,
|
||||||
|
ModelProfileLabelGroups,
|
||||||
|
ModelProfileStats,
|
||||||
|
OnlineCacheEntry,
|
||||||
|
OnlineResp,
|
||||||
|
ProviderKey,
|
||||||
|
RecordJob,
|
||||||
|
StoredModel,
|
||||||
|
} from '../../types'
|
||||||
|
import {
|
||||||
|
aiLabelGroup,
|
||||||
|
normalizeAiLabel,
|
||||||
|
prettyAiLabel,
|
||||||
|
} from '../../aiLabels'
|
||||||
|
import {
|
||||||
|
SegmentLabelIcon,
|
||||||
|
} from './Icons'
|
||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import RecordJobActions from './RecordJobActions'
|
import RecordJobActions from './RecordJobActions'
|
||||||
@ -57,18 +79,6 @@ function isRunningJob(job: RecordJob): boolean {
|
|||||||
|
|
||||||
const MD_CACHE_TTL_MS = 10 * 60 * 1000 // 10 min
|
const MD_CACHE_TTL_MS = 10 * 60 * 1000 // 10 min
|
||||||
|
|
||||||
type OnlineCacheEntry = {
|
|
||||||
at: number
|
|
||||||
room: ChaturbateRoom | null
|
|
||||||
meta: Pick<OnlineResp, 'enabled' | 'fetchedAt' | 'lastError'> | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type BioCacheEntry = {
|
|
||||||
at: number
|
|
||||||
bio: BioContext | null
|
|
||||||
meta: Pick<BioResp, 'enabled' | 'fetchedAt' | 'lastError'> | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// In-Memory (über Modal-Open/Close hinweg, solange Tab offen)
|
// In-Memory (über Modal-Open/Close hinweg, solange Tab offen)
|
||||||
const mdOnlineMem = new Map<string, OnlineCacheEntry>()
|
const mdOnlineMem = new Map<string, OnlineCacheEntry>()
|
||||||
const mdBioMem = new Map<string, BioCacheEntry>()
|
const mdBioMem = new Map<string, BioCacheEntry>()
|
||||||
@ -97,8 +107,6 @@ function ssSet(key: string, value: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProviderKey = 'chaturbate' | 'myfreecams'
|
|
||||||
|
|
||||||
function providerOfModel(model?: StoredModel | null): ProviderKey {
|
function providerOfModel(model?: StoredModel | null): ProviderKey {
|
||||||
const host = String(model?.host ?? '').toLowerCase()
|
const host = String(model?.host ?? '').toLowerCase()
|
||||||
const chatUrl = String(model?.chatRoomUrl ?? '').toLowerCase()
|
const chatUrl = String(model?.chatRoomUrl ?? '').toLowerCase()
|
||||||
@ -325,151 +333,6 @@ function heroNewPill() {
|
|||||||
const previewBlurCls = (blur?: boolean) =>
|
const previewBlurCls = (blur?: boolean) =>
|
||||||
blur ? 'blur-md scale-[1.03] brightness-90' : ''
|
blur ? 'blur-md scale-[1.03] brightness-90' : ''
|
||||||
|
|
||||||
// ------ API types (Chaturbate online) ------
|
|
||||||
|
|
||||||
type ChaturbateRoom = {
|
|
||||||
gender?: string
|
|
||||||
location?: string
|
|
||||||
country?: string
|
|
||||||
current_show?: string
|
|
||||||
username?: string
|
|
||||||
room_subject?: string
|
|
||||||
tags?: string[]
|
|
||||||
is_new?: boolean
|
|
||||||
num_users?: number
|
|
||||||
num_followers?: number
|
|
||||||
spoken_languages?: string
|
|
||||||
display_name?: string
|
|
||||||
birthday?: string
|
|
||||||
is_hd?: boolean
|
|
||||||
age?: number
|
|
||||||
seconds_online?: number
|
|
||||||
image_url?: string
|
|
||||||
image_url_360x270?: string
|
|
||||||
chat_room_url?: string
|
|
||||||
chat_room_url_revshare?: string
|
|
||||||
iframe_embed?: string
|
|
||||||
iframe_embed_revshare?: string
|
|
||||||
slug?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type OnlineResp = {
|
|
||||||
enabled?: boolean
|
|
||||||
fetchedAt?: string
|
|
||||||
count?: number
|
|
||||||
lastError?: string
|
|
||||||
rooms?: ChaturbateRoom[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------ API types (Chaturbate biocontext proxy) ------
|
|
||||||
|
|
||||||
type BioPhotoSet = {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
cover_url?: string
|
|
||||||
tokens?: number
|
|
||||||
is_video?: boolean
|
|
||||||
user_can_access?: boolean
|
|
||||||
user_has_purchased?: boolean
|
|
||||||
fan_club_only?: boolean
|
|
||||||
label_text?: string
|
|
||||||
label_color?: string
|
|
||||||
video_has_sound?: boolean
|
|
||||||
video_ready?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type BioSocial = {
|
|
||||||
id: number
|
|
||||||
title_name: string
|
|
||||||
image_url?: string
|
|
||||||
link?: string
|
|
||||||
popup_link?: boolean
|
|
||||||
tokens?: number
|
|
||||||
purchased?: boolean
|
|
||||||
label_text?: string
|
|
||||||
label_color?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type BioContext = {
|
|
||||||
follower_count?: number
|
|
||||||
location?: string
|
|
||||||
real_name?: string
|
|
||||||
body_decorations?: string
|
|
||||||
last_broadcast?: string
|
|
||||||
smoke_drink?: string
|
|
||||||
body_type?: string
|
|
||||||
display_birthday?: string
|
|
||||||
about_me?: string
|
|
||||||
wish_list?: string
|
|
||||||
time_since_last_broadcast?: string
|
|
||||||
fan_club_cost?: number
|
|
||||||
performer_has_fanclub?: boolean
|
|
||||||
fan_club_is_member?: boolean
|
|
||||||
fan_club_join_url?: string
|
|
||||||
needs_supporter_to_pm?: boolean
|
|
||||||
interested_in?: string[]
|
|
||||||
display_age?: number
|
|
||||||
sex?: string
|
|
||||||
room_status?: string // offline/online
|
|
||||||
photo_sets?: BioPhotoSet[]
|
|
||||||
social_medias?: BioSocial[]
|
|
||||||
is_broadcaster_or_staff?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type BioResp = {
|
|
||||||
enabled?: boolean
|
|
||||||
fetchedAt?: string
|
|
||||||
lastError?: string
|
|
||||||
model?: string
|
|
||||||
bio?: BioContext | any | null
|
|
||||||
roomStatus?: string
|
|
||||||
isOnline?: boolean
|
|
||||||
chatRoomUrl?: string
|
|
||||||
imageUrl?: string
|
|
||||||
|
|
||||||
avatarUrl?: string
|
|
||||||
snapUrl?: string
|
|
||||||
roomTopic?: string
|
|
||||||
country?: string
|
|
||||||
gender?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------ props ------
|
|
||||||
|
|
||||||
// ------ API types (local model store) ------
|
|
||||||
// /api/models liefert StoredModel aus dem models_store
|
|
||||||
type StoredModel = {
|
|
||||||
id: string
|
|
||||||
host?: string | null
|
|
||||||
modelKey: string
|
|
||||||
tags?: string | null
|
|
||||||
|
|
||||||
gender?: string | null
|
|
||||||
country?: string | null
|
|
||||||
|
|
||||||
lastSeenOnline?: boolean | null
|
|
||||||
lastSeenOnlineAt?: string
|
|
||||||
|
|
||||||
roomStatus?: string
|
|
||||||
isOnline?: boolean
|
|
||||||
chatRoomUrl?: string
|
|
||||||
imageUrl?: string
|
|
||||||
lastOnlineAt?: string
|
|
||||||
lastOfflineAt?: string
|
|
||||||
lastRoomSyncAt?: string
|
|
||||||
|
|
||||||
favorite?: boolean
|
|
||||||
watching?: boolean
|
|
||||||
liked?: boolean | null
|
|
||||||
hot?: boolean
|
|
||||||
keep?: boolean
|
|
||||||
createdAt?: string
|
|
||||||
updatedAt?: string
|
|
||||||
cbOnlineJson?: string | null
|
|
||||||
cbOnlineFetchedAt?: string | null
|
|
||||||
cbOnlineLastError?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean
|
open: boolean
|
||||||
modelKey: string | null
|
modelKey: string | null
|
||||||
@ -496,6 +359,624 @@ type Props = {
|
|||||||
onStopJob?: (id: string) => void | Promise<void>
|
onStopJob?: (id: string) => void | Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readNumberLike(...values: unknown[]): number | null {
|
||||||
|
for (const value of values) {
|
||||||
|
const n = Number(value)
|
||||||
|
if (Number.isFinite(n) && n > 0) return n
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJobDurationSeconds(job: RecordJob): number | null {
|
||||||
|
const meta = parseJobMeta((job as any)?.meta) as any
|
||||||
|
|
||||||
|
return readNumberLike(
|
||||||
|
(job as any)?.durationSeconds,
|
||||||
|
(job as any)?.durationSec,
|
||||||
|
meta?.media?.durationSeconds,
|
||||||
|
meta?.media?.durationSec,
|
||||||
|
meta?.media?.duration,
|
||||||
|
meta?.file?.durationSeconds,
|
||||||
|
meta?.file?.durationSec,
|
||||||
|
meta?.durationSeconds,
|
||||||
|
meta?.durationSec
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJobSizeBytes(job: RecordJob): number | null {
|
||||||
|
const meta = parseJobMeta((job as any)?.meta) as any
|
||||||
|
|
||||||
|
return readNumberLike(
|
||||||
|
(job as any)?.sizeBytes,
|
||||||
|
(job as any)?.fileSizeBytes,
|
||||||
|
(job as any)?.bytes,
|
||||||
|
(job as any)?.size,
|
||||||
|
meta?.file?.sizeBytes,
|
||||||
|
meta?.file?.bytes,
|
||||||
|
meta?.sizeBytes,
|
||||||
|
meta?.bytes,
|
||||||
|
meta?.size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJobRatingStars(job: RecordJob): number | null {
|
||||||
|
const meta = parseJobMeta((job as any)?.meta) as any
|
||||||
|
|
||||||
|
const ai =
|
||||||
|
meta?.analysis?.highlights ??
|
||||||
|
meta?.analysis?.ai ??
|
||||||
|
meta?.ai ??
|
||||||
|
null
|
||||||
|
|
||||||
|
const raw =
|
||||||
|
ai?.rating?.stars ??
|
||||||
|
ai?.stars ??
|
||||||
|
ai?.rating?.rating ??
|
||||||
|
null
|
||||||
|
|
||||||
|
const n = Number(raw)
|
||||||
|
if (!Number.isFinite(n)) return null
|
||||||
|
|
||||||
|
const rounded = Math.round(n)
|
||||||
|
if (rounded < 1 || rounded > 5) return null
|
||||||
|
|
||||||
|
return rounded
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettyProfileLabel(value: unknown): string {
|
||||||
|
const key = normalizeAiLabel(value)
|
||||||
|
if (!key || key === 'unknown') return ''
|
||||||
|
|
||||||
|
return prettyAiLabel(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfileLabelEntry = {
|
||||||
|
label: string
|
||||||
|
iconLabel: string
|
||||||
|
count: number
|
||||||
|
group: ModelProfileLabelGroupKey
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyProfileLabelGroups(): ModelProfileLabelGroups {
|
||||||
|
return {
|
||||||
|
positions: [],
|
||||||
|
bodyParts: [],
|
||||||
|
objects: [],
|
||||||
|
clothing: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelProfileLabelGroup(value: unknown): ModelProfileLabelGroupKey | '' {
|
||||||
|
const group = aiLabelGroup(value)
|
||||||
|
|
||||||
|
if (group === 'position') return 'positions'
|
||||||
|
if (group === 'body') return 'bodyParts'
|
||||||
|
if (group === 'object') return 'objects'
|
||||||
|
if (group === 'clothing') return 'clothing'
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function countPercent(count: number, total: number) {
|
||||||
|
if (!Number.isFinite(count) || !Number.isFinite(total) || total <= 0) {
|
||||||
|
return '0%'
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Math.round((count / total) * 100)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileIconLabel(value: unknown): string {
|
||||||
|
const raw = String(value ?? '').trim()
|
||||||
|
if (!raw) return ''
|
||||||
|
|
||||||
|
const lower = raw.toLowerCase()
|
||||||
|
|
||||||
|
if (lower.startsWith('combo:')) {
|
||||||
|
const parts = lower
|
||||||
|
.slice('combo:'.length)
|
||||||
|
.split('+')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
return parts[0] ?? raw
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProfileLabel(
|
||||||
|
out: Map<string, ProfileLabelEntry>,
|
||||||
|
value: unknown
|
||||||
|
) {
|
||||||
|
const raw = String(value ?? '').trim()
|
||||||
|
if (!raw) return
|
||||||
|
|
||||||
|
const lower = raw.toLowerCase()
|
||||||
|
|
||||||
|
if (lower.startsWith('combo:')) {
|
||||||
|
for (const part of lower.slice('combo:'.length).split('+')) {
|
||||||
|
addProfileLabel(out, part)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = modelProfileLabelGroup(raw)
|
||||||
|
if (!group) return
|
||||||
|
|
||||||
|
const label = prettyProfileLabel(raw)
|
||||||
|
if (!label) return
|
||||||
|
|
||||||
|
const iconLabel = profileIconLabel(raw)
|
||||||
|
const key = `${group}:${label.toLowerCase()}`
|
||||||
|
const current = out.get(key)
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
current.count += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out.set(key, {
|
||||||
|
label,
|
||||||
|
iconLabel,
|
||||||
|
count: 1,
|
||||||
|
group,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectProfileLabels(
|
||||||
|
job: RecordJob,
|
||||||
|
out: Map<string, ProfileLabelEntry>
|
||||||
|
) {
|
||||||
|
const meta = parseJobMeta((job as any)?.meta) as any
|
||||||
|
|
||||||
|
const ai =
|
||||||
|
meta?.analysis?.highlights ??
|
||||||
|
meta?.analysis?.ai ??
|
||||||
|
meta?.ai ??
|
||||||
|
null
|
||||||
|
|
||||||
|
const segments = Array.isArray(ai?.segments) ? ai.segments : []
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
addProfileLabel(out, segment?.label)
|
||||||
|
addProfileLabel(out, segment?.title)
|
||||||
|
addProfileLabel(out, segment?.category)
|
||||||
|
addProfileLabel(out, segment?.type)
|
||||||
|
addProfileLabel(out, segment?.kind)
|
||||||
|
addProfileLabel(out, segment?.name)
|
||||||
|
addProfileLabel(out, segment?.position)
|
||||||
|
|
||||||
|
if (Array.isArray(segment?.tags)) {
|
||||||
|
for (const tag of segment.tags) addProfileLabel(out, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJobDate(job: RecordJob): Date | null {
|
||||||
|
const raw =
|
||||||
|
(job as any)?.endedAt ??
|
||||||
|
(job as any)?.completedAt ??
|
||||||
|
(job as any)?.startedAt ??
|
||||||
|
null
|
||||||
|
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
const d = new Date(raw)
|
||||||
|
return Number.isFinite(d.getTime()) ? d : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKeepJob(job: RecordJob): boolean {
|
||||||
|
const output = String((job as any)?.output ?? '').replaceAll('\\', '/')
|
||||||
|
return output.includes('/keep/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildModelProfileStats(
|
||||||
|
rows: RecordJob[],
|
||||||
|
totalCount: number,
|
||||||
|
runningRows: RecordJob[]
|
||||||
|
): ModelProfileStats {
|
||||||
|
let totalDurationSec = 0
|
||||||
|
let totalBytes = 0
|
||||||
|
let knownBytesCount = 0
|
||||||
|
|
||||||
|
let ratingSum = 0
|
||||||
|
let ratingCount = 0
|
||||||
|
let bestStars: number | null = null
|
||||||
|
let highRatedCount = 0
|
||||||
|
let lowRatedCount = 0
|
||||||
|
|
||||||
|
let hotCount = 0
|
||||||
|
let keepCount = 0
|
||||||
|
let latestAt: Date | null = null
|
||||||
|
|
||||||
|
const labelCounts = new Map<string, ProfileLabelEntry>()
|
||||||
|
|
||||||
|
for (const job of rows) {
|
||||||
|
const duration = readJobDurationSeconds(job)
|
||||||
|
if (duration != null) totalDurationSec += duration
|
||||||
|
|
||||||
|
const bytes = readJobSizeBytes(job)
|
||||||
|
if (bytes != null) {
|
||||||
|
totalBytes += bytes
|
||||||
|
knownBytesCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
const stars = readJobRatingStars(job)
|
||||||
|
if (stars != null) {
|
||||||
|
ratingSum += stars
|
||||||
|
ratingCount++
|
||||||
|
|
||||||
|
bestStars = bestStars == null ? stars : Math.max(bestStars, stars)
|
||||||
|
|
||||||
|
if (stars >= 4) highRatedCount++
|
||||||
|
if (stars <= 2) lowRatedCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = baseName(job.output || '')
|
||||||
|
if (isHotName(file)) hotCount++
|
||||||
|
if (isKeepJob(job)) keepCount++
|
||||||
|
|
||||||
|
const d = readJobDate(job)
|
||||||
|
if (d && (!latestAt || d.getTime() > latestAt.getTime())) {
|
||||||
|
latestAt = d
|
||||||
|
}
|
||||||
|
|
||||||
|
collectProfileLabels(job, labelCounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedLabels = Array.from(labelCounts.values())
|
||||||
|
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'de'))
|
||||||
|
|
||||||
|
const labelGroups = emptyProfileLabelGroups()
|
||||||
|
|
||||||
|
for (const item of sortedLabels) {
|
||||||
|
labelGroups[item.group].push({
|
||||||
|
label: item.label,
|
||||||
|
iconLabel: item.iconLabel,
|
||||||
|
count: item.count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys(labelGroups) as ModelProfileLabelGroupKey[]) {
|
||||||
|
labelGroups[key] = labelGroups[key].slice(0, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
const topLabels = sortedLabels.slice(0, 12).map((item) => ({
|
||||||
|
label: item.label,
|
||||||
|
iconLabel: item.iconLabel,
|
||||||
|
count: item.count,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalCount,
|
||||||
|
loadedCount: rows.length,
|
||||||
|
runningCount: runningRows.length,
|
||||||
|
totalDurationSec,
|
||||||
|
totalBytes,
|
||||||
|
knownBytesCount,
|
||||||
|
avgStars: ratingCount > 0 ? ratingSum / ratingCount : null,
|
||||||
|
bestStars,
|
||||||
|
highRatedCount,
|
||||||
|
lowRatedCount,
|
||||||
|
hotCount,
|
||||||
|
keepCount,
|
||||||
|
latestAt,
|
||||||
|
topLabels,
|
||||||
|
labelGroups,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelProfileStatsLabelList({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
values,
|
||||||
|
total,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
values: ModelProfileStats['topLabels']
|
||||||
|
total: number
|
||||||
|
}) {
|
||||||
|
if (values.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-gray-50 p-4 text-sm text-gray-500 ring-1 ring-black/5 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10">
|
||||||
|
Für diese Kategorie wurden noch keine Inhalte erkannt.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{values.map((item) => {
|
||||||
|
const shareWidth = countPercent(item.count, total)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
className="rounded-xl bg-white p-3 ring-1 ring-gray-200 dark:bg-gray-950/40 dark:ring-white/10"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-100 dark:ring-indigo-300/30">
|
||||||
|
<SegmentLabelIcon
|
||||||
|
label={item.iconLabel ?? item.label}
|
||||||
|
className="h-5 w-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
{fmtInt(item.count)} Treffer
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 rounded-full bg-indigo-50 px-2 py-0.5 text-[11px] font-bold text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-100 dark:ring-indigo-300/30">
|
||||||
|
{shareWidth}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-indigo-500 transition-all dark:bg-indigo-400"
|
||||||
|
style={{ width: shareWidth }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelProfileStatsPanel({
|
||||||
|
stats,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
stats: ModelProfileStats
|
||||||
|
loading: boolean
|
||||||
|
}) {
|
||||||
|
const [activeTab, setActiveTab] =
|
||||||
|
useState<ModelProfileLabelGroupKey>('positions')
|
||||||
|
|
||||||
|
const loadedHint =
|
||||||
|
stats.totalCount > stats.loadedCount
|
||||||
|
? `aus ${fmtInt(stats.loadedCount)} geladenen Downloads`
|
||||||
|
: `${fmtInt(stats.loadedCount)} Downloads ausgewertet`
|
||||||
|
|
||||||
|
const avgStorage =
|
||||||
|
stats.knownBytesCount > 0
|
||||||
|
? stats.totalBytes / stats.knownBytesCount
|
||||||
|
: null
|
||||||
|
|
||||||
|
const labelGroups = stats.labelGroups ?? emptyProfileLabelGroups()
|
||||||
|
|
||||||
|
const tabItems: Array<{
|
||||||
|
key: ModelProfileLabelGroupKey
|
||||||
|
title: string
|
||||||
|
shortTitle: string
|
||||||
|
description: string
|
||||||
|
values: ModelProfileStats['topLabels']
|
||||||
|
total: number
|
||||||
|
topLabel?: ModelProfileStats['topLabels'][number]
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
key: 'positions',
|
||||||
|
title: 'Positionen',
|
||||||
|
shortTitle: 'Positionen',
|
||||||
|
description: 'Positions-Labels aus den analysierten Segmenten.',
|
||||||
|
values: labelGroups.positions ?? [],
|
||||||
|
total: Math.max(
|
||||||
|
1,
|
||||||
|
(labelGroups.positions ?? []).reduce((sum, item) => sum + item.count, 0)
|
||||||
|
),
|
||||||
|
topLabel: labelGroups.positions?.[0],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'bodyParts',
|
||||||
|
title: 'Körperteile',
|
||||||
|
shortTitle: 'Körper',
|
||||||
|
description: 'Körperteil-Labels aus Segmenten und Analyse-Tags.',
|
||||||
|
values: labelGroups.bodyParts ?? [],
|
||||||
|
total: Math.max(
|
||||||
|
1,
|
||||||
|
(labelGroups.bodyParts ?? []).reduce((sum, item) => sum + item.count, 0)
|
||||||
|
),
|
||||||
|
topLabel: labelGroups.bodyParts?.[0],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'objects',
|
||||||
|
title: 'Objekte',
|
||||||
|
shortTitle: 'Objekte',
|
||||||
|
description: 'Objekt-Labels aus Segmenten und Analyse-Tags.',
|
||||||
|
values: labelGroups.objects ?? [],
|
||||||
|
total: Math.max(
|
||||||
|
1,
|
||||||
|
(labelGroups.objects ?? []).reduce((sum, item) => sum + item.count, 0)
|
||||||
|
),
|
||||||
|
topLabel: labelGroups.objects?.[0],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'clothing',
|
||||||
|
title: 'Kleidung',
|
||||||
|
shortTitle: 'Kleidung',
|
||||||
|
description: 'Kleidungs-Labels aus Segmenten und Analyse-Tags.',
|
||||||
|
values: labelGroups.clothing ?? [],
|
||||||
|
total: Math.max(
|
||||||
|
1,
|
||||||
|
(labelGroups.clothing ?? []).reduce((sum, item) => sum + item.count, 0)
|
||||||
|
),
|
||||||
|
topLabel: labelGroups.clothing?.[0],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const activeTabItem =
|
||||||
|
tabItems.find((item) => item.key === activeTab) ?? tabItems[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="overflow-hidden rounded-2xl border border-gray-200/70 bg-white/80 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/[0.04]">
|
||||||
|
<div className="border-b border-gray-200/70 bg-gray-50/80 px-4 py-3 dark:border-white/10 dark:bg-white/[0.03]">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Profil-Stats
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Wie TrainingStats: Kennzahlen und erkannte Label-Gruppen
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<span className="shrink-0 rounded-full bg-white px-2 py-1 text-[11px] font-medium text-gray-500 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10">
|
||||||
|
Lädt…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="shrink-0 rounded-full bg-white px-2 py-1 text-[11px] font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10">
|
||||||
|
{loadedHint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 p-3 sm:p-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||||||
|
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Downloads
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{fmtInt(stats.totalCount)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
{stats.runningCount > 0
|
||||||
|
? `${fmtInt(stats.runningCount)} laufen`
|
||||||
|
: 'abgeschlossen'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||||||
|
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Laufzeit
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{fmtHms(stats.totalDurationSec)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
gesamte Videodauer
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||||||
|
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Speicher
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{stats.knownBytesCount > 0 ? fmtBytes(stats.totalBytes) : '—'}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
Ø {avgStorage != null ? fmtBytes(avgStorage) : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl bg-gray-50 p-2 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||||||
|
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||||
|
{tabItems.map((item) => {
|
||||||
|
const active = item.key === activeTab
|
||||||
|
const top = item.topLabel
|
||||||
|
const TopIconLabel = top?.iconLabel ?? top?.label ?? item.title
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab(item.key)}
|
||||||
|
className={[
|
||||||
|
'rounded-xl p-2 text-left transition',
|
||||||
|
active
|
||||||
|
? [
|
||||||
|
'bg-white text-indigo-700 shadow-sm ring-1 ring-indigo-200',
|
||||||
|
'dark:bg-indigo-500/18 dark:text-indigo-50 dark:ring-indigo-300/45',
|
||||||
|
].join(' ')
|
||||||
|
: [
|
||||||
|
'text-gray-600 hover:bg-white/70 hover:text-gray-900',
|
||||||
|
'dark:text-gray-300 dark:hover:bg-white/10 dark:hover:text-white',
|
||||||
|
].join(' '),
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1',
|
||||||
|
active
|
||||||
|
? 'bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-white/12 dark:text-indigo-50 dark:ring-white/20'
|
||||||
|
: 'bg-white text-gray-500 ring-gray-200 dark:bg-white/7 dark:text-gray-300 dark:ring-white/10',
|
||||||
|
].join(' ')}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<SegmentLabelIcon
|
||||||
|
label={TopIconLabel}
|
||||||
|
className="h-5 w-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-xs font-semibold">
|
||||||
|
<span className="sm:hidden">{item.shortTitle}</span>
|
||||||
|
<span className="hidden sm:inline">{item.title}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 flex items-center justify-start gap-2">
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'rounded-full px-1.5 py-0.5 text-[9px] font-bold ring-1',
|
||||||
|
active
|
||||||
|
? 'bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-white/10 dark:text-indigo-100 dark:ring-white/20'
|
||||||
|
: 'bg-gray-50 text-gray-600 ring-gray-200 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{fmtInt(item.values.length)} Labels
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{top ? (
|
||||||
|
<span className="hidden truncate text-[10px] text-gray-500 dark:text-gray-400 sm:inline">
|
||||||
|
Top: {top.label}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModelProfileStatsLabelList
|
||||||
|
title={activeTabItem.title}
|
||||||
|
description={activeTabItem.description}
|
||||||
|
values={activeTabItem.values}
|
||||||
|
total={activeTabItem.total}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeModelKey(raw: string | null | undefined): string {
|
function normalizeModelKey(raw: string | null | undefined): string {
|
||||||
let s = String(raw ?? '').trim()
|
let s = String(raw ?? '').trim()
|
||||||
if (!s) return ''
|
if (!s) return ''
|
||||||
@ -684,6 +1165,9 @@ export default function ModelDetails({
|
|||||||
const [done, setDone] = useState<RecordJob[]>([])
|
const [done, setDone] = useState<RecordJob[]>([])
|
||||||
const [doneLoading, setDoneLoading] = useState(false)
|
const [doneLoading, setDoneLoading] = useState(false)
|
||||||
|
|
||||||
|
const [statsDone, setStatsDone] = useState<RecordJob[]>([])
|
||||||
|
const [statsLoading, setStatsLoading] = useState(false)
|
||||||
|
|
||||||
const [running, setRunning] = useState<RecordJob[]>([])
|
const [running, setRunning] = useState<RecordJob[]>([])
|
||||||
const [runningLoading, setRunningLoading] = useState(false)
|
const [runningLoading, setRunningLoading] = useState(false)
|
||||||
|
|
||||||
@ -701,10 +1185,11 @@ export default function ModelDetails({
|
|||||||
|
|
||||||
const [donePage, setDonePage] = useState(1)
|
const [donePage, setDonePage] = useState(1)
|
||||||
const DONE_PAGE_SIZE = 4
|
const DONE_PAGE_SIZE = 4
|
||||||
|
const MODEL_STATS_PAGE_SIZE = 1000
|
||||||
|
|
||||||
const key = normalizeModelKey(modelKey)
|
const key = normalizeModelKey(modelKey)
|
||||||
|
|
||||||
type TabKey = 'info' | 'downloads' | 'running'
|
type TabKey = 'info' | 'stats' | 'downloads' | 'running'
|
||||||
const [tab, setTab] = useState<TabKey>('info')
|
const [tab, setTab] = useState<TabKey>('info')
|
||||||
|
|
||||||
const bioReqRef = useRef<AbortController | null>(null)
|
const bioReqRef = useRef<AbortController | null>(null)
|
||||||
@ -935,12 +1420,46 @@ export default function ModelDetails({
|
|||||||
}
|
}
|
||||||
}, [key, donePage])
|
}, [key, donePage])
|
||||||
|
|
||||||
|
const refetchStatsDone = useCallback(async () => {
|
||||||
|
if (!key) return
|
||||||
|
|
||||||
|
setStatsLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url =
|
||||||
|
`/api/record/done?model=${encodeURIComponent(key)}` +
|
||||||
|
`&page=1&pageSize=${MODEL_STATS_PAGE_SIZE}` +
|
||||||
|
`&sort=completed_desc&includeKeep=1&withCount=1`
|
||||||
|
|
||||||
|
const r = await fetch(url, { cache: 'no-store' })
|
||||||
|
const data = await r.json().catch(() => null)
|
||||||
|
|
||||||
|
const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
|
||||||
|
const count = Number(data?.count ?? items.length)
|
||||||
|
|
||||||
|
setStatsDone(items)
|
||||||
|
|
||||||
|
if (Number.isFinite(count)) {
|
||||||
|
setDoneTotalCount((prev) => Math.max(prev, count))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setStatsLoading(false)
|
||||||
|
}
|
||||||
|
}, [key])
|
||||||
|
|
||||||
const refetchDoneRef = useRef(refetchDone)
|
const refetchDoneRef = useRef(refetchDone)
|
||||||
|
const refetchStatsDoneRef = useRef(refetchStatsDone)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refetchDoneRef.current = refetchDone
|
refetchDoneRef.current = refetchDone
|
||||||
}, [refetchDone])
|
}, [refetchDone])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refetchStatsDoneRef.current = refetchStatsDone
|
||||||
|
}, [refetchStatsDone])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
||||||
@ -952,6 +1471,7 @@ export default function ModelDetails({
|
|||||||
|
|
||||||
const onDone = () => {
|
const onDone = () => {
|
||||||
void refetchDoneRef.current()
|
void refetchDoneRef.current()
|
||||||
|
void refetchStatsDoneRef.current()
|
||||||
}
|
}
|
||||||
|
|
||||||
es.addEventListener('jobs', onJobs)
|
es.addEventListener('jobs', onJobs)
|
||||||
@ -1005,6 +1525,11 @@ export default function ModelDetails({
|
|||||||
void refetchDone()
|
void refetchDone()
|
||||||
}, [open, key, refetchDone])
|
}, [open, key, refetchDone])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !key) return
|
||||||
|
void refetchStatsDone()
|
||||||
|
}, [open, key, refetchStatsDone])
|
||||||
|
|
||||||
// Running jobs
|
// Running jobs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@ -1095,6 +1620,14 @@ export default function ModelDetails({
|
|||||||
})
|
})
|
||||||
}, [runningList, key])
|
}, [runningList, key])
|
||||||
|
|
||||||
|
const profileStats = useMemo(() => {
|
||||||
|
return buildModelProfileStats(
|
||||||
|
statsDone.length > 0 ? statsDone : done,
|
||||||
|
doneTotalCount,
|
||||||
|
runningMatches
|
||||||
|
)
|
||||||
|
}, [statsDone, done, doneTotalCount, runningMatches])
|
||||||
|
|
||||||
const titleName = effectiveRoom?.display_name || model?.modelKey || key || 'Model'
|
const titleName = effectiveRoom?.display_name || model?.modelKey || key || 'Model'
|
||||||
|
|
||||||
const titleUsername = useMemo(() => {
|
const titleUsername = useMemo(() => {
|
||||||
@ -1519,8 +2052,23 @@ export default function ModelDetails({
|
|||||||
|
|
||||||
const tabs: TabItem[] = [
|
const tabs: TabItem[] = [
|
||||||
{ id: 'info', label: 'Info' },
|
{ id: 'info', label: 'Info' },
|
||||||
{ id: 'downloads', label: 'Downloads', count: doneTotalCount ? fmtInt(doneTotalCount) : undefined, disabled: doneLoading },
|
{
|
||||||
{ id: 'running', label: 'Running', count: runningMatches.length ? fmtInt(runningMatches.length) : undefined, disabled: runningLoading },
|
id: 'stats',
|
||||||
|
label: 'Stats',
|
||||||
|
disabled: statsLoading && statsDone.length === 0 && done.length === 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'downloads',
|
||||||
|
label: 'Downloads',
|
||||||
|
count: doneTotalCount ? fmtInt(doneTotalCount) : undefined,
|
||||||
|
disabled: doneLoading,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'running',
|
||||||
|
label: 'Running',
|
||||||
|
count: runningMatches.length ? fmtInt(runningMatches.length) : undefined,
|
||||||
|
disabled: runningLoading,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const mobileFlagBtnBase =
|
const mobileFlagBtnBase =
|
||||||
@ -1596,7 +2144,7 @@ export default function ModelDetails({
|
|||||||
tab === 'info' ? (
|
tab === 'info' ? (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={cn('h-8 px-2 sm:h-9 sm:px-3', 'whitespace-nowrap')}
|
className={cn('h-8 px-3', 'whitespace-nowrap')}
|
||||||
disabled={bioLoading || !modelKey}
|
disabled={bioLoading || !modelKey}
|
||||||
onClick={() => void refreshBio()}
|
onClick={() => void refreshBio()}
|
||||||
title="BioContext neu abrufen"
|
title="BioContext neu abrufen"
|
||||||
@ -2383,7 +2931,7 @@ export default function ModelDetails({
|
|||||||
<div className="min-h-0">
|
<div className="min-h-0">
|
||||||
{/* INFO */}
|
{/* INFO */}
|
||||||
{tab === 'info' ? (
|
{tab === 'info' ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
{/* Subject */}
|
{/* Subject */}
|
||||||
<div className="rounded-lg border border-gray-200/70 bg-white/70 p-3 sm:p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5">
|
<div className="rounded-lg border border-gray-200/70 bg-white/70 p-3 sm:p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
@ -2603,6 +3151,17 @@ export default function ModelDetails({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
||||||
|
{/* STATS */}
|
||||||
|
{tab === 'stats' ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<ModelProfileStatsPanel
|
||||||
|
stats={profileStats}
|
||||||
|
loading={statsLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* DOWNLOADS */}
|
{/* DOWNLOADS */}
|
||||||
{tab === 'downloads' ? (
|
{tab === 'downloads' ? (
|
||||||
<div className="min-h-0">
|
<div className="min-h-0">
|
||||||
|
|||||||
@ -2,7 +2,16 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback, type PointerEvent, type MouseEvent } from 'react'
|
import {
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useCallback,
|
||||||
|
useId,
|
||||||
|
type PointerEvent,
|
||||||
|
type MouseEvent,
|
||||||
|
} from 'react'
|
||||||
|
import type { VideoHeatmapSegment, VideoHeatmapSource } from '../../types'
|
||||||
|
|
||||||
export const PREVIEW_SCRUBBER_HEIGHT_PX = 28
|
export const PREVIEW_SCRUBBER_HEIGHT_PX = 28
|
||||||
|
|
||||||
@ -13,6 +22,8 @@ type Props = {
|
|||||||
onIndexClick?: (index: number) => void
|
onIndexClick?: (index: number) => void
|
||||||
className?: string
|
className?: string
|
||||||
stepSeconds?: number
|
stepSeconds?: number
|
||||||
|
durationSeconds?: number
|
||||||
|
heatmapSegments?: VideoHeatmapSegment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function clamp(n: number, min: number, max: number) {
|
function clamp(n: number, min: number, max: number) {
|
||||||
@ -26,6 +37,76 @@ function formatClock(totalSeconds: number) {
|
|||||||
return `${m}:${String(sec).padStart(2, '0')}`
|
return `${m}:${String(sec).padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HeatCurvePoint = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeatCurveSource = 'neutral' | 'position' | 'clothing'
|
||||||
|
|
||||||
|
type HeatGradientStop = {
|
||||||
|
offset: number
|
||||||
|
source: HeatCurveSource
|
||||||
|
}
|
||||||
|
|
||||||
|
function smoothSeries(values: number[], minValue = 0) {
|
||||||
|
return values.map((_, i) => {
|
||||||
|
let sum = 0
|
||||||
|
let weightSum = 0
|
||||||
|
|
||||||
|
for (let off = -1; off <= 1; off++) {
|
||||||
|
const idx = i + off
|
||||||
|
if (idx < 0 || idx >= values.length) continue
|
||||||
|
|
||||||
|
const w = off === 0 ? 3 : 1
|
||||||
|
sum += values[idx] * w
|
||||||
|
weightSum += w
|
||||||
|
}
|
||||||
|
|
||||||
|
return clamp(sum / Math.max(1, weightSum), minValue, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function heatGradientStyleForSource(source: HeatCurveSource) {
|
||||||
|
switch (source) {
|
||||||
|
case 'position':
|
||||||
|
return { color: '#ef4444', opacity: 0.5 } // rot
|
||||||
|
case 'clothing':
|
||||||
|
return { color: '#3b82f6', opacity: 0.5 } // blau
|
||||||
|
default:
|
||||||
|
return { color: '#ffffff', opacity: 0.5 } // neutral weiß
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCurveSource(source: VideoHeatmapSource | undefined): HeatCurveSource {
|
||||||
|
if (source === 'position') return 'position'
|
||||||
|
if (source === 'clothing') return 'clothing'
|
||||||
|
return 'neutral'
|
||||||
|
}
|
||||||
|
|
||||||
|
function smoothCurvePath(points: HeatCurvePoint[]) {
|
||||||
|
if (points.length === 0) return ''
|
||||||
|
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`
|
||||||
|
|
||||||
|
let d = `M ${points[0].x} ${points[0].y}`
|
||||||
|
|
||||||
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
|
const p0 = points[Math.max(0, i - 1)]
|
||||||
|
const p1 = points[i]
|
||||||
|
const p2 = points[i + 1]
|
||||||
|
const p3 = points[Math.min(points.length - 1, i + 2)]
|
||||||
|
|
||||||
|
const c1x = p1.x + (p2.x - p0.x) / 6
|
||||||
|
const c1y = p1.y + (p2.y - p0.y) / 6
|
||||||
|
const c2x = p2.x - (p3.x - p1.x) / 6
|
||||||
|
const c2y = p2.y - (p3.y - p1.y) / 6
|
||||||
|
|
||||||
|
d += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2.x} ${p2.y}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
export default function PreviewScrubber({
|
export default function PreviewScrubber({
|
||||||
imageCount,
|
imageCount,
|
||||||
activeIndex,
|
activeIndex,
|
||||||
@ -33,8 +114,16 @@ export default function PreviewScrubber({
|
|||||||
onIndexClick,
|
onIndexClick,
|
||||||
className,
|
className,
|
||||||
stepSeconds = 0,
|
stepSeconds = 0,
|
||||||
|
durationSeconds = 0,
|
||||||
|
heatmapSegments = [],
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const rawGradientId = useId()
|
||||||
|
|
||||||
|
const gradientId = useMemo(
|
||||||
|
() => `heat-gradient-${rawGradientId.replace(/[^a-zA-Z0-9_-]/g, '')}`,
|
||||||
|
[rawGradientId]
|
||||||
|
)
|
||||||
|
|
||||||
const rafRef = useRef<number | null>(null)
|
const rafRef = useRef<number | null>(null)
|
||||||
const pendingIndexRef = useRef<number | undefined>(undefined)
|
const pendingIndexRef = useRef<number | undefined>(undefined)
|
||||||
@ -119,10 +208,8 @@ export default function PreviewScrubber({
|
|||||||
[indexFromClientX, onIndexClick]
|
[indexFromClientX, onIndexClick]
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!imageCount || imageCount < 1) return null
|
|
||||||
|
|
||||||
const markerLeftPct =
|
const markerLeftPct =
|
||||||
typeof activeIndex === 'number'
|
typeof activeIndex === 'number' && imageCount > 0
|
||||||
? ((activeIndex + 0.5) / imageCount) * 100
|
? ((activeIndex + 0.5) / imageCount) * 100
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
@ -135,6 +222,160 @@ export default function PreviewScrubber({
|
|||||||
|
|
||||||
const showLabel = typeof activeIndex === 'number'
|
const showLabel = typeof activeIndex === 'number'
|
||||||
|
|
||||||
|
const effectiveDuration =
|
||||||
|
durationSeconds > 0
|
||||||
|
? durationSeconds
|
||||||
|
: stepSeconds > 0 && imageCount > 1
|
||||||
|
? stepSeconds * Math.max(1, imageCount - 1)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const heatCurve = useMemo(() => {
|
||||||
|
if (!imageCount || imageCount < 1) return null
|
||||||
|
if (!Array.isArray(heatmapSegments) || heatmapSegments.length === 0) return null
|
||||||
|
if (!Number.isFinite(effectiveDuration) || effectiveDuration <= 0) return null
|
||||||
|
|
||||||
|
const sampleCount = Math.max(140, Math.min(360, imageCount * 7))
|
||||||
|
const twoPi = Math.PI * 2
|
||||||
|
|
||||||
|
const values = new Array(sampleCount).fill(0.018)
|
||||||
|
const positionValues = new Array(sampleCount).fill(0)
|
||||||
|
const clothingValues = new Array(sampleCount).fill(0)
|
||||||
|
|
||||||
|
for (const segment of heatmapSegments) {
|
||||||
|
const start = clamp(Number(segment.startSec), 0, effectiveDuration)
|
||||||
|
const end = clamp(Number(segment.endSec), 0, effectiveDuration)
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) continue
|
||||||
|
|
||||||
|
const score = clamp(Number(segment.intensity ?? 0), 0, 1)
|
||||||
|
const segmentDuration = Math.max(0.1, end - start)
|
||||||
|
const source = normalizeCurveSource(segment.source)
|
||||||
|
|
||||||
|
const amplitude = 0.24 + score * 1.05
|
||||||
|
|
||||||
|
const waveCount =
|
||||||
|
score >= 0.85 ? 5.4 :
|
||||||
|
score >= 0.65 ? 4.5 :
|
||||||
|
score >= 0.45 ? 3.7 :
|
||||||
|
score >= 0.20 ? 3.0 :
|
||||||
|
2.4
|
||||||
|
|
||||||
|
const fadeSeconds = Math.max(
|
||||||
|
0.35,
|
||||||
|
Math.min(1.35, segmentDuration * 0.12)
|
||||||
|
)
|
||||||
|
|
||||||
|
for (let i = 0; i < sampleCount; i++) {
|
||||||
|
const tSec = (i / Math.max(1, sampleCount - 1)) * effectiveDuration
|
||||||
|
|
||||||
|
if (tSec < start - fadeSeconds || tSec > end + fadeSeconds) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let localT = 0
|
||||||
|
let fade = 1
|
||||||
|
|
||||||
|
if (tSec < start) {
|
||||||
|
const d = (start - tSec) / fadeSeconds
|
||||||
|
localT = 0
|
||||||
|
fade = Math.pow(1 - clamp(d, 0, 1), 3.2)
|
||||||
|
} else if (tSec > end) {
|
||||||
|
const d = (tSec - end) / fadeSeconds
|
||||||
|
localT = 1
|
||||||
|
fade = Math.pow(1 - clamp(d, 0, 1), 3.8)
|
||||||
|
} else {
|
||||||
|
localT = (tSec - start) / segmentDuration
|
||||||
|
fade = Math.pow(Math.sin(Math.PI * clamp(localT, 0, 1)), 0.72)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fade <= 0.001) continue
|
||||||
|
|
||||||
|
const waveA =
|
||||||
|
Math.pow(
|
||||||
|
0.5 + 0.5 * Math.sin(localT * twoPi * waveCount + 0.3),
|
||||||
|
1.45
|
||||||
|
)
|
||||||
|
|
||||||
|
const waveB =
|
||||||
|
Math.pow(
|
||||||
|
0.5 + 0.5 * Math.sin(localT * twoPi * (waveCount * 2.0) + 1.15),
|
||||||
|
2.0
|
||||||
|
)
|
||||||
|
|
||||||
|
const waveC =
|
||||||
|
Math.pow(
|
||||||
|
0.5 + 0.5 * Math.sin(localT * twoPi * (waveCount * 3.15) + 2.0),
|
||||||
|
2.6
|
||||||
|
)
|
||||||
|
|
||||||
|
const wave =
|
||||||
|
0.18 +
|
||||||
|
waveA * 0.46 +
|
||||||
|
waveB * 0.28 +
|
||||||
|
waveC * 0.16
|
||||||
|
|
||||||
|
const nextValue = clamp(0.018 + amplitude * wave * fade, 0, 1)
|
||||||
|
values[i] = Math.max(values[i], nextValue)
|
||||||
|
|
||||||
|
if (source === 'position') {
|
||||||
|
positionValues[i] = Math.max(positionValues[i], nextValue)
|
||||||
|
} else if (source === 'clothing') {
|
||||||
|
clothingValues[i] = Math.max(clothingValues[i], nextValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const smoothed = smoothSeries(values, 0.012)
|
||||||
|
const smoothedPosition = smoothSeries(positionValues, 0)
|
||||||
|
const smoothedClothing = smoothSeries(clothingValues, 0)
|
||||||
|
|
||||||
|
const dominantSources: HeatCurveSource[] = smoothed.map((_, i) => {
|
||||||
|
const p = smoothedPosition[i]
|
||||||
|
const c = smoothedClothing[i]
|
||||||
|
|
||||||
|
if (p <= 0.02 && c <= 0.02) return 'neutral'
|
||||||
|
if (p >= c) return 'position'
|
||||||
|
return 'clothing'
|
||||||
|
})
|
||||||
|
|
||||||
|
const gradientStops: HeatGradientStop[] = []
|
||||||
|
let current = dominantSources[0] ?? 'neutral'
|
||||||
|
gradientStops.push({ offset: 0, source: current })
|
||||||
|
|
||||||
|
for (let i = 1; i < dominantSources.length; i++) {
|
||||||
|
const next = dominantSources[i]
|
||||||
|
if (next === current) continue
|
||||||
|
|
||||||
|
const offset = (i / Math.max(1, dominantSources.length - 1)) * 100
|
||||||
|
|
||||||
|
gradientStops.push({ offset, source: current })
|
||||||
|
gradientStops.push({ offset, source: next })
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
|
||||||
|
gradientStops.push({ offset: 100, source: current })
|
||||||
|
|
||||||
|
const baseY = 30
|
||||||
|
const chartHeight = 29
|
||||||
|
|
||||||
|
const points = smoothed.map((value, i) => {
|
||||||
|
const x = (i / Math.max(1, sampleCount - 1)) * 100
|
||||||
|
const visualValue = 0.055 + Math.pow(clamp(value, 0, 1), 0.82) * 0.98
|
||||||
|
const y = baseY - visualValue * chartHeight
|
||||||
|
return { x, y }
|
||||||
|
})
|
||||||
|
|
||||||
|
const linePath = smoothCurvePath(points)
|
||||||
|
const areaPath = `${linePath} L 100 ${baseY} L 0 ${baseY} Z`
|
||||||
|
|
||||||
|
return {
|
||||||
|
linePath,
|
||||||
|
areaPath,
|
||||||
|
gradientStops,
|
||||||
|
}
|
||||||
|
}, [imageCount, heatmapSegments, effectiveDuration])
|
||||||
|
|
||||||
|
if (!imageCount || imageCount < 1) return null
|
||||||
|
|
||||||
const labelLeftStyle =
|
const labelLeftStyle =
|
||||||
typeof markerLeftPct === 'number'
|
typeof markerLeftPct === 'number'
|
||||||
? {
|
? {
|
||||||
@ -169,10 +410,80 @@ export default function PreviewScrubber({
|
|||||||
aria-valuemax={imageCount}
|
aria-valuemax={imageCount}
|
||||||
aria-valuenow={typeof activeIndex === 'number' ? activeIndex + 1 : undefined}
|
aria-valuenow={typeof activeIndex === 'number' ? activeIndex + 1 : undefined}
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-4 bg-white/35 ring-1 ring-white/40 backdrop-blur-[1px]">
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 overflow-hidden">
|
||||||
|
{heatCurve ? (
|
||||||
|
<div className="absolute inset-x-0 inset-y-0">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 100 32"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
className="h-full w-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id={gradientId}
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="100"
|
||||||
|
y2="0"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
{heatCurve.gradientStops.map((stop, idx) => {
|
||||||
|
const style = heatGradientStyleForSource(stop.source)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<stop
|
||||||
|
key={`${stop.offset}-${stop.source}-${idx}`}
|
||||||
|
offset={`${stop.offset}%`}
|
||||||
|
stopColor={style.color}
|
||||||
|
stopOpacity={style.opacity}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<path
|
||||||
|
d={heatCurve.areaPath}
|
||||||
|
fill={`url(#${gradientId})`}
|
||||||
|
opacity={0.65}
|
||||||
|
style={{
|
||||||
|
mixBlendMode: 'screen',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<path
|
||||||
|
d={heatCurve.linePath}
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(255, 255, 255, 0.98)"
|
||||||
|
strokeWidth="0.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
style={{
|
||||||
|
filter:
|
||||||
|
'drop-shadow(0 1px 1px rgba(0,0,0,0.72)) drop-shadow(0 0 1px rgba(255,255,255,0.35))',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="absolute inset-x-0 bottom-0 z-20 h-[3px]">
|
||||||
|
<div className="absolute inset-0 bg-white/30 shadow-[0_0_0_1px_rgba(0,0,0,0.28)]" />
|
||||||
|
|
||||||
|
{typeof markerLeftPct === 'number' ? (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 h-full bg-red-500 shadow-[0_0_6px_rgba(239,68,68,0.75)]"
|
||||||
|
style={{
|
||||||
|
width: `${markerLeftPct}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{typeof markerLeftPct === 'number' ? (
|
{typeof markerLeftPct === 'number' ? (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-y-0 w-[2px] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.35)]"
|
className="absolute bottom-0 z-30 h-[3px] w-[2px] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.55),0_0_5px_rgba(255,255,255,0.75)]"
|
||||||
style={{
|
style={{
|
||||||
left: `${markerLeftPct}%`,
|
left: `${markerLeftPct}%`,
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
|
|||||||
@ -6,12 +6,27 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type CSSProperties,
|
|
||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import HoverPopover from './HoverPopover'
|
import HoverPopover from './HoverPopover'
|
||||||
import type { RecordJobMeta } from '../../types'
|
import type {
|
||||||
|
RecordJobMeta,
|
||||||
|
RatingSegment,
|
||||||
|
SegmentSpritePreview,
|
||||||
|
} from '../../types'
|
||||||
|
import {
|
||||||
|
RATING_VALUE_LABELS as VALUE_LABELS,
|
||||||
|
isAiLikeLabel,
|
||||||
|
isPersonSegmentLabel,
|
||||||
|
isPersonSegmentObject,
|
||||||
|
normalizeAiLabel,
|
||||||
|
prettyAiLabel,
|
||||||
|
rawAiLabelParts,
|
||||||
|
segmentTitleFromLabel,
|
||||||
|
splitAiComboOrList,
|
||||||
|
tagsFromAiLabel,
|
||||||
|
} from '../../aiLabels'
|
||||||
import {
|
import {
|
||||||
SegmentLabelIcon,
|
SegmentLabelIcon,
|
||||||
getSegmentLabelItem,
|
getSegmentLabelItem,
|
||||||
@ -31,385 +46,6 @@ type RatingEntry = {
|
|||||||
tone?: 'normal' | 'strong' | 'muted'
|
tone?: 'normal' | 'strong' | 'muted'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SegmentSpritePreview = {
|
|
||||||
src: string
|
|
||||||
frameStyle: CSSProperties
|
|
||||||
imageStyle: CSSProperties
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RatingSegment = {
|
|
||||||
key: string
|
|
||||||
title: string
|
|
||||||
timeLabel?: string
|
|
||||||
valueLabel?: string
|
|
||||||
detail?: string
|
|
||||||
tags: string[]
|
|
||||||
iconLabels?: string[]
|
|
||||||
preview?: SegmentSpritePreview
|
|
||||||
openAtSec?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const VALUE_LABELS: Record<string, string> = {
|
|
||||||
stars: 'Sterne',
|
|
||||||
score: 'Score',
|
|
||||||
confidence: 'Konfidenz',
|
|
||||||
probability: 'Wahrscheinlichkeit',
|
|
||||||
nsfw: 'NSFW',
|
|
||||||
adult: 'Adult',
|
|
||||||
explicit: 'Explizit',
|
|
||||||
explicitness: 'Explizitheit',
|
|
||||||
nudity: 'Nacktheit',
|
|
||||||
sexual: 'Sexuell',
|
|
||||||
sexualActivity: 'Sexuelle Aktivität',
|
|
||||||
visibility: 'Sichtbarkeit',
|
|
||||||
quality: 'Qualität',
|
|
||||||
reason: 'Begründung',
|
|
||||||
summary: 'Zusammenfassung',
|
|
||||||
label: 'Label',
|
|
||||||
category: 'Kategorie',
|
|
||||||
model: 'Modell',
|
|
||||||
version: 'Version',
|
|
||||||
}
|
|
||||||
|
|
||||||
const AI_LABEL_FALLBACKS: Record<string, string> = {
|
|
||||||
vulva: 'Vagina',
|
|
||||||
pussy: 'Vagina',
|
|
||||||
penis: 'Penis',
|
|
||||||
anus: 'Anus',
|
|
||||||
ass: 'Hintern',
|
|
||||||
breasts: 'Brüste',
|
|
||||||
buttocks: 'Hintern',
|
|
||||||
tongue: 'Zunge',
|
|
||||||
|
|
||||||
blindfold: 'Augenbinde',
|
|
||||||
buttplug: 'Buttplug',
|
|
||||||
collar: 'Halsband',
|
|
||||||
dildo: 'Dildo',
|
|
||||||
handcuffs: 'Handschellen',
|
|
||||||
shower: 'Dusche',
|
|
||||||
strapon: 'Strapon',
|
|
||||||
towel: 'Handtuch',
|
|
||||||
vibrator: 'Vibrator',
|
|
||||||
|
|
||||||
bikini: 'Bikini',
|
|
||||||
bra: 'BH',
|
|
||||||
dress: 'Kleid',
|
|
||||||
heels: 'High Heels',
|
|
||||||
hotpants: 'Hotpants',
|
|
||||||
lingerie: 'Lingerie',
|
|
||||||
panties: 'Slip',
|
|
||||||
skirt: 'Rock',
|
|
||||||
stockings: 'Strümpfe',
|
|
||||||
croptop: 'Crop Top',
|
|
||||||
|
|
||||||
unknown: 'Unbekannt',
|
|
||||||
missionary: 'Missionary',
|
|
||||||
doggy: 'Doggy',
|
|
||||||
cowgirl: 'Cowgirl',
|
|
||||||
reverse_cowgirl: 'Reverse Cowgirl',
|
|
||||||
cunnilingus: 'Cunnilingus',
|
|
||||||
prone_bone: 'Prone Bone',
|
|
||||||
standing: 'Stehend',
|
|
||||||
standing_doggy: 'Standing Doggy',
|
|
||||||
spooning: 'Spooning',
|
|
||||||
sitting: 'Sitzend',
|
|
||||||
facesitting: 'Facesitting',
|
|
||||||
handjob: 'Handjob',
|
|
||||||
blowjob: 'Blowjob',
|
|
||||||
toy_play: 'Toy Play',
|
|
||||||
fingering: 'Fingering',
|
|
||||||
'69': '69',
|
|
||||||
|
|
||||||
person: 'Person',
|
|
||||||
person_unknown: 'Person',
|
|
||||||
person_male: 'Mann',
|
|
||||||
person_female: 'Frau',
|
|
||||||
}
|
|
||||||
|
|
||||||
const PERSON_SEGMENT_LABELS = new Set([
|
|
||||||
'person',
|
|
||||||
'person_unknown',
|
|
||||||
'person_male',
|
|
||||||
'person_female',
|
|
||||||
'male_person',
|
|
||||||
'female_person',
|
|
||||||
'people_male',
|
|
||||||
'people_female',
|
|
||||||
'frau',
|
|
||||||
'mann',
|
|
||||||
'person',
|
|
||||||
])
|
|
||||||
|
|
||||||
function normalizeSegmentLabel(value: unknown): string {
|
|
||||||
return String(value ?? '')
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/^detector:/, '')
|
|
||||||
.replace(/^body:/, '')
|
|
||||||
.replace(/^object:/, '')
|
|
||||||
.replace(/^clothing:/, '')
|
|
||||||
.replace(/^position:/, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPersonSegmentLabel(value: unknown): boolean {
|
|
||||||
return PERSON_SEGMENT_LABELS.has(normalizeSegmentLabel(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitMaybeComboOrList(value: unknown): string[] {
|
|
||||||
return String(value ?? '')
|
|
||||||
.split(/[+,;|]+/g)
|
|
||||||
.map((part) => part.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNonPersonHighlightLabel(value: unknown): boolean {
|
|
||||||
const parts = splitMaybeComboOrList(value)
|
|
||||||
|
|
||||||
for (const part of parts) {
|
|
||||||
const normalized = normalizeSegmentLabel(part)
|
|
||||||
if (!normalized) continue
|
|
||||||
|
|
||||||
if (!isPersonSegmentLabel(normalized)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
function segmentObjectHasNonPersonContext(obj: Record<string, unknown>): boolean {
|
|
||||||
const values: unknown[] = [
|
|
||||||
obj.tags,
|
|
||||||
obj.labels,
|
|
||||||
obj.classes,
|
|
||||||
obj.flags,
|
|
||||||
obj.summary,
|
|
||||||
obj.reason,
|
|
||||||
obj.description,
|
|
||||||
obj.text,
|
|
||||||
obj.caption,
|
|
||||||
obj.note,
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const value of values) {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
if (value.some((item) => isNonPersonHighlightLabel(item))) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'string' && isNonPersonHighlightLabel(value)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPersonSegmentObject(obj: Record<string, unknown>): boolean {
|
|
||||||
const rawLabel =
|
|
||||||
obj.label ??
|
|
||||||
obj.title ??
|
|
||||||
obj.category ??
|
|
||||||
obj.type ??
|
|
||||||
obj.kind ??
|
|
||||||
obj.name ??
|
|
||||||
''
|
|
||||||
|
|
||||||
// Wenn der Haupttitel keine reine Person ist, behalten.
|
|
||||||
// Beispiel: combo:person_female+object:dildo
|
|
||||||
if (!isPersonSegmentLabel(rawLabel)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wenn es Zusatzkontext gibt, behalten.
|
|
||||||
// Beispiel: label=person_female, tags=[dildo, vibrator]
|
|
||||||
if (segmentObjectHasNonPersonContext(obj)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wirklich nur "Frau", "Mann", "Person" alleine ausblenden.
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAiLikeLabel(value: string): boolean {
|
|
||||||
const s = value.trim()
|
|
||||||
if (!s) return false
|
|
||||||
|
|
||||||
return (
|
|
||||||
s in AI_LABEL_FALLBACKS ||
|
|
||||||
/^[a-z0-9]+(?:_[a-z0-9]+)+$/i.test(s) ||
|
|
||||||
/^[a-z0-9]+(?:-[a-z0-9]+)+$/i.test(s)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function titleCaseLabel(value: string): string {
|
|
||||||
return value
|
|
||||||
.replace(/[_-]+/g, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim()
|
|
||||||
.replace(/\b\w/g, (m) => m.toUpperCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
function prettyAiLabel(value: unknown): string {
|
|
||||||
const raw = String(value ?? '').trim()
|
|
||||||
if (!raw) return ''
|
|
||||||
|
|
||||||
const key = raw.toLowerCase()
|
|
||||||
|
|
||||||
if (key.startsWith('position:')) {
|
|
||||||
return prettyAiLabel(key.slice('position:'.length))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.startsWith('object:')) {
|
|
||||||
return prettyAiLabel(key.slice('object:'.length))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.startsWith('clothing:')) {
|
|
||||||
return prettyAiLabel(key.slice('clothing:'.length))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.startsWith('body:')) {
|
|
||||||
return prettyAiLabel(key.slice('body:'.length))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.startsWith('detector:')) {
|
|
||||||
return prettyAiLabel(key.slice('detector:'.length))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.startsWith('combo:')) {
|
|
||||||
const rest = key.slice('combo:'.length)
|
|
||||||
return rest
|
|
||||||
.split('+')
|
|
||||||
.map((part) => prettyAiLabel(part))
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' + ')
|
|
||||||
}
|
|
||||||
|
|
||||||
const direct = AI_LABEL_FALLBACKS[key]
|
|
||||||
if (direct) return direct
|
|
||||||
|
|
||||||
return isAiLikeLabel(raw) ? titleCaseLabel(raw) : raw
|
|
||||||
}
|
|
||||||
|
|
||||||
function rawSegmentLabelParts(value: unknown): string[] {
|
|
||||||
const raw = String(value ?? '').trim().toLowerCase()
|
|
||||||
if (!raw) return []
|
|
||||||
|
|
||||||
if (raw.startsWith('combo:')) {
|
|
||||||
return raw
|
|
||||||
.slice('combo:'.length)
|
|
||||||
.split('+')
|
|
||||||
.map((part) => part.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
return [raw]
|
|
||||||
}
|
|
||||||
|
|
||||||
function tagsFromSegmentLabel(value: unknown): string[] {
|
|
||||||
const out: string[] = []
|
|
||||||
|
|
||||||
for (const part of rawSegmentLabelParts(value)) {
|
|
||||||
const clean = part.trim()
|
|
||||||
if (!clean) continue
|
|
||||||
|
|
||||||
if (clean.startsWith('position:')) {
|
|
||||||
const label = prettyAiLabel(clean)
|
|
||||||
if (label) out.push(`Position: ${label}`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clean.startsWith('body:')) {
|
|
||||||
const label = prettyAiLabel(clean)
|
|
||||||
if (label) out.push(label)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clean.startsWith('object:')) {
|
|
||||||
const label = prettyAiLabel(clean)
|
|
||||||
if (label) out.push(label)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clean.startsWith('clothing:')) {
|
|
||||||
const label = prettyAiLabel(clean)
|
|
||||||
if (label) out.push(label)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clean.startsWith('detector:')) {
|
|
||||||
const label = prettyAiLabel(clean)
|
|
||||||
if (label) out.push(label)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isKnownFrontendPositionLabel(clean)) {
|
|
||||||
const label = prettyAiLabel(clean)
|
|
||||||
if (label) out.push(`Position: ${label}`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = prettyAiLabel(clean)
|
|
||||||
if (label) out.push(label)
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function isKnownFrontendPositionLabel(value: unknown): boolean {
|
|
||||||
const label = String(value ?? '').trim().toLowerCase()
|
|
||||||
|
|
||||||
return [
|
|
||||||
'missionary',
|
|
||||||
'doggy',
|
|
||||||
'doggystyle',
|
|
||||||
'cowgirl',
|
|
||||||
'reverse_cowgirl',
|
|
||||||
'cunnilingus',
|
|
||||||
'prone_bone',
|
|
||||||
'standing',
|
|
||||||
'standing_doggy',
|
|
||||||
'spooning',
|
|
||||||
'sitting',
|
|
||||||
'facesitting',
|
|
||||||
'handjob',
|
|
||||||
'blowjob',
|
|
||||||
'toy_play',
|
|
||||||
'fingering',
|
|
||||||
'69',
|
|
||||||
].includes(label)
|
|
||||||
}
|
|
||||||
|
|
||||||
function segmentTitleFromRawLabel(raw: string): string {
|
|
||||||
const parts = rawSegmentLabelParts(raw)
|
|
||||||
|
|
||||||
if (parts.length <= 1) {
|
|
||||||
return prettyAiLabel(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
const positionPart = parts.find((part) => {
|
|
||||||
const clean = normalizeSegmentLabel(part)
|
|
||||||
return part.startsWith('position:') || isKnownFrontendPositionLabel(clean)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (positionPart) {
|
|
||||||
return prettyAiLabel(positionPart)
|
|
||||||
}
|
|
||||||
|
|
||||||
const objectPart = parts.find((part) => part.startsWith('object:'))
|
|
||||||
if (objectPart) return prettyAiLabel(objectPart)
|
|
||||||
|
|
||||||
const bodyPart = parts.find((part) => part.startsWith('body:'))
|
|
||||||
if (bodyPart) return prettyAiLabel(bodyPart)
|
|
||||||
|
|
||||||
const clothingPart = parts.find((part) => part.startsWith('clothing:'))
|
|
||||||
if (clothingPart) return prettyAiLabel(clothingPart)
|
|
||||||
|
|
||||||
return prettyAiLabel(parts[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSegmentTags(obj: Record<string, unknown>): string[] {
|
function readSegmentTags(obj: Record<string, unknown>): string[] {
|
||||||
const position = typeof obj.position === 'string' && obj.position.trim()
|
const position = typeof obj.position === 'string' && obj.position.trim()
|
||||||
? `Position: ${prettyAiLabel(obj.position)}`
|
? `Position: ${prettyAiLabel(obj.position)}`
|
||||||
@ -417,7 +53,7 @@ function readSegmentTags(obj: Record<string, unknown>): string[] {
|
|||||||
|
|
||||||
const tags = [
|
const tags = [
|
||||||
position,
|
position,
|
||||||
...tagsFromSegmentLabel(obj.label ?? obj.title ?? obj.category ?? obj.type ?? obj.kind ?? obj.name),
|
...tagsFromAiLabel(obj.label ?? obj.title ?? obj.category ?? obj.type ?? obj.kind ?? obj.name),
|
||||||
...readTags(obj),
|
...readTags(obj),
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1207,7 +843,7 @@ function readSegmentArray(metaRaw: unknown): unknown[] {
|
|||||||
])
|
])
|
||||||
|
|
||||||
const key = [
|
const key = [
|
||||||
normalizeSegmentLabel(label),
|
tagsFromAiLabel(label).join('+'),
|
||||||
typeof start === 'number' ? start.toFixed(1) : '',
|
typeof start === 'number' ? start.toFixed(1) : '',
|
||||||
typeof end === 'number' ? end.toFixed(1) : '',
|
typeof end === 'number' ? end.toFixed(1) : '',
|
||||||
].join('|')
|
].join('|')
|
||||||
@ -1237,10 +873,10 @@ function iconLabelsFromRawValue(value: unknown): string[] {
|
|||||||
if (!raw) return []
|
if (!raw) return []
|
||||||
|
|
||||||
if (raw.toLowerCase().startsWith('combo:')) {
|
if (raw.toLowerCase().startsWith('combo:')) {
|
||||||
return rawSegmentLabelParts(raw)
|
return rawAiLabelParts(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
return splitMaybeComboOrList(raw)
|
return splitAiComboOrList(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
function readSegmentIconLabels(obj: Record<string, unknown>): string[] {
|
function readSegmentIconLabels(obj: Record<string, unknown>): string[] {
|
||||||
@ -1270,7 +906,7 @@ function readSegmentIconLabels(obj: Record<string, unknown>): string[] {
|
|||||||
const clean = String(label ?? '').trim()
|
const clean = String(label ?? '').trim()
|
||||||
if (!clean) continue
|
if (!clean) continue
|
||||||
|
|
||||||
const key = normalizeSegmentLabel(clean)
|
const key = normalizeAiLabel(clean)
|
||||||
if (!key || seen.has(key)) continue
|
if (!key || seen.has(key)) continue
|
||||||
|
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
@ -1322,7 +958,7 @@ export function readRatingSegments(metaRaw: unknown): RatingSegment[] {
|
|||||||
firstString(raw, ['title', 'label', 'category', 'type', 'kind', 'name']) ??
|
firstString(raw, ['title', 'label', 'category', 'type', 'kind', 'name']) ??
|
||||||
`Segment ${index + 1}`
|
`Segment ${index + 1}`
|
||||||
|
|
||||||
const title = segmentTitleFromRawLabel(rawTitle)
|
const title = segmentTitleFromLabel(rawTitle)
|
||||||
|
|
||||||
const rawDetail =
|
const rawDetail =
|
||||||
firstString(raw, ['summary', 'reason', 'description', 'text', 'caption', 'note']) ??
|
firstString(raw, ['summary', 'reason', 'description', 'text', 'caption', 'note']) ??
|
||||||
|
|||||||
@ -42,11 +42,17 @@ export type SwipeCardProps = {
|
|||||||
onTap?: () => void
|
onTap?: () => void
|
||||||
onSwipeLeft: () => boolean | void | Promise<boolean | void>
|
onSwipeLeft: () => boolean | void | Promise<boolean | void>
|
||||||
onSwipeRight: () => boolean | void | Promise<boolean | void>
|
onSwipeRight: () => boolean | void | Promise<boolean | void>
|
||||||
|
onSwipeUp?: () => boolean | void | Promise<boolean | void>
|
||||||
|
onSwipeDown?: () => boolean | void | Promise<boolean | void>
|
||||||
className?: string
|
className?: string
|
||||||
leftAction?: SwipeAction
|
leftAction?: SwipeAction
|
||||||
rightAction?: SwipeAction
|
rightAction?: SwipeAction
|
||||||
|
upAction?: SwipeAction
|
||||||
|
downAction?: SwipeAction
|
||||||
thresholdPx?: number
|
thresholdPx?: number
|
||||||
thresholdRatio?: number
|
thresholdRatio?: number
|
||||||
|
verticalThresholdPx?: number
|
||||||
|
verticalThresholdRatio?: number
|
||||||
snapMs?: number
|
snapMs?: number
|
||||||
commitMs?: number
|
commitMs?: number
|
||||||
ignoreFromBottomPx?: number
|
ignoreFromBottomPx?: number
|
||||||
@ -65,6 +71,8 @@ export type SwipeCardProps = {
|
|||||||
export type SwipeCardHandle = {
|
export type SwipeCardHandle = {
|
||||||
swipeLeft: (opts?: { runAction?: boolean }) => Promise<boolean>
|
swipeLeft: (opts?: { runAction?: boolean }) => Promise<boolean>
|
||||||
swipeRight: (opts?: { runAction?: boolean }) => Promise<boolean>
|
swipeRight: (opts?: { runAction?: boolean }) => Promise<boolean>
|
||||||
|
swipeUp: (opts?: { runAction?: boolean }) => Promise<boolean>
|
||||||
|
swipeDown: (opts?: { runAction?: boolean }) => Promise<boolean>
|
||||||
reset: () => void
|
reset: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,6 +82,9 @@ const FLICK_VELOCITY = 0.55
|
|||||||
const MAX_OVERSWIPE_PX = 64
|
const MAX_OVERSWIPE_PX = 64
|
||||||
const ROTATE_MAX_DEG = 5
|
const ROTATE_MAX_DEG = 5
|
||||||
const SCALE_MIN = 0.992
|
const SCALE_MIN = 0.992
|
||||||
|
type CommitDir = 'left' | 'right' | 'up' | 'down'
|
||||||
|
|
||||||
|
const VERTICAL_FLICK_VELOCITY = 0.55
|
||||||
|
|
||||||
function clamp(v: number, min: number, max: number) {
|
function clamp(v: number, min: number, max: number) {
|
||||||
return Math.max(min, Math.min(max, v))
|
return Math.max(min, Math.min(max, v))
|
||||||
@ -101,9 +112,17 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
|||||||
onTap,
|
onTap,
|
||||||
onSwipeLeft,
|
onSwipeLeft,
|
||||||
onSwipeRight,
|
onSwipeRight,
|
||||||
|
onSwipeUp,
|
||||||
|
onSwipeDown,
|
||||||
className,
|
className,
|
||||||
|
leftAction,
|
||||||
|
rightAction,
|
||||||
|
upAction = { label: 'Öffnen' },
|
||||||
|
downAction = { label: 'Weiter' },
|
||||||
thresholdPx = 140,
|
thresholdPx = 140,
|
||||||
thresholdRatio = 0.28,
|
thresholdRatio = 0.28,
|
||||||
|
verticalThresholdPx = 110,
|
||||||
|
verticalThresholdRatio = 0.24,
|
||||||
ignoreFromBottomPx = 72,
|
ignoreFromBottomPx = 72,
|
||||||
ignoreSelector = '[data-swipe-ignore]',
|
ignoreSelector = '[data-swipe-ignore]',
|
||||||
snapMs = 220,
|
snapMs = 220,
|
||||||
@ -129,14 +148,22 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
|||||||
|
|
||||||
const rafRef = useRef<number | null>(null)
|
const rafRef = useRef<number | null>(null)
|
||||||
const dxRef = useRef(0)
|
const dxRef = useRef(0)
|
||||||
|
const dyRef = useRef(0)
|
||||||
|
|
||||||
const thresholdRef = useRef(0)
|
const thresholdRef = useRef(0)
|
||||||
|
const verticalThresholdRef = useRef(0)
|
||||||
|
|
||||||
const widthRef = useRef(360)
|
const widthRef = useRef(360)
|
||||||
|
const heightRef = useRef(520)
|
||||||
|
|
||||||
const tapTimerRef = useRef<number | null>(null)
|
const tapTimerRef = useRef<number | null>(null)
|
||||||
const lastTapRef = useRef<{ t: number; x: number; y: number } | null>(null)
|
const lastTapRef = useRef<{ t: number; x: number; y: number } | null>(null)
|
||||||
|
|
||||||
const velocityXRef = useRef(0)
|
const velocityXRef = useRef(0)
|
||||||
|
const velocityYRef = useRef(0)
|
||||||
|
|
||||||
const lastMoveXRef = useRef(0)
|
const lastMoveXRef = useRef(0)
|
||||||
|
const lastMoveYRef = useRef(0)
|
||||||
const lastMoveTRef = useRef(0)
|
const lastMoveTRef = useRef(0)
|
||||||
|
|
||||||
const pointer = useRef<{
|
const pointer = useRef<{
|
||||||
@ -160,9 +187,13 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
|||||||
})
|
})
|
||||||
|
|
||||||
const [dx, setDx] = useState(0)
|
const [dx, setDx] = useState(0)
|
||||||
const [armedDir, setArmedDir] = useState<null | 'left' | 'right'>(null)
|
const [dy, setDy] = useState(0)
|
||||||
|
const [armedDir, setArmedDir] = useState<null | CommitDir>(null)
|
||||||
const [animMs, setAnimMs] = useState(0)
|
const [animMs, setAnimMs] = useState(0)
|
||||||
|
|
||||||
|
const hasVerticalSwipe = Boolean(onSwipeUp || onSwipeDown)
|
||||||
|
const baseTouchAction = hasVerticalSwipe ? 'none' : 'pan-y'
|
||||||
|
|
||||||
const clearTapTimer = useCallback(() => {
|
const clearTapTimer = useCallback(() => {
|
||||||
if (tapTimerRef.current != null) {
|
if (tapTimerRef.current != null) {
|
||||||
window.clearTimeout(tapTimerRef.current)
|
window.clearTimeout(tapTimerRef.current)
|
||||||
@ -186,12 +217,16 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
|||||||
onPressEnd?.()
|
onPressEnd?.()
|
||||||
}, [clearPressTimer, onPressEnd])
|
}, [clearPressTimer, onPressEnd])
|
||||||
|
|
||||||
const flushDx = useCallback((nextDx: number) => {
|
const flushDrag = useCallback((nextDx: number, nextDy: number) => {
|
||||||
dxRef.current = nextDx
|
dxRef.current = nextDx
|
||||||
|
dyRef.current = nextDy
|
||||||
|
|
||||||
if (rafRef.current != null) return
|
if (rafRef.current != null) return
|
||||||
|
|
||||||
rafRef.current = requestAnimationFrame(() => {
|
rafRef.current = requestAnimationFrame(() => {
|
||||||
rafRef.current = null
|
rafRef.current = null
|
||||||
setDx(dxRef.current)
|
setDx(dxRef.current)
|
||||||
|
setDy(dyRef.current)
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@ -205,66 +240,107 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
|||||||
}
|
}
|
||||||
|
|
||||||
dxRef.current = 0
|
dxRef.current = 0
|
||||||
|
dyRef.current = 0
|
||||||
|
|
||||||
velocityXRef.current = 0
|
velocityXRef.current = 0
|
||||||
|
velocityYRef.current = 0
|
||||||
|
|
||||||
if (cardRef.current) {
|
if (cardRef.current) {
|
||||||
cardRef.current.style.touchAction = 'pan-y'
|
cardRef.current.style.touchAction = baseTouchAction
|
||||||
}
|
}
|
||||||
|
|
||||||
setAnimMs(snapMs)
|
setAnimMs(snapMs)
|
||||||
setDx(0)
|
setDx(0)
|
||||||
|
setDy(0)
|
||||||
setArmedDir(null)
|
setArmedDir(null)
|
||||||
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
setAnimMs(0)
|
setAnimMs(0)
|
||||||
}, snapMs)
|
}, snapMs)
|
||||||
}, [snapMs, clearPressTimer])
|
}, [snapMs, clearPressTimer, baseTouchAction])
|
||||||
|
|
||||||
const commit = useCallback(
|
const commit = useCallback(
|
||||||
async (dir: 'left' | 'right', runAction: boolean) => {
|
async (dir: CommitDir, runAction: boolean) => {
|
||||||
if (rafRef.current != null) {
|
if (rafRef.current != null) {
|
||||||
cancelAnimationFrame(rafRef.current)
|
cancelAnimationFrame(rafRef.current)
|
||||||
rafRef.current = null
|
rafRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cardRef.current) {
|
if (cardRef.current) {
|
||||||
cardRef.current.style.touchAction = 'pan-y'
|
cardRef.current.style.touchAction = baseTouchAction
|
||||||
}
|
}
|
||||||
|
|
||||||
const w = cardRef.current?.offsetWidth || widthRef.current || 360
|
const w = cardRef.current?.offsetWidth || widthRef.current || 360
|
||||||
const outDx = dir === 'right' ? w + 56 : -(w + 56)
|
const h = cardRef.current?.offsetHeight || heightRef.current || 520
|
||||||
|
|
||||||
|
const out =
|
||||||
|
dir === 'right'
|
||||||
|
? { x: w + 56, y: 0 }
|
||||||
|
: dir === 'left'
|
||||||
|
? { x: -(w + 56), y: 0 }
|
||||||
|
: dir === 'up'
|
||||||
|
? { x: 0, y: -(h * 0.72 + 56) }
|
||||||
|
: { x: 0, y: h * 0.48 + 56 }
|
||||||
|
|
||||||
setAnimMs(commitMs)
|
setAnimMs(commitMs)
|
||||||
setArmedDir(dir)
|
setArmedDir(dir)
|
||||||
dxRef.current = outDx
|
|
||||||
setDx(outDx)
|
dxRef.current = out.x
|
||||||
|
dyRef.current = out.y
|
||||||
|
|
||||||
|
setDx(out.x)
|
||||||
|
setDy(out.y)
|
||||||
|
|
||||||
const animPromise = new Promise<void>((resolve) => {
|
const animPromise = new Promise<void>((resolve) => {
|
||||||
window.setTimeout(resolve, commitMs)
|
window.setTimeout(resolve, commitMs)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Wichtig: erst rausanimieren lassen, dann Aktion starten
|
|
||||||
await animPromise
|
await animPromise
|
||||||
|
|
||||||
let ok: boolean | void = true
|
let ok: boolean | void = true
|
||||||
|
|
||||||
if (runAction) {
|
if (runAction) {
|
||||||
ok = await Promise.resolve(
|
const action =
|
||||||
dir === 'right' ? onSwipeRight() : onSwipeLeft()
|
dir === 'right'
|
||||||
).catch(() => false)
|
? onSwipeRight
|
||||||
|
: dir === 'left'
|
||||||
|
? onSwipeLeft
|
||||||
|
: dir === 'up'
|
||||||
|
? onSwipeUp
|
||||||
|
: onSwipeDown
|
||||||
|
|
||||||
|
if (!action) {
|
||||||
|
ok = false
|
||||||
|
} else {
|
||||||
|
ok = await Promise.resolve(action()).catch(() => false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ok === false) {
|
if (ok === false) {
|
||||||
setAnimMs(snapMs)
|
setAnimMs(snapMs)
|
||||||
setArmedDir(null)
|
setArmedDir(null)
|
||||||
setDx(0)
|
|
||||||
dxRef.current = 0
|
dxRef.current = 0
|
||||||
|
dyRef.current = 0
|
||||||
|
|
||||||
|
setDx(0)
|
||||||
|
setDy(0)
|
||||||
|
|
||||||
window.setTimeout(() => setAnimMs(0), snapMs)
|
window.setTimeout(() => setAnimMs(0), snapMs)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
[commitMs, onSwipeLeft, onSwipeRight, snapMs]
|
[
|
||||||
|
commitMs,
|
||||||
|
onSwipeLeft,
|
||||||
|
onSwipeRight,
|
||||||
|
onSwipeUp,
|
||||||
|
onSwipeDown,
|
||||||
|
snapMs,
|
||||||
|
baseTouchAction,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
const softResetForTap = useCallback(() => {
|
const softResetForTap = useCallback(() => {
|
||||||
@ -274,17 +350,20 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
|||||||
}
|
}
|
||||||
|
|
||||||
dxRef.current = 0
|
dxRef.current = 0
|
||||||
|
dyRef.current = 0
|
||||||
|
|
||||||
setAnimMs(0)
|
setAnimMs(0)
|
||||||
setDx(0)
|
setDx(0)
|
||||||
|
setDy(0)
|
||||||
setArmedDir(null)
|
setArmedDir(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const el = cardRef.current
|
const el = cardRef.current
|
||||||
if (el) el.style.touchAction = 'pan-y'
|
if (el) el.style.touchAction = baseTouchAction
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}, [])
|
}, [baseTouchAction])
|
||||||
|
|
||||||
const runHotFx = useCallback(
|
const runHotFx = useCallback(
|
||||||
(clientX?: number, clientY?: number) => {
|
(clientX?: number, clientY?: number) => {
|
||||||
@ -413,21 +492,67 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
|||||||
() => ({
|
() => ({
|
||||||
swipeLeft: (opts) => commit('left', opts?.runAction ?? true),
|
swipeLeft: (opts) => commit('left', opts?.runAction ?? true),
|
||||||
swipeRight: (opts) => commit('right', opts?.runAction ?? true),
|
swipeRight: (opts) => commit('right', opts?.runAction ?? true),
|
||||||
|
swipeUp: (opts) => commit('up', opts?.runAction ?? true),
|
||||||
|
swipeDown: (opts) => commit('down', opts?.runAction ?? true),
|
||||||
reset: () => reset(),
|
reset: () => reset(),
|
||||||
}),
|
}),
|
||||||
[commit, reset]
|
[commit, reset]
|
||||||
)
|
)
|
||||||
|
|
||||||
const absDx = Math.abs(dx)
|
const absDx = Math.abs(dx)
|
||||||
const swipeDir: 'left' | 'right' | null = dx === 0 ? null : dx > 0 ? 'right' : 'left'
|
const absDy = Math.abs(dy)
|
||||||
|
|
||||||
|
const swipeDir: CommitDir | null =
|
||||||
|
absDx === 0 && absDy === 0
|
||||||
|
? null
|
||||||
|
: absDy > absDx
|
||||||
|
? dy > 0
|
||||||
|
? 'down'
|
||||||
|
: 'up'
|
||||||
|
: dx > 0
|
||||||
|
? 'right'
|
||||||
|
: 'left'
|
||||||
|
|
||||||
const activeThreshold =
|
const activeThreshold =
|
||||||
thresholdRef.current || Math.min(thresholdPx, (cardRef.current?.offsetWidth || 360) * thresholdRatio)
|
thresholdRef.current ||
|
||||||
|
Math.min(thresholdPx, (cardRef.current?.offsetWidth || 360) * thresholdRatio)
|
||||||
|
|
||||||
const reveal = clamp(absDx / Math.max(1, activeThreshold), 0, 1)
|
const activeVerticalThreshold =
|
||||||
const revealSoft = clamp(absDx / Math.max(1, activeThreshold * 1.35), 0, 1)
|
verticalThresholdRef.current ||
|
||||||
|
Math.min(
|
||||||
|
verticalThresholdPx,
|
||||||
|
(cardRef.current?.offsetHeight || 520) * verticalThresholdRatio
|
||||||
|
)
|
||||||
|
|
||||||
const tiltDeg = clamp(dx / 34, -ROTATE_MAX_DEG, ROTATE_MAX_DEG)
|
const reveal =
|
||||||
const dragScale = dx === 0 ? 1 : Math.max(SCALE_MIN, 1 - revealSoft * 0.008)
|
swipeDir === 'up' || swipeDir === 'down'
|
||||||
|
? clamp(absDy / Math.max(1, activeVerticalThreshold), 0, 1)
|
||||||
|
: clamp(absDx / Math.max(1, activeThreshold), 0, 1)
|
||||||
|
|
||||||
|
const revealSoft =
|
||||||
|
swipeDir === 'up' || swipeDir === 'down'
|
||||||
|
? clamp(absDy / Math.max(1, activeVerticalThreshold * 1.35), 0, 1)
|
||||||
|
: clamp(absDx / Math.max(1, activeThreshold * 1.35), 0, 1)
|
||||||
|
|
||||||
|
const tiltDeg = swipeDir === 'up' || swipeDir === 'down'
|
||||||
|
? clamp(dx / 80, -2, 2)
|
||||||
|
: clamp(dx / 34, -ROTATE_MAX_DEG, ROTATE_MAX_DEG)
|
||||||
|
|
||||||
|
const dragScale =
|
||||||
|
absDx === 0 && absDy === 0
|
||||||
|
? 1
|
||||||
|
: Math.max(SCALE_MIN, 1 - revealSoft * 0.008)
|
||||||
|
|
||||||
|
const activeAction =
|
||||||
|
armedDir === 'left'
|
||||||
|
? leftAction ?? { label: 'Löschen' }
|
||||||
|
: armedDir === 'right'
|
||||||
|
? rightAction ?? { label: 'Behalten' }
|
||||||
|
: armedDir === 'up'
|
||||||
|
? upAction
|
||||||
|
: armedDir === 'down'
|
||||||
|
? downAction
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={outerRef} className={cn('relative isolate overflow-visible rounded-lg', className)}>
|
<div ref={outerRef} className={cn('relative isolate overflow-visible rounded-lg', className)}>
|
||||||
@ -436,16 +561,19 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
|||||||
className="relative"
|
className="relative"
|
||||||
style={{
|
style={{
|
||||||
transform:
|
transform:
|
||||||
dx !== 0
|
dx !== 0 || dy !== 0
|
||||||
? `translate3d(${dx}px,0,0) rotate(${tiltDeg}deg) scale(${dragScale})`
|
? `translate3d(${dx}px,${dy}px,0) rotate(${tiltDeg}deg) scale(${dragScale})`
|
||||||
: undefined,
|
: undefined,
|
||||||
transition: animMs
|
transition: animMs
|
||||||
? `transform ${animMs}ms cubic-bezier(0.22, 1, 0.36, 1)`
|
? `transform ${animMs}ms cubic-bezier(0.22, 1, 0.36, 1)`
|
||||||
: undefined,
|
: undefined,
|
||||||
touchAction: 'pan-y',
|
touchAction: baseTouchAction,
|
||||||
willChange: dx !== 0 ? 'transform' : undefined,
|
willChange: dx !== 0 || dy !== 0 ? 'transform' : undefined,
|
||||||
borderRadius: dx !== 0 ? '12px' : undefined,
|
borderRadius: dx !== 0 || dy !== 0 ? '12px' : undefined,
|
||||||
filter: dx !== 0 ? `saturate(${1 + reveal * 0.06}) brightness(${1 + reveal * 0.015})` : undefined,
|
filter:
|
||||||
|
dx !== 0 || dy !== 0
|
||||||
|
? `saturate(${1 + reveal * 0.06}) brightness(${1 + reveal * 0.015})`
|
||||||
|
: undefined,
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
if (!enabled || disabled) return
|
if (!enabled || disabled) return
|
||||||
@ -523,8 +651,13 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
|||||||
}
|
}
|
||||||
|
|
||||||
const w = cardRef.current?.offsetWidth || 360
|
const w = cardRef.current?.offsetWidth || 360
|
||||||
|
const h = cardRef.current?.offsetHeight || 520
|
||||||
|
|
||||||
widthRef.current = w
|
widthRef.current = w
|
||||||
|
heightRef.current = h
|
||||||
|
|
||||||
thresholdRef.current = Math.min(thresholdPx, w * thresholdRatio)
|
thresholdRef.current = Math.min(thresholdPx, w * thresholdRatio)
|
||||||
|
verticalThresholdRef.current = Math.min(verticalThresholdPx, h * verticalThresholdRatio)
|
||||||
|
|
||||||
pointer.current = {
|
pointer.current = {
|
||||||
id: e.pointerId,
|
id: e.pointerId,
|
||||||
@ -538,9 +671,14 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastMoveXRef.current = e.clientX
|
lastMoveXRef.current = e.clientX
|
||||||
|
lastMoveYRef.current = e.clientY
|
||||||
lastMoveTRef.current = performance.now()
|
lastMoveTRef.current = performance.now()
|
||||||
|
|
||||||
velocityXRef.current = 0
|
velocityXRef.current = 0
|
||||||
|
velocityYRef.current = 0
|
||||||
|
|
||||||
dxRef.current = 0
|
dxRef.current = 0
|
||||||
|
dyRef.current = 0
|
||||||
}}
|
}}
|
||||||
onPointerMove={(e) => {
|
onPointerMove={(e) => {
|
||||||
if (!enabled || disabled) return
|
if (!enabled || disabled) return
|
||||||
@ -558,19 +696,24 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
|||||||
|
|
||||||
if (absY > absX * LOCK_RATIO) {
|
if (absY > absX * LOCK_RATIO) {
|
||||||
clearPressTimer()
|
clearPressTimer()
|
||||||
pointer.current.axisLocked = 'y'
|
|
||||||
pointer.current.id = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (absX > absY * LOCK_RATIO) {
|
if (!hasVerticalSwipe) {
|
||||||
|
pointer.current.axisLocked = 'y'
|
||||||
|
pointer.current.id = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pointer.current.axisLocked = 'y'
|
||||||
|
} else if (absX > absY * LOCK_RATIO) {
|
||||||
pointer.current.axisLocked = 'x'
|
pointer.current.axisLocked = 'x'
|
||||||
} else {
|
} else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pointer.current.axisLocked !== 'x') return
|
if (pointer.current.axisLocked !== 'x' && pointer.current.axisLocked !== 'y') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!pointer.current.dragging) {
|
if (!pointer.current.dragging) {
|
||||||
clearPressTimer()
|
clearPressTimer()
|
||||||
@ -588,28 +731,69 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
|||||||
|
|
||||||
const now = performance.now()
|
const now = performance.now()
|
||||||
const dt = Math.max(1, now - lastMoveTRef.current)
|
const dt = Math.max(1, now - lastMoveTRef.current)
|
||||||
const vx = (e.clientX - lastMoveXRef.current) / dt
|
|
||||||
velocityXRef.current = vx
|
velocityXRef.current = (e.clientX - lastMoveXRef.current) / dt
|
||||||
|
velocityYRef.current = (e.clientY - lastMoveYRef.current) / dt
|
||||||
|
|
||||||
lastMoveXRef.current = e.clientX
|
lastMoveXRef.current = e.clientX
|
||||||
|
lastMoveYRef.current = e.clientY
|
||||||
lastMoveTRef.current = now
|
lastMoveTRef.current = now
|
||||||
|
|
||||||
const next = rubberBand(ddx, widthRef.current)
|
if (pointer.current.axisLocked === 'x') {
|
||||||
flushDx(next)
|
const next = rubberBand(ddx, widthRef.current)
|
||||||
|
flushDrag(next, 0)
|
||||||
|
|
||||||
const threshold = thresholdRef.current
|
const threshold = thresholdRef.current
|
||||||
const nextDir = next > threshold ? 'right' : next < -threshold ? 'left' : null
|
const nextDir =
|
||||||
|
next > threshold
|
||||||
|
? 'right'
|
||||||
|
: next < -threshold
|
||||||
|
? 'left'
|
||||||
|
: null
|
||||||
|
|
||||||
setArmedDir((prev) => {
|
setArmedDir((prev) => {
|
||||||
if (prev === nextDir) return prev
|
if (prev === nextDir) return prev
|
||||||
if (nextDir) {
|
|
||||||
try {
|
if (nextDir) {
|
||||||
navigator.vibrate?.(10)
|
try {
|
||||||
} catch {
|
navigator.vibrate?.(10)
|
||||||
// ignore
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return nextDir
|
return nextDir
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pointer.current.axisLocked === 'y') {
|
||||||
|
const next = rubberBand(ddy, heightRef.current)
|
||||||
|
flushDrag(0, next)
|
||||||
|
|
||||||
|
const threshold = verticalThresholdRef.current
|
||||||
|
const nextDir =
|
||||||
|
next > threshold && onSwipeDown
|
||||||
|
? 'down'
|
||||||
|
: next < -threshold && onSwipeUp
|
||||||
|
? 'up'
|
||||||
|
: null
|
||||||
|
|
||||||
|
setArmedDir((prev) => {
|
||||||
|
if (prev === nextDir) return prev
|
||||||
|
|
||||||
|
if (nextDir) {
|
||||||
|
try {
|
||||||
|
navigator.vibrate?.(10)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextDir
|
||||||
|
})
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onPointerUp={(e) => {
|
onPointerUp={(e) => {
|
||||||
if (!enabled || disabled) return
|
if (!enabled || disabled) return
|
||||||
@ -711,19 +895,50 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
|||||||
}
|
}
|
||||||
|
|
||||||
const finalDx = dxRef.current
|
const finalDx = dxRef.current
|
||||||
const abs = Math.abs(finalDx)
|
const finalDy = dyRef.current
|
||||||
|
|
||||||
|
const absX = Math.abs(finalDx)
|
||||||
|
const absY = Math.abs(finalDy)
|
||||||
|
|
||||||
const vx = velocityXRef.current
|
const vx = velocityXRef.current
|
||||||
|
const vy = velocityYRef.current
|
||||||
|
|
||||||
const fastRight = vx > FLICK_VELOCITY
|
const fastRight = vx > FLICK_VELOCITY
|
||||||
const fastLeft = vx < -FLICK_VELOCITY
|
const fastLeft = vx < -FLICK_VELOCITY
|
||||||
|
const fastDown = vy > VERTICAL_FLICK_VELOCITY
|
||||||
|
const fastUp = vy < -VERTICAL_FLICK_VELOCITY
|
||||||
|
|
||||||
dxRef.current = 0
|
dxRef.current = 0
|
||||||
|
dyRef.current = 0
|
||||||
|
|
||||||
if (finalDx > threshold || (fastRight && abs >= threshold * 0.5)) {
|
const verticalThreshold =
|
||||||
|
verticalThresholdRef.current ||
|
||||||
|
Math.min(verticalThresholdPx, heightRef.current * verticalThresholdRatio)
|
||||||
|
|
||||||
|
if (hasVerticalSwipe && absY > absX) {
|
||||||
|
if (
|
||||||
|
onSwipeDown &&
|
||||||
|
(finalDy > verticalThreshold || (fastDown && absY >= verticalThreshold * 0.5))
|
||||||
|
) {
|
||||||
|
void commit('down', true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
onSwipeUp &&
|
||||||
|
(finalDy < -verticalThreshold || (fastUp && absY >= verticalThreshold * 0.5))
|
||||||
|
) {
|
||||||
|
void commit('up', true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalDx > threshold || (fastRight && absX >= threshold * 0.5)) {
|
||||||
void commit('right', true)
|
void commit('right', true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (finalDx < -threshold || (fastLeft && abs >= threshold * 0.5)) {
|
if (finalDx < -threshold || (fastLeft && absX >= threshold * 0.5)) {
|
||||||
void commit('left', true)
|
void commit('left', true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -760,10 +975,13 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
|||||||
}
|
}
|
||||||
|
|
||||||
dxRef.current = 0
|
dxRef.current = 0
|
||||||
|
dyRef.current = 0
|
||||||
|
|
||||||
velocityXRef.current = 0
|
velocityXRef.current = 0
|
||||||
|
velocityYRef.current = 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
|
;(e.currentTarget as HTMLElement).style.touchAction = baseTouchAction
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@ -780,10 +998,14 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
|||||||
opacity: dx === 0 ? 0 : 0.08 + revealSoft * 0.16,
|
opacity: dx === 0 ? 0 : 0.08 + revealSoft * 0.16,
|
||||||
background:
|
background:
|
||||||
swipeDir === 'right'
|
swipeDir === 'right'
|
||||||
? 'linear-gradient(90deg, rgba(16,185,129,0.16) 0%, rgba(16,185,129,0.05) 42%, rgba(0,0,0,0) 100%)'
|
? 'linear-gradient(90deg, rgba(16,185,129,0.18) 0%, rgba(16,185,129,0.06) 42%, rgba(0,0,0,0) 100%)'
|
||||||
: swipeDir === 'left'
|
: swipeDir === 'left'
|
||||||
? 'linear-gradient(270deg, rgba(244,63,94,0.16) 0%, rgba(244,63,94,0.05) 42%, rgba(0,0,0,0) 100%)'
|
? 'linear-gradient(270deg, rgba(244,63,94,0.18) 0%, rgba(244,63,94,0.06) 42%, rgba(0,0,0,0) 100%)'
|
||||||
: 'transparent',
|
: swipeDir === 'up'
|
||||||
|
? 'linear-gradient(0deg, rgba(99,102,241,0.22) 0%, rgba(99,102,241,0.08) 46%, rgba(0,0,0,0) 100%)'
|
||||||
|
: swipeDir === 'down'
|
||||||
|
? 'linear-gradient(180deg, rgba(148,163,184,0.22) 0%, rgba(148,163,184,0.08) 46%, rgba(0,0,0,0) 100%)'
|
||||||
|
: 'transparent',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -793,12 +1015,32 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
|||||||
opacity: armedDir ? 1 : 0,
|
opacity: armedDir ? 1 : 0,
|
||||||
boxShadow:
|
boxShadow:
|
||||||
armedDir === 'right'
|
armedDir === 'right'
|
||||||
? 'inset 0 0 0 1px rgba(16,185,129,0.40), inset 0 0 28px rgba(16,185,129,0.10)'
|
? 'inset 0 0 0 1px rgba(16,185,129,0.42), inset 0 0 28px rgba(16,185,129,0.12)'
|
||||||
: armedDir === 'left'
|
: armedDir === 'left'
|
||||||
? 'inset 0 0 0 1px rgba(244,63,94,0.40), inset 0 0 28px rgba(244,63,94,0.10)'
|
? 'inset 0 0 0 1px rgba(244,63,94,0.42), inset 0 0 28px rgba(244,63,94,0.12)'
|
||||||
: 'none',
|
: armedDir === 'up'
|
||||||
|
? 'inset 0 0 0 1px rgba(99,102,241,0.44), inset 0 0 32px rgba(99,102,241,0.15)'
|
||||||
|
: armedDir === 'down'
|
||||||
|
? 'inset 0 0 0 1px rgba(148,163,184,0.42), inset 0 0 32px rgba(148,163,184,0.13)'
|
||||||
|
: 'none',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{activeAction ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute inset-0 z-30 grid place-items-center rounded-lg transition-opacity duration-100',
|
||||||
|
activeAction.className
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
opacity: armedDir ? 1 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="rounded-full bg-black/55 px-4 py-2 text-sm font-black uppercase tracking-wide text-white shadow-lg ring-1 ring-white/20 backdrop-blur-sm">
|
||||||
|
{activeAction.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
248
frontend/src/components/ui/autoTags.ts
Normal file
248
frontend/src/components/ui/autoTags.ts
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
// frontend\src\components\ui\autoTags.ts
|
||||||
|
|
||||||
|
import type { RecordJob, ResolutionBucket } from '../../types'
|
||||||
|
import {
|
||||||
|
AI_LABEL_FALLBACKS,
|
||||||
|
isDerivedFilterTag,
|
||||||
|
isKnownAiContentLabel,
|
||||||
|
normalizeAiLabel,
|
||||||
|
prettyAiLabel,
|
||||||
|
} from '../../aiLabels'
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, any> {
|
||||||
|
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMetaObject(metaRaw: unknown): Record<string, any> | null {
|
||||||
|
if (!metaRaw) return null
|
||||||
|
|
||||||
|
if (typeof metaRaw === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(metaRaw)
|
||||||
|
return isPlainObject(parsed) ? parsed : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isPlainObject(metaRaw) ? metaRaw : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalTagKey(tag: unknown): string {
|
||||||
|
const clean = String(tag ?? '').trim()
|
||||||
|
if (!clean) return ''
|
||||||
|
|
||||||
|
const normalized = normalizeAiLabel(clean)
|
||||||
|
|
||||||
|
if (normalized && AI_LABEL_FALLBACKS[normalized]) {
|
||||||
|
return AI_LABEL_FALLBACKS[normalized].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
return clean
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^detector:/, '')
|
||||||
|
.replace(/^position:/, '')
|
||||||
|
.replace(/^body:/, '')
|
||||||
|
.replace(/^object:/, '')
|
||||||
|
.replace(/^clothing:/, '')
|
||||||
|
.replace(/[_-]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTag(out: string[], seen: Set<string>, tag: unknown) {
|
||||||
|
const clean = String(tag ?? '').trim()
|
||||||
|
if (!clean) return
|
||||||
|
|
||||||
|
// Tags, die jetzt eigene Sidebar-Filter haben, nicht mehr anzeigen.
|
||||||
|
if (isDerivedFilterTag(clean)) return
|
||||||
|
|
||||||
|
const key = canonicalTagKey(clean)
|
||||||
|
if (!key || seen.has(key)) return
|
||||||
|
|
||||||
|
seen.add(key)
|
||||||
|
out.push(clean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeAiLabel(value: unknown): boolean {
|
||||||
|
const raw = String(value ?? '').trim().toLowerCase()
|
||||||
|
if (!raw) return false
|
||||||
|
|
||||||
|
if (
|
||||||
|
raw.startsWith('combo:') ||
|
||||||
|
raw.startsWith('detector:') ||
|
||||||
|
raw.startsWith('position:') ||
|
||||||
|
raw.startsWith('body:') ||
|
||||||
|
raw.startsWith('object:') ||
|
||||||
|
raw.startsWith('clothing:')
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return isKnownAiContentLabel(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTags(out: string[], seen: Set<string>, values: unknown) {
|
||||||
|
const addOne = (value: unknown) => {
|
||||||
|
const clean = String(value ?? '').trim()
|
||||||
|
if (!clean) return
|
||||||
|
|
||||||
|
if (looksLikeAiLabel(clean)) {
|
||||||
|
addTagFromLabel(out, seen, clean)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addTag(out, seen, clean)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(values)) {
|
||||||
|
for (const value of values) {
|
||||||
|
addOne(value)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof values === 'string') {
|
||||||
|
for (const part of values.split(/[,;|]+/g)) {
|
||||||
|
addOne(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTagFromLabel(out: string[], seen: Set<string>, value: unknown) {
|
||||||
|
const raw = String(value ?? '').trim().toLowerCase()
|
||||||
|
if (!raw || raw === 'unknown') return
|
||||||
|
|
||||||
|
if (raw.startsWith('combo:')) {
|
||||||
|
const rest = raw.slice('combo:'.length)
|
||||||
|
for (const part of rest.split('+')) {
|
||||||
|
addTagFromLabel(out, seen, part)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeAiLabel(raw)
|
||||||
|
if (!normalized || normalized === 'unknown') return
|
||||||
|
|
||||||
|
if (isKnownAiContentLabel(normalized)) {
|
||||||
|
addTag(out, seen, prettyAiLabel(normalized))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAiNode(meta: Record<string, any> | null): Record<string, any> | null {
|
||||||
|
if (!meta) return null
|
||||||
|
|
||||||
|
const ai =
|
||||||
|
meta?.analysis?.highlights ??
|
||||||
|
meta?.analysis?.ai ??
|
||||||
|
meta?.ai ??
|
||||||
|
null
|
||||||
|
|
||||||
|
return isPlainObject(ai) ? ai : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratingStarsFromAi(ai: Record<string, any> | null): number | null {
|
||||||
|
const raw =
|
||||||
|
ai?.rating?.stars ??
|
||||||
|
ai?.stars ??
|
||||||
|
ai?.rating?.rating ??
|
||||||
|
null
|
||||||
|
|
||||||
|
const n = Number(raw)
|
||||||
|
if (!Number.isFinite(n)) return null
|
||||||
|
|
||||||
|
const rounded = Math.round(n)
|
||||||
|
if (rounded < 1 || rounded > 5) return null
|
||||||
|
|
||||||
|
return rounded
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSegments(ai: Record<string, any> | null): Record<string, any>[] {
|
||||||
|
const raw = ai?.segments
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
|
||||||
|
return raw.filter(isPlainObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ratingStarsForMeta(metaRaw: unknown): number | null {
|
||||||
|
const meta = parseMetaObject(metaRaw)
|
||||||
|
const ai = readAiNode(meta)
|
||||||
|
return ratingStarsFromAi(ai)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ratingStarsForJob(job: RecordJob): number | null {
|
||||||
|
return ratingStarsForMeta((job as any)?.meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
function videoWidthFromMeta(meta: Record<string, any> | null): number {
|
||||||
|
return Number(
|
||||||
|
meta?.media?.video?.width ??
|
||||||
|
meta?.media?.video?.w ??
|
||||||
|
meta?.media?.width ??
|
||||||
|
meta?.file?.video?.width ??
|
||||||
|
meta?.file?.video?.w ??
|
||||||
|
meta?.file?.width ??
|
||||||
|
meta?.video?.width ??
|
||||||
|
meta?.video?.w ??
|
||||||
|
meta?.width ??
|
||||||
|
0
|
||||||
|
) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolutionBucketForMeta(metaRaw: unknown): ResolutionBucket {
|
||||||
|
const meta = parseMetaObject(metaRaw)
|
||||||
|
const width = videoWidthFromMeta(meta)
|
||||||
|
|
||||||
|
if (width >= 3800) return '4k'
|
||||||
|
if (width >= 1900) return 'fullhd'
|
||||||
|
if (width >= 1280) return 'hd'
|
||||||
|
if (width > 0) return 'sd'
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolutionBucketForJob(job: RecordJob): ResolutionBucket {
|
||||||
|
return resolutionBucketForMeta((job as any)?.meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeTags(...lists: Array<Array<string | undefined | null>>): string[] {
|
||||||
|
const out: string[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
|
||||||
|
for (const list of lists) {
|
||||||
|
for (const tag of list) {
|
||||||
|
addTag(out, seen, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function autoTagsForMeta(metaRaw: unknown): string[] {
|
||||||
|
const meta = parseMetaObject(metaRaw)
|
||||||
|
const ai = readAiNode(meta)
|
||||||
|
|
||||||
|
const out: string[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
|
||||||
|
for (const segment of readSegments(ai)) {
|
||||||
|
addTagFromLabel(out, seen, segment.label)
|
||||||
|
addTagFromLabel(out, seen, segment.title)
|
||||||
|
addTagFromLabel(out, seen, segment.category)
|
||||||
|
addTagFromLabel(out, seen, segment.type)
|
||||||
|
addTagFromLabel(out, seen, segment.kind)
|
||||||
|
addTagFromLabel(out, seen, segment.name)
|
||||||
|
|
||||||
|
if (typeof segment.position === 'string' && segment.position.trim()) {
|
||||||
|
addTagFromLabel(out, seen, `position:${segment.position}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
addTags(out, seen, segment.tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.slice(0, 14)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function autoTagsForJob(job: RecordJob): string[] {
|
||||||
|
return autoTagsForMeta((job as any)?.meta)
|
||||||
|
}
|
||||||
329
frontend/src/components/ui/videoHeatmap.ts
Normal file
329
frontend/src/components/ui/videoHeatmap.ts
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
// frontend\src\components\ui\videoHeatmap.ts
|
||||||
|
|
||||||
|
import type {
|
||||||
|
RecordJob,
|
||||||
|
VideoHeatmapSegment,
|
||||||
|
VideoHeatmapSource,
|
||||||
|
} from '../../types'
|
||||||
|
import {
|
||||||
|
aiLabelGroup,
|
||||||
|
normalizeAiLabel,
|
||||||
|
prettyAiLabel,
|
||||||
|
} from '../../aiLabels'
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, any> {
|
||||||
|
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseHeatmapMeta(metaRaw: unknown): Record<string, any> | null {
|
||||||
|
if (!metaRaw) return null
|
||||||
|
|
||||||
|
if (typeof metaRaw === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(metaRaw)
|
||||||
|
return isPlainObject(parsed) ? parsed : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isPlainObject(metaRaw) ? metaRaw : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDurationSeconds(value: unknown): number {
|
||||||
|
const n = Number(value)
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return 0
|
||||||
|
return n > 24 * 60 * 60 ? n / 1000 : n
|
||||||
|
}
|
||||||
|
|
||||||
|
export function heatmapDurationForJob(
|
||||||
|
job: RecordJob,
|
||||||
|
fallbackDurationSeconds?: number
|
||||||
|
): number {
|
||||||
|
const meta = parseHeatmapMeta((job as any)?.meta)
|
||||||
|
|
||||||
|
return normalizeDurationSeconds(
|
||||||
|
meta?.media?.durationSeconds ??
|
||||||
|
meta?.file?.durationSeconds ??
|
||||||
|
meta?.durationSeconds ??
|
||||||
|
(job as any)?.durationSeconds ??
|
||||||
|
fallbackDurationSeconds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAiNode(meta: Record<string, any> | null): Record<string, any> | null {
|
||||||
|
if (!meta) return null
|
||||||
|
|
||||||
|
const ai =
|
||||||
|
meta?.analysis?.highlights ??
|
||||||
|
meta?.analysis?.ai ??
|
||||||
|
meta?.ai ??
|
||||||
|
null
|
||||||
|
|
||||||
|
return isPlainObject(ai) ? ai : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(n: number, min: number, max: number) {
|
||||||
|
return Math.max(min, Math.min(max, n))
|
||||||
|
}
|
||||||
|
|
||||||
|
function readScore(value: unknown): number {
|
||||||
|
const n = Number(value)
|
||||||
|
if (!Number.isFinite(n)) return 0.35
|
||||||
|
|
||||||
|
if (n >= 0 && n <= 1) return clamp(n, 0, 1)
|
||||||
|
if (n > 1 && n <= 100) return clamp(n / 100, 0, 1)
|
||||||
|
|
||||||
|
return 0.35
|
||||||
|
}
|
||||||
|
|
||||||
|
type WeightedDetectedLabel = {
|
||||||
|
label: string
|
||||||
|
scoreWeight: number
|
||||||
|
priority: number
|
||||||
|
source: VideoHeatmapSource
|
||||||
|
}
|
||||||
|
|
||||||
|
function weightedLabelFromValue(value: unknown): WeightedDetectedLabel | null {
|
||||||
|
const raw = String(value ?? '').trim()
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
const lower = raw.toLowerCase()
|
||||||
|
|
||||||
|
if (lower.startsWith('combo:')) {
|
||||||
|
const parts = lower.slice('combo:'.length).split('+')
|
||||||
|
|
||||||
|
let best: WeightedDetectedLabel | null = null
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const found = weightedLabelFromValue(part)
|
||||||
|
if (!found) continue
|
||||||
|
|
||||||
|
if (!best || found.priority > best.priority) {
|
||||||
|
best = found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeAiLabel(raw)
|
||||||
|
if (!normalized || normalized === 'unknown') return null
|
||||||
|
|
||||||
|
const group = aiLabelGroup(normalized)
|
||||||
|
|
||||||
|
if (group === 'position') {
|
||||||
|
return {
|
||||||
|
label: prettyAiLabel(normalized),
|
||||||
|
scoreWeight: 1.0,
|
||||||
|
priority: 2,
|
||||||
|
source: 'position',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group === 'clothing') {
|
||||||
|
return {
|
||||||
|
label: prettyAiLabel(normalized),
|
||||||
|
scoreWeight: 0.58,
|
||||||
|
priority: 1,
|
||||||
|
source: 'clothing',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readWeightedLabel(item: any): WeightedDetectedLabel | null {
|
||||||
|
const directValues = [
|
||||||
|
item?.position,
|
||||||
|
item?.Position,
|
||||||
|
item?.label,
|
||||||
|
item?.Label,
|
||||||
|
item?.title,
|
||||||
|
item?.name,
|
||||||
|
item?.category,
|
||||||
|
item?.type,
|
||||||
|
item?.kind,
|
||||||
|
]
|
||||||
|
|
||||||
|
let best: WeightedDetectedLabel | null = null
|
||||||
|
|
||||||
|
for (const value of directValues) {
|
||||||
|
const found = weightedLabelFromValue(value)
|
||||||
|
if (!found) continue
|
||||||
|
|
||||||
|
if (!best || found.priority > best.priority) {
|
||||||
|
best = found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayValues = [
|
||||||
|
item?.tags,
|
||||||
|
item?.labels,
|
||||||
|
item?.classes,
|
||||||
|
item?.flags,
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const values of arrayValues) {
|
||||||
|
if (!Array.isArray(values)) continue
|
||||||
|
|
||||||
|
for (const value of values) {
|
||||||
|
const found = weightedLabelFromValue(value)
|
||||||
|
if (!found) continue
|
||||||
|
|
||||||
|
if (!best || found.priority > best.priority) {
|
||||||
|
best = found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSegment(
|
||||||
|
out: VideoHeatmapSegment[],
|
||||||
|
startRaw: unknown,
|
||||||
|
endRaw: unknown,
|
||||||
|
markerRaw: unknown,
|
||||||
|
durationSec: number,
|
||||||
|
label: string,
|
||||||
|
scoreRaw: unknown,
|
||||||
|
source: VideoHeatmapSource = 'other'
|
||||||
|
) {
|
||||||
|
if (!Number.isFinite(durationSec) || durationSec <= 0) return
|
||||||
|
|
||||||
|
let start = Number(startRaw)
|
||||||
|
let end = Number(endRaw)
|
||||||
|
|
||||||
|
if (!Number.isFinite(start) || start < 0) {
|
||||||
|
const marker = Number(markerRaw)
|
||||||
|
if (!Number.isFinite(marker) || marker < 0) return
|
||||||
|
|
||||||
|
start = marker - 3
|
||||||
|
end = marker + 3
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(end) || end <= start) {
|
||||||
|
end = start + 6
|
||||||
|
}
|
||||||
|
|
||||||
|
start = clamp(start, 0, durationSec)
|
||||||
|
end = clamp(end, 0, durationSec)
|
||||||
|
|
||||||
|
if (end <= start) return
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
startSec: start,
|
||||||
|
endSec: end,
|
||||||
|
intensity: readScore(scoreRaw),
|
||||||
|
label,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildVideoHeatmapSegments(
|
||||||
|
job: RecordJob,
|
||||||
|
fallbackDurationSeconds?: number
|
||||||
|
): VideoHeatmapSegment[] {
|
||||||
|
const meta = parseHeatmapMeta((job as any)?.meta)
|
||||||
|
const ai = readAiNode(meta)
|
||||||
|
const durationSec = heatmapDurationForJob(job, fallbackDurationSeconds)
|
||||||
|
|
||||||
|
if (!ai || durationSec <= 0) return []
|
||||||
|
|
||||||
|
const out: VideoHeatmapSegment[] = []
|
||||||
|
|
||||||
|
const segments = Array.isArray(ai?.segments ?? ai?.Segments)
|
||||||
|
? (ai?.segments ?? ai?.Segments)
|
||||||
|
: []
|
||||||
|
|
||||||
|
for (const item of segments) {
|
||||||
|
if (!isPlainObject(item)) continue
|
||||||
|
|
||||||
|
const detected = readWeightedLabel(item)
|
||||||
|
if (!detected) continue
|
||||||
|
|
||||||
|
const rawScore = readScore(
|
||||||
|
item.score ?? item.Score ?? item.confidence
|
||||||
|
)
|
||||||
|
|
||||||
|
const weightedScore = clamp(rawScore * detected.scoreWeight, 0, 1)
|
||||||
|
|
||||||
|
addSegment(
|
||||||
|
out,
|
||||||
|
item.startSeconds ?? item.StartSeconds ?? item.start ?? item.Start,
|
||||||
|
item.endSeconds ?? item.EndSeconds ?? item.end ?? item.End,
|
||||||
|
item.time ?? item.Time ?? item.at ?? item.timestamp,
|
||||||
|
durationSec,
|
||||||
|
detected.label,
|
||||||
|
weightedScore,
|
||||||
|
detected.source
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hits = Array.isArray(ai?.hits ?? ai?.Hits)
|
||||||
|
? (ai?.hits ?? ai?.Hits)
|
||||||
|
: []
|
||||||
|
|
||||||
|
for (const item of hits) {
|
||||||
|
if (!isPlainObject(item)) continue
|
||||||
|
|
||||||
|
const detected = readWeightedLabel(item)
|
||||||
|
if (!detected) continue
|
||||||
|
|
||||||
|
const rawScore = readScore(
|
||||||
|
item.score ?? item.Score ?? item.confidence
|
||||||
|
)
|
||||||
|
|
||||||
|
const weightedScore = clamp(rawScore * detected.scoreWeight, 0, 1)
|
||||||
|
|
||||||
|
addSegment(
|
||||||
|
out,
|
||||||
|
item.startSeconds ?? item.StartSeconds ?? item.start ?? item.Start,
|
||||||
|
item.endSeconds ?? item.EndSeconds ?? item.end ?? item.End,
|
||||||
|
item.time ?? item.Time ?? item.at ?? item.timestamp,
|
||||||
|
durationSec,
|
||||||
|
detected.label,
|
||||||
|
weightedScore,
|
||||||
|
detected.source
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergeHeatmapSegments(out, durationSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeHeatmapSegments(
|
||||||
|
input: VideoHeatmapSegment[],
|
||||||
|
durationSec: number
|
||||||
|
): VideoHeatmapSegment[] {
|
||||||
|
if (input.length <= 1) return input
|
||||||
|
|
||||||
|
const sorted = [...input]
|
||||||
|
.filter((s) => s.endSec > s.startSec)
|
||||||
|
.sort((a, b) => a.startSec - b.startSec || b.endSec - a.endSec)
|
||||||
|
|
||||||
|
const out: VideoHeatmapSegment[] = []
|
||||||
|
|
||||||
|
for (const seg of sorted) {
|
||||||
|
const last = out[out.length - 1]
|
||||||
|
|
||||||
|
// Nur sehr nahe/überlappende Bereiche zusammenfassen.
|
||||||
|
if (
|
||||||
|
!last ||
|
||||||
|
last.source !== seg.source ||
|
||||||
|
seg.startSec > last.endSec + 1.5
|
||||||
|
) {
|
||||||
|
out.push({ ...seg })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
last.endSec = clamp(Math.max(last.endSec, seg.endSec), 0, durationSec)
|
||||||
|
last.intensity = Math.max(last.intensity, seg.intensity)
|
||||||
|
|
||||||
|
if (!last.label && seg.label) {
|
||||||
|
last.label = seg.label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
@ -377,15 +377,24 @@ export type BackgroundSplitProgress = {
|
|||||||
|
|
||||||
export type StoredModel = {
|
export type StoredModel = {
|
||||||
id: string
|
id: string
|
||||||
input: string
|
input?: string
|
||||||
host?: string
|
host?: string | null
|
||||||
modelKey: string
|
modelKey: string
|
||||||
watching: boolean
|
watching?: boolean
|
||||||
favorite?: boolean
|
favorite?: boolean
|
||||||
liked?: boolean | null
|
liked?: boolean | null
|
||||||
tags?: string
|
hot?: boolean
|
||||||
|
keep?: boolean
|
||||||
|
tags?: string | null
|
||||||
isUrl?: boolean
|
isUrl?: boolean
|
||||||
path?: string
|
path?: string
|
||||||
|
|
||||||
|
gender?: string | null
|
||||||
|
country?: string | null
|
||||||
|
|
||||||
|
lastSeenOnline?: boolean | null
|
||||||
|
lastSeenOnlineAt?: string
|
||||||
|
|
||||||
roomStatus?: string
|
roomStatus?: string
|
||||||
isOnline?: boolean
|
isOnline?: boolean
|
||||||
chatRoomUrl?: string
|
chatRoomUrl?: string
|
||||||
@ -393,6 +402,12 @@ export type StoredModel = {
|
|||||||
lastOnlineAt?: string
|
lastOnlineAt?: string
|
||||||
lastOfflineAt?: string
|
lastOfflineAt?: string
|
||||||
lastRoomSyncAt?: string
|
lastRoomSyncAt?: string
|
||||||
|
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
cbOnlineJson?: string | null
|
||||||
|
cbOnlineFetchedAt?: string | null
|
||||||
|
cbOnlineLastError?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PendingWatchedRoom = {
|
export type PendingWatchedRoom = {
|
||||||
@ -464,4 +479,266 @@ export type RunFileMutationOptions<T> = {
|
|||||||
failTitle: string
|
failTitle: string
|
||||||
failPrefix?: string
|
failPrefix?: string
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResolutionBucket = '4k' | 'fullhd' | 'hd' | 'sd' | ''
|
||||||
|
|
||||||
|
export type RatingSidebarFilter =
|
||||||
|
| 'all'
|
||||||
|
| 'stars_5'
|
||||||
|
| 'stars_4'
|
||||||
|
| 'stars_3'
|
||||||
|
| 'stars_2'
|
||||||
|
| 'stars_1'
|
||||||
|
| 'unrated'
|
||||||
|
|
||||||
|
export type ResolutionSidebarFilter =
|
||||||
|
| 'all'
|
||||||
|
| Exclude<ResolutionBucket, ''>
|
||||||
|
| 'unknown'
|
||||||
|
|
||||||
|
export type PhaseSidebarFilter =
|
||||||
|
| 'all'
|
||||||
|
| 'meta'
|
||||||
|
| 'thumb'
|
||||||
|
| 'teaser'
|
||||||
|
| 'sprites'
|
||||||
|
| 'analyze'
|
||||||
|
|
||||||
|
export type SplitProgressOverlay = {
|
||||||
|
phase: 'preparing' | 'splitting' | 'done' | 'error'
|
||||||
|
total: number
|
||||||
|
current: number
|
||||||
|
completed: number
|
||||||
|
segmentProgress?: number
|
||||||
|
overallProgress?: number
|
||||||
|
message?: string
|
||||||
|
segment?: {
|
||||||
|
index: number
|
||||||
|
label: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
startedAtMs?: number
|
||||||
|
sourceFile?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SegmentSpritePreview = {
|
||||||
|
src: string
|
||||||
|
frameStyle: import('react').CSSProperties
|
||||||
|
imageStyle: import('react').CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RatingSegment = {
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
timeLabel?: string
|
||||||
|
valueLabel?: string
|
||||||
|
detail?: string
|
||||||
|
tags: string[]
|
||||||
|
iconLabels?: string[]
|
||||||
|
preview?: SegmentSpritePreview
|
||||||
|
openAtSec?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProviderKey = 'chaturbate' | 'myfreecams'
|
||||||
|
|
||||||
|
export type ChaturbateRoom = {
|
||||||
|
gender?: string
|
||||||
|
location?: string
|
||||||
|
country?: string
|
||||||
|
current_show?: string
|
||||||
|
username?: string
|
||||||
|
room_subject?: string
|
||||||
|
tags?: string[]
|
||||||
|
is_new?: boolean
|
||||||
|
num_users?: number
|
||||||
|
num_followers?: number
|
||||||
|
spoken_languages?: string
|
||||||
|
display_name?: string
|
||||||
|
birthday?: string
|
||||||
|
is_hd?: boolean
|
||||||
|
age?: number
|
||||||
|
seconds_online?: number
|
||||||
|
image_url?: string
|
||||||
|
image_url_360x270?: string
|
||||||
|
chat_room_url?: string
|
||||||
|
chat_room_url_revshare?: string
|
||||||
|
iframe_embed?: string
|
||||||
|
iframe_embed_revshare?: string
|
||||||
|
slug?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OnlineResp = {
|
||||||
|
enabled?: boolean
|
||||||
|
fetchedAt?: string
|
||||||
|
count?: number
|
||||||
|
lastError?: string
|
||||||
|
rooms?: ChaturbateRoom[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BioPhotoSet = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
cover_url?: string
|
||||||
|
tokens?: number
|
||||||
|
is_video?: boolean
|
||||||
|
user_can_access?: boolean
|
||||||
|
user_has_purchased?: boolean
|
||||||
|
fan_club_only?: boolean
|
||||||
|
label_text?: string
|
||||||
|
label_color?: string
|
||||||
|
video_has_sound?: boolean
|
||||||
|
video_ready?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BioSocial = {
|
||||||
|
id: number
|
||||||
|
title_name: string
|
||||||
|
image_url?: string
|
||||||
|
link?: string
|
||||||
|
popup_link?: boolean
|
||||||
|
tokens?: number
|
||||||
|
purchased?: boolean
|
||||||
|
label_text?: string
|
||||||
|
label_color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BioContext = {
|
||||||
|
follower_count?: number
|
||||||
|
location?: string
|
||||||
|
real_name?: string
|
||||||
|
body_decorations?: string
|
||||||
|
last_broadcast?: string
|
||||||
|
smoke_drink?: string
|
||||||
|
body_type?: string
|
||||||
|
display_birthday?: string
|
||||||
|
about_me?: string
|
||||||
|
wish_list?: string
|
||||||
|
time_since_last_broadcast?: string
|
||||||
|
fan_club_cost?: number
|
||||||
|
performer_has_fanclub?: boolean
|
||||||
|
fan_club_is_member?: boolean
|
||||||
|
fan_club_join_url?: string
|
||||||
|
needs_supporter_to_pm?: boolean
|
||||||
|
interested_in?: string[]
|
||||||
|
display_age?: number
|
||||||
|
sex?: string
|
||||||
|
room_status?: string
|
||||||
|
photo_sets?: BioPhotoSet[]
|
||||||
|
social_medias?: BioSocial[]
|
||||||
|
is_broadcaster_or_staff?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BioResp = {
|
||||||
|
enabled?: boolean
|
||||||
|
fetchedAt?: string
|
||||||
|
lastError?: string
|
||||||
|
model?: string
|
||||||
|
bio?: BioContext | any | null
|
||||||
|
roomStatus?: string
|
||||||
|
isOnline?: boolean
|
||||||
|
chatRoomUrl?: string
|
||||||
|
imageUrl?: string
|
||||||
|
|
||||||
|
avatarUrl?: string
|
||||||
|
snapUrl?: string
|
||||||
|
roomTopic?: string
|
||||||
|
country?: string
|
||||||
|
gender?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OnlineCacheEntry = {
|
||||||
|
at: number
|
||||||
|
room: ChaturbateRoom | null
|
||||||
|
meta: Pick<OnlineResp, 'enabled' | 'fetchedAt' | 'lastError'> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BioCacheEntry = {
|
||||||
|
at: number
|
||||||
|
bio: BioContext | null
|
||||||
|
meta: Pick<BioResp, 'enabled' | 'fetchedAt' | 'lastError'> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelProfileTopLabel = {
|
||||||
|
label: string
|
||||||
|
count: number
|
||||||
|
iconLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelProfileLabelGroupKey =
|
||||||
|
| 'positions'
|
||||||
|
| 'bodyParts'
|
||||||
|
| 'objects'
|
||||||
|
| 'clothing'
|
||||||
|
|
||||||
|
export type ModelProfileLabelGroups = Record<
|
||||||
|
ModelProfileLabelGroupKey,
|
||||||
|
ModelProfileTopLabel[]
|
||||||
|
>
|
||||||
|
|
||||||
|
export type ModelProfileStats = {
|
||||||
|
totalCount: number
|
||||||
|
loadedCount: number
|
||||||
|
runningCount: number
|
||||||
|
totalDurationSec: number
|
||||||
|
totalBytes: number
|
||||||
|
knownBytesCount: number
|
||||||
|
avgStars: number | null
|
||||||
|
bestStars: number | null
|
||||||
|
highRatedCount: number
|
||||||
|
lowRatedCount: number
|
||||||
|
hotCount: number
|
||||||
|
keepCount: number
|
||||||
|
latestAt: Date | null
|
||||||
|
topLabels: ModelProfileTopLabel[]
|
||||||
|
labelGroups: ModelProfileLabelGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FinishedReportMode = 'day' | 'week'
|
||||||
|
|
||||||
|
export type FinishedReportTopItem = {
|
||||||
|
label: string
|
||||||
|
count: number
|
||||||
|
iconLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FinishedReportDailyItem = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
count: number
|
||||||
|
durationSeconds: number
|
||||||
|
sizeBytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FinishedReportStats = {
|
||||||
|
mode: FinishedReportMode
|
||||||
|
start: Date
|
||||||
|
end: Date
|
||||||
|
|
||||||
|
totalCount: number
|
||||||
|
totalDurationSeconds: number
|
||||||
|
totalSizeBytes: number
|
||||||
|
knownSizeCount: number
|
||||||
|
|
||||||
|
hotCount: number
|
||||||
|
keepCount: number
|
||||||
|
|
||||||
|
ratedCount: number
|
||||||
|
ratingCounts: Record<1 | 2 | 3 | 4 | 5, number>
|
||||||
|
|
||||||
|
topModels: FinishedReportTopItem[]
|
||||||
|
topTags: FinishedReportTopItem[]
|
||||||
|
daily: FinishedReportDailyItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VideoHeatmapSource = 'position' | 'clothing' | 'other'
|
||||||
|
|
||||||
|
export type VideoHeatmapSegment = {
|
||||||
|
startSec: number
|
||||||
|
endSec: number
|
||||||
|
intensity: number
|
||||||
|
label?: string
|
||||||
|
source?: VideoHeatmapSource
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user