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.
|
||||
// 1 = jeder Sekunde, 3 = alle 3 Sekunden, 5 = alle 5 Sekunden.
|
||||
analyzeVideoFrameIntervalSeconds = 3
|
||||
analyzeVideoFrameIntervalSeconds = 1
|
||||
|
||||
// AI-Server nicht mit tausenden Pfaden auf einmal fluten.
|
||||
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_doggy",
|
||||
"spooning",
|
||||
"sitting",
|
||||
"facesitting",
|
||||
"handjob",
|
||||
"blowjob",
|
||||
|
||||
@ -122,7 +122,6 @@ func isKnownPositionLabel(label string) bool {
|
||||
"standing",
|
||||
"standing_doggy",
|
||||
"spooning",
|
||||
"sitting",
|
||||
"facesitting",
|
||||
"handjob",
|
||||
"blowjob",
|
||||
@ -153,7 +152,7 @@ func positionSeverityWeight(label string) float64 {
|
||||
return 0.84
|
||||
case "spooning":
|
||||
return 0.78
|
||||
case "standing", "sitting":
|
||||
case "standing":
|
||||
return 0.42
|
||||
default:
|
||||
return 0.00
|
||||
|
||||
@ -13,8 +13,8 @@
|
||||
"useMyFreeCamsApi": true,
|
||||
"autoDeleteSmallDownloads": true,
|
||||
"autoDeleteSmallDownloadsBelowMB": 300,
|
||||
"autoDeleteSmallDownloadsKeepFavorites": true,
|
||||
"autoDeleteLowRatedDownloads": false,
|
||||
"autoDeleteSmallDownloadsKeepFavorites": false,
|
||||
"autoDeleteLowRatedDownloads": true,
|
||||
"autoDeleteLowRatedDownloadsMaxStars": 3,
|
||||
"lowDiskPauseBelowGB": 5,
|
||||
"blurPreviews": false,
|
||||
|
||||
@ -257,6 +257,123 @@ func findAIServerScriptDir() (string, error) {
|
||||
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 {
|
||||
deadline := time.Now().Add(timeout)
|
||||
client := &http.Client{
|
||||
@ -345,19 +462,15 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
exePath, _ := os.Executable()
|
||||
exeDir := ""
|
||||
if exePath != "" {
|
||||
exeDir = filepath.Dir(exePath)
|
||||
trainingRoot, appBaseDir := findTrainingRootDir(scriptDir)
|
||||
|
||||
detectionLabelsPath := strings.TrimSpace(os.Getenv("DETECTION_LABELS_PATH"))
|
||||
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")
|
||||
|
||||
pythonPath := aiServerPythonPath()
|
||||
@ -392,7 +505,10 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
|
||||
|
||||
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, "TRAINING_ROOT", trainingRoot)
|
||||
env = upsertEnv(env, "DETECTION_LABELS_PATH", detectionLabelsPath)
|
||||
|
||||
@ -4152,7 +4152,6 @@ export default function App() {
|
||||
: isTrainingTab && trainingImageExpanded
|
||||
? 'max-w-none px-2 sm:px-3 lg:px-4'
|
||||
: 'mx-auto max-w-7xl',
|
||||
isTrainingTab ? '' : 'space-y-2',
|
||||
].join(' ')}
|
||||
>
|
||||
{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,
|
||||
FinishedPostworkSummary,
|
||||
DoneSortMode,
|
||||
SplitProgressOverlay,
|
||||
RatingSidebarFilter,
|
||||
ResolutionSidebarFilter,
|
||||
PhaseSidebarFilter,
|
||||
} from '../../types'
|
||||
import ButtonGroup from './ButtonGroup'
|
||||
import {
|
||||
@ -60,6 +64,13 @@ import {
|
||||
} from './finishedSelectionStore'
|
||||
import { formatResolution, formatDuration, formatBytes } from './formatters'
|
||||
import RatingOverlay from './RatingOverlay'
|
||||
import FinishedReportModal from './FinishedReportModal'
|
||||
import {
|
||||
autoTagsForJob,
|
||||
mergeTags,
|
||||
ratingStarsForJob,
|
||||
resolutionBucketForJob,
|
||||
} from './autoTags'
|
||||
|
||||
type Props = {
|
||||
jobs: RecordJob[]
|
||||
@ -569,23 +580,229 @@ function postworkBadgeTone(summary?: FinishedPostworkSummary): string {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
const PHASE_SIDEBAR_FILTER_OPTIONS: Array<{
|
||||
value: PhaseSidebarFilter
|
||||
label: string
|
||||
}> = [
|
||||
{ value: 'all', label: 'Alle' },
|
||||
{ value: 'meta', label: 'Meta' },
|
||||
{ value: 'thumb', label: 'Bild' },
|
||||
{ value: 'teaser', label: 'Teaser' },
|
||||
{ value: 'sprites', label: 'Sprites' },
|
||||
{ value: 'analyze', label: 'Analyse' },
|
||||
]
|
||||
|
||||
function phaseKeyForStep(
|
||||
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 = {
|
||||
@ -2034,6 +2251,17 @@ export default function FinishedDownloads({
|
||||
|
||||
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] =
|
||||
useState<Record<string, FinishedPostworkBadge[]>>({})
|
||||
|
||||
@ -2226,18 +2454,41 @@ export default function FinishedDownloads({
|
||||
const seen = new Set<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 tag of parseTags(flags?.tags)) {
|
||||
const k = lowerTag(tag)
|
||||
if (!k || seen.has(k)) continue
|
||||
seen.add(k)
|
||||
out.push(tag)
|
||||
add(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' }))
|
||||
return out
|
||||
}, [modelsByKey])
|
||||
}, [modelsByKey, allDoneJobs, doneJobs])
|
||||
|
||||
const teaserState = useMemo<FinishedDownloadsTeaserState>(
|
||||
() => ({
|
||||
@ -2263,6 +2514,26 @@ export default function FinishedDownloads({
|
||||
return { tagsByModelKey, tagSetByModelKey }
|
||||
}, [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(
|
||||
() => lowerTag(deferredSearchQuery).split(/\s+/g).map((s) => s.trim()).filter(Boolean),
|
||||
[deferredSearchQuery]
|
||||
@ -2271,7 +2542,10 @@ export default function FinishedDownloads({
|
||||
const searchActiveForGlobalFetch =
|
||||
activeTagSet.size > 0 ||
|
||||
searchTokens.length > 0 ||
|
||||
showOnlyCorrupt
|
||||
showOnlyCorrupt ||
|
||||
ratingFilter !== 'all' ||
|
||||
resolutionFilter !== 'all' ||
|
||||
phaseFilter !== 'all'
|
||||
|
||||
const globalFilterActive = searchActiveForGlobalFetch
|
||||
const mobileCardStackMode = isSmall && view === 'cards'
|
||||
@ -2506,14 +2780,20 @@ export default function FinishedDownloads({
|
||||
? base.filter((j) => {
|
||||
const file = baseName(j.output || '')
|
||||
const model = modelNameFromOutput(j.output)
|
||||
const modelKey = lowerTag(model)
|
||||
const tags = modelTags.tagsByModelKey[modelKey] ?? []
|
||||
const tags = tagsForJob(j)
|
||||
|
||||
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) {
|
||||
if (!hay.includes(t)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
: base
|
||||
@ -2522,13 +2802,13 @@ export default function FinishedDownloads({
|
||||
activeTagSet.size === 0
|
||||
? searched
|
||||
: searched.filter((j) => {
|
||||
const modelKey = lowerTag(modelNameFromOutput(j.output))
|
||||
const have = modelTags.tagSetByModelKey[modelKey]
|
||||
const have = tagSetForJob(j)
|
||||
if (!have || have.size === 0) return false
|
||||
|
||||
for (const t of activeTagSet) {
|
||||
if (!have.has(t)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
@ -2536,14 +2816,36 @@ export default function FinishedDownloads({
|
||||
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,
|
||||
deletedKeys,
|
||||
activeTagSet,
|
||||
modelTags,
|
||||
tagsForJob,
|
||||
tagSetForJob,
|
||||
searchTokens,
|
||||
showOnlyCorrupt,
|
||||
ratingFilter,
|
||||
resolutionFilter,
|
||||
phaseFilter,
|
||||
effectivePostworkSummaryByFile,
|
||||
])
|
||||
|
||||
const finishedDownloadsLoading = allDoneJobsLoading
|
||||
@ -3911,7 +4213,15 @@ export default function FinishedDownloads({
|
||||
|
||||
useEffect(() => {
|
||||
onPageChange(1)
|
||||
}, [deferredSearchQuery, tagFilter, showOnlyCorrupt, onPageChange])
|
||||
}, [
|
||||
deferredSearchQuery,
|
||||
tagFilter,
|
||||
showOnlyCorrupt,
|
||||
ratingFilter,
|
||||
resolutionFilter,
|
||||
phaseFilter,
|
||||
onPageChange,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
// -----------------------------------------------------------------------------
|
||||
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)]">
|
||||
{/* 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
|
||||
className="
|
||||
rounded-xl border border-gray-200/70 bg-white/95 shadow-sm
|
||||
@ -5023,6 +5483,17 @@ export default function FinishedDownloads({
|
||||
/>
|
||||
</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={desktopToolbarLeftClass}>
|
||||
<div className="grid w-full grid-cols-1 gap-2">
|
||||
@ -5060,6 +5531,8 @@ export default function FinishedDownloads({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{renderRatingResolutionFilters()}
|
||||
|
||||
<Button
|
||||
size={isCompactDesktop ? 'sm' : 'md'}
|
||||
variant={lastAction ? 'soft' : 'secondary'}
|
||||
@ -5215,6 +5688,17 @@ export default function FinishedDownloads({
|
||||
/>
|
||||
</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 ? (
|
||||
<Button
|
||||
size="md"
|
||||
@ -5414,6 +5898,8 @@ export default function FinishedDownloads({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderRatingResolutionFilters()}
|
||||
|
||||
{view !== 'table' ? (
|
||||
<div className="space-y-2">
|
||||
{view === 'gallery' ? (
|
||||
@ -5523,7 +6009,10 @@ export default function FinishedDownloads({
|
||||
|
||||
{tagFilter.length > 0 ||
|
||||
(searchQuery || '').trim() !== '' ||
|
||||
showOnlyCorrupt ? (
|
||||
showOnlyCorrupt ||
|
||||
ratingFilter !== 'all' ||
|
||||
resolutionFilter !== 'all' ||
|
||||
phaseFilter !== 'all' ? (
|
||||
<div className="mt-2 flex flex-wrap gap-3">
|
||||
{tagFilter.length > 0 ? (
|
||||
<button
|
||||
@ -5544,6 +6033,22 @@ export default function FinishedDownloads({
|
||||
Suche zurücksetzen
|
||||
</button>
|
||||
) : 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>
|
||||
) : null}
|
||||
|
||||
|
||||
@ -36,6 +36,10 @@ import {
|
||||
readPreviewSpriteInfo,
|
||||
scrubProgressRatioFromIndex,
|
||||
} from './previewSprite'
|
||||
import {
|
||||
buildVideoHeatmapSegments,
|
||||
heatmapDurationForJob,
|
||||
} from './videoHeatmap'
|
||||
import ModelGenderIcon from './ModelGenderIcon'
|
||||
import { parseFinishedTags, resolveFinishedModelFlags } from './finishedModelFlags'
|
||||
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
||||
@ -47,6 +51,7 @@ import FinishedDownloadsDesktopCard from './FinishedDownloadsDesktopCard'
|
||||
import PreviewRatingOverlay from './PreviewRatingOverlay'
|
||||
import PreviewMetaOverlay from './PreviewMetaOverlay'
|
||||
import DeletingPreviewOverlay from './DeletingPreviewOverlay'
|
||||
import { autoTagsForJob, mergeTags } from './autoTags'
|
||||
|
||||
|
||||
type SwipeRefs = {
|
||||
@ -516,6 +521,8 @@ function FinishedDownloadsCardsView({
|
||||
const hasSpriteScrubber = sprite.hasSpriteScrubber
|
||||
const scrubberCount = hasScrubberUi ? sprite.count : 0
|
||||
const scrubberStepSeconds = hasScrubberUi ? sprite.stepSeconds : 0
|
||||
const heatmapDuration = heatmapDurationForJob(j, durations[k] ?? (j as any)?.durationSeconds)
|
||||
const heatmapSegments = buildVideoHeatmapSegments(j, heatmapDuration)
|
||||
|
||||
const teaserPreloadEnabled =
|
||||
opts?.mobileStackTopOnlyVideo
|
||||
@ -543,7 +550,10 @@ function FinishedDownloadsCardsView({
|
||||
const isLiked = flags?.liked === true
|
||||
const isWatching = Boolean(flags?.watching)
|
||||
|
||||
const tags = parseFinishedTags(flags?.tags)
|
||||
const tags = mergeTags(
|
||||
parseFinishedTags(flags?.tags),
|
||||
autoTagsForJob(j)
|
||||
)
|
||||
|
||||
const modelCountry = resolveFinishedCountry(flags, j)
|
||||
|
||||
@ -835,6 +845,8 @@ function FinishedDownloadsCardsView({
|
||||
})
|
||||
}}
|
||||
stepSeconds={scrubberStepSeconds}
|
||||
durationSeconds={heatmapDuration}
|
||||
heatmapSegments={heatmapSegments}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@ -1145,6 +1157,7 @@ function FinishedDownloadsCardsView({
|
||||
formatBytes={formatBytes}
|
||||
lower={lower}
|
||||
jobForDetails={jobForDetails}
|
||||
handleScrubberClickIndex={handleScrubberClickIndex}
|
||||
handleDuration={handleDuration}
|
||||
handleResolution={handleResolution}
|
||||
resolutionLabelOf={resolutionLabelOf}
|
||||
@ -1311,6 +1324,10 @@ function FinishedDownloadsCardsView({
|
||||
doubleTapMaxMovePx={48}
|
||||
pressDelayMs={220}
|
||||
enablePressHold={canSpeedHold}
|
||||
verticalThresholdPx={92}
|
||||
verticalThresholdRatio={0.20}
|
||||
upAction={{ label: 'Öffnen' }}
|
||||
downAction={{ label: 'Weiter' }}
|
||||
onDoubleTap={async () => {
|
||||
if (isHot) return
|
||||
await onToggleHot?.(j)
|
||||
@ -1344,12 +1361,20 @@ function FinishedDownloadsCardsView({
|
||||
setMobileTopTeaserEnabled(false)
|
||||
return await keepVideo(j)
|
||||
}}
|
||||
onSwipeUp={() => {
|
||||
setMobileTopTeaserEnabled(false)
|
||||
openPlayer(j)
|
||||
return false
|
||||
}}
|
||||
onSwipeDown={() => {
|
||||
setMobileTopTeaserEnabled(false)
|
||||
skipTopMobileCard()
|
||||
return true
|
||||
}}
|
||||
onPressStart={(info) => {
|
||||
if (info.pointerType === 'mouse' && info.button !== 0) return
|
||||
if (!canSpeedHold) return
|
||||
|
||||
// Wichtig im Auswahl-Modus:
|
||||
// Falls der Teaser noch nicht läuft, beim Gedrückthalten aktivieren.
|
||||
setMobileTopTeaserEnabled(true)
|
||||
setSpeedHoldKey(k)
|
||||
}}
|
||||
@ -1370,16 +1395,22 @@ function FinishedDownloadsCardsView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mobileTopRow && mobileOrderedRows.length > 1 ? (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="soft"
|
||||
onClick={skipTopMobileCard}
|
||||
className="w-full"
|
||||
>
|
||||
Überspringen
|
||||
</Button>
|
||||
{mobileTopRow ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="text-center text-[11px] font-medium text-gray-500 dark:text-gray-400">
|
||||
← Löschen · → Behalten · ↑ Öffnen · ↓ Weiter · Doppeltipp HOT · Halten schneller
|
||||
</div>
|
||||
|
||||
{mobileOrderedRows.length > 1 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="soft"
|
||||
onClick={skipTopMobileCard}
|
||||
className="w-full"
|
||||
>
|
||||
Überspringen
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
'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 Checkbox from './Checkbox'
|
||||
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
||||
@ -27,9 +27,21 @@ import {
|
||||
shouldAnimateTeaser,
|
||||
shouldEnableTeaserAudio,
|
||||
} 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 PreviewMetaOverlay from './PreviewMetaOverlay'
|
||||
import { autoTagsForJob, mergeTags } from './autoTags'
|
||||
|
||||
export type FinishedDownloadsDesktopCardProps = {
|
||||
job: RecordJob
|
||||
@ -55,6 +67,11 @@ export type FinishedDownloadsDesktopCardProps = {
|
||||
lower: (s: string) => string
|
||||
|
||||
jobForDetails: (job: RecordJob) => RecordJob
|
||||
handleScrubberClickIndex: (
|
||||
job: RecordJob,
|
||||
segmentIndex: number,
|
||||
segmentCount: number
|
||||
) => void
|
||||
handleDuration: (job: RecordJob, seconds: number) => void
|
||||
handleResolution: (job: RecordJob, w: number, h: number) => void
|
||||
resolutionLabelOf: (job: RecordJob) => string
|
||||
@ -138,6 +155,7 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
formatBytes,
|
||||
lower,
|
||||
jobForDetails,
|
||||
handleScrubberClickIndex,
|
||||
handleDuration,
|
||||
handleResolution,
|
||||
resolutionLabelOf,
|
||||
@ -170,6 +188,9 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
const inlineActive = inlinePlay?.key === k
|
||||
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
|
||||
|
||||
const [scrubActiveIndex, setScrubActiveIndex] = useState<number | undefined>()
|
||||
const [scrubHovering, setScrubHovering] = useState(false)
|
||||
|
||||
const allowSound = shouldEnableTeaserAudio({
|
||||
state: teaserState,
|
||||
itemKey: k,
|
||||
@ -217,7 +238,10 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
lower
|
||||
)
|
||||
|
||||
const tags = parseFinishedTags(flags?.tags)
|
||||
const tags = mergeTags(
|
||||
parseFinishedTags(flags?.tags),
|
||||
autoTagsForJob(j)
|
||||
)
|
||||
const modelCountry = resolveFinishedCountry(flags, j)
|
||||
const dur = runtimeOf(j)
|
||||
const size = formatBytes(sizeBytesOf(j))
|
||||
@ -241,6 +265,57 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
const resLabelRaw = resolutionLabelOf(j)
|
||||
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)}`
|
||||
|
||||
return (
|
||||
@ -276,6 +351,8 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
onMouseLeave={() => {
|
||||
clearLongPressTimer()
|
||||
onHoverPreviewKeyChange?.(null)
|
||||
setScrubActiveIndex(undefined)
|
||||
setScrubHovering(false)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@ -352,8 +429,14 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
animated={shouldAnimateTeaser({
|
||||
state: teaserState,
|
||||
itemKey: k,
|
||||
disabled: false,
|
||||
disabled: spriteScrubbingActive,
|
||||
})}
|
||||
scrubProgressRatio={hasSpriteScrubber ? undefined : scrubProgressRatio}
|
||||
preferScrubProgress={
|
||||
!hasSpriteScrubber &&
|
||||
scrubHovering &&
|
||||
typeof scrubActiveIndex === 'number'
|
||||
}
|
||||
animatedMode="teaser"
|
||||
animatedTrigger="always"
|
||||
inlineVideo={inlineActive ? 'always' : false}
|
||||
@ -364,6 +447,44 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
popoverMuted={previewMuted}
|
||||
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>
|
||||
|
||||
@ -497,7 +618,8 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
prevInline.nonce === nextInline.nonce &&
|
||||
prev.modelsByKey === next.modelsByKey &&
|
||||
prev.activeTagSet === next.activeTagSet &&
|
||||
prev.renderPostworkBadge === next.renderPostworkBadge
|
||||
prev.renderPostworkBadge === next.renderPostworkBadge &&
|
||||
prev.handleScrubberClickIndex === next.handleScrubberClickIndex
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@ -26,13 +26,18 @@ import {
|
||||
readPreviewSpriteInfo,
|
||||
scrubProgressRatioFromIndex,
|
||||
} from './previewSprite'
|
||||
import {
|
||||
buildVideoHeatmapSegments,
|
||||
heatmapDurationForJob,
|
||||
} from './videoHeatmap'
|
||||
import ModelGenderIcon from './ModelGenderIcon'
|
||||
import { resolveFinishedModelFlags } from './finishedModelFlags'
|
||||
import { parseFinishedTags, resolveFinishedModelFlags } from './finishedModelFlags'
|
||||
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
||||
import SpritePreloader from './SpritePreloader'
|
||||
import PreviewRatingOverlay from './PreviewRatingOverlay'
|
||||
import PreviewMetaOverlay from './PreviewMetaOverlay'
|
||||
import DeletingPreviewOverlay from './DeletingPreviewOverlay'
|
||||
import { autoTagsForJob, mergeTags } from './autoTags'
|
||||
|
||||
function normalizeDurationSeconds(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
||||
@ -258,19 +263,11 @@ function FinishedDownloadsGalleryCardInner({
|
||||
)
|
||||
|
||||
const tags = useMemo(() => {
|
||||
const s = String(flags?.tags ?? '').trim()
|
||||
if (!s) return [] as string[]
|
||||
const parts = s.split(/[\n,;|]+/g).map((p) => p.trim()).filter(Boolean)
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const p of parts) {
|
||||
const kk = p.toLowerCase()
|
||||
if (seen.has(kk)) continue
|
||||
seen.add(kk)
|
||||
out.push(p)
|
||||
}
|
||||
return out
|
||||
}, [flags?.tags])
|
||||
return mergeTags(
|
||||
parseFinishedTags(flags?.tags),
|
||||
autoTagsForJob(j)
|
||||
)
|
||||
}, [flags?.tags, (j as any)?.meta])
|
||||
|
||||
const isFav = Boolean(flags?.favorite)
|
||||
const isLiked = flags?.liked === true
|
||||
@ -350,6 +347,16 @@ function FinishedDownloadsGalleryCardInner({
|
||||
normalizeDurationSeconds(meta?.media?.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 =
|
||||
hasSpriteScrubber && typeof activeScrubIndex === 'number'
|
||||
|
||||
@ -546,6 +553,8 @@ function FinishedDownloadsGalleryCardInner({
|
||||
handleScrubberClickIndex(j, index, scrubberCount)
|
||||
}}
|
||||
stepSeconds={scrubberStepSeconds}
|
||||
durationSeconds={heatmapDuration}
|
||||
heatmapSegments={heatmapSegments}
|
||||
/>
|
||||
</div>
|
||||
) : 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) {
|
||||
return (
|
||||
<IconBase
|
||||
@ -1051,11 +1025,6 @@ const SEGMENT_LABEL_META: SegmentLabelMeta[] = [
|
||||
text: 'Prone Bone',
|
||||
icon: ProneBoneIcon,
|
||||
},
|
||||
{
|
||||
match: ['sitting', 'sitting_sex', 'sitting-sex', 'seated', 'seated_sex'],
|
||||
text: 'Sitting',
|
||||
icon: SittingSexIcon,
|
||||
},
|
||||
{
|
||||
match: ['spooning', 'spoons', 'spoon'],
|
||||
text: 'Spooning',
|
||||
|
||||
@ -4,7 +4,29 @@
|
||||
|
||||
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 Button from './Button'
|
||||
import RecordJobActions from './RecordJobActions'
|
||||
@ -57,18 +79,6 @@ function isRunningJob(job: RecordJob): boolean {
|
||||
|
||||
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)
|
||||
const mdOnlineMem = new Map<string, OnlineCacheEntry>()
|
||||
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 {
|
||||
const host = String(model?.host ?? '').toLowerCase()
|
||||
const chatUrl = String(model?.chatRoomUrl ?? '').toLowerCase()
|
||||
@ -325,151 +333,6 @@ function heroNewPill() {
|
||||
const previewBlurCls = (blur?: boolean) =>
|
||||
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 = {
|
||||
open: boolean
|
||||
modelKey: string | null
|
||||
@ -496,6 +359,624 @@ type Props = {
|
||||
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 {
|
||||
let s = String(raw ?? '').trim()
|
||||
if (!s) return ''
|
||||
@ -684,6 +1165,9 @@ export default function ModelDetails({
|
||||
const [done, setDone] = useState<RecordJob[]>([])
|
||||
const [doneLoading, setDoneLoading] = useState(false)
|
||||
|
||||
const [statsDone, setStatsDone] = useState<RecordJob[]>([])
|
||||
const [statsLoading, setStatsLoading] = useState(false)
|
||||
|
||||
const [running, setRunning] = useState<RecordJob[]>([])
|
||||
const [runningLoading, setRunningLoading] = useState(false)
|
||||
|
||||
@ -701,10 +1185,11 @@ export default function ModelDetails({
|
||||
|
||||
const [donePage, setDonePage] = useState(1)
|
||||
const DONE_PAGE_SIZE = 4
|
||||
const MODEL_STATS_PAGE_SIZE = 1000
|
||||
|
||||
const key = normalizeModelKey(modelKey)
|
||||
|
||||
type TabKey = 'info' | 'downloads' | 'running'
|
||||
type TabKey = 'info' | 'stats' | 'downloads' | 'running'
|
||||
const [tab, setTab] = useState<TabKey>('info')
|
||||
|
||||
const bioReqRef = useRef<AbortController | null>(null)
|
||||
@ -935,12 +1420,46 @@ export default function ModelDetails({
|
||||
}
|
||||
}, [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 refetchStatsDoneRef = useRef(refetchStatsDone)
|
||||
|
||||
useEffect(() => {
|
||||
refetchDoneRef.current = refetchDone
|
||||
}, [refetchDone])
|
||||
|
||||
useEffect(() => {
|
||||
refetchStatsDoneRef.current = refetchStatsDone
|
||||
}, [refetchStatsDone])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
@ -952,6 +1471,7 @@ export default function ModelDetails({
|
||||
|
||||
const onDone = () => {
|
||||
void refetchDoneRef.current()
|
||||
void refetchStatsDoneRef.current()
|
||||
}
|
||||
|
||||
es.addEventListener('jobs', onJobs)
|
||||
@ -1005,6 +1525,11 @@ export default function ModelDetails({
|
||||
void refetchDone()
|
||||
}, [open, key, refetchDone])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !key) return
|
||||
void refetchStatsDone()
|
||||
}, [open, key, refetchStatsDone])
|
||||
|
||||
// Running jobs
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@ -1095,6 +1620,14 @@ export default function ModelDetails({
|
||||
})
|
||||
}, [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 titleUsername = useMemo(() => {
|
||||
@ -1519,8 +2052,23 @@ export default function ModelDetails({
|
||||
|
||||
const tabs: TabItem[] = [
|
||||
{ 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 =
|
||||
@ -1596,7 +2144,7 @@ export default function ModelDetails({
|
||||
tab === 'info' ? (
|
||||
<Button
|
||||
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}
|
||||
onClick={() => void refreshBio()}
|
||||
title="BioContext neu abrufen"
|
||||
@ -2383,7 +2931,7 @@ export default function ModelDetails({
|
||||
<div className="min-h-0">
|
||||
{/* INFO */}
|
||||
{tab === 'info' ? (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
{/* 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="flex items-center justify-between gap-3">
|
||||
@ -2603,6 +3151,17 @@ export default function ModelDetails({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
{/* STATS */}
|
||||
{tab === 'stats' ? (
|
||||
<div className="space-y-3">
|
||||
<ModelProfileStatsPanel
|
||||
stats={profileStats}
|
||||
loading={statsLoading}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* DOWNLOADS */}
|
||||
{tab === 'downloads' ? (
|
||||
<div className="min-h-0">
|
||||
|
||||
@ -2,7 +2,16 @@
|
||||
|
||||
'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
|
||||
|
||||
@ -13,6 +22,8 @@ type Props = {
|
||||
onIndexClick?: (index: number) => void
|
||||
className?: string
|
||||
stepSeconds?: number
|
||||
durationSeconds?: number
|
||||
heatmapSegments?: VideoHeatmapSegment[]
|
||||
}
|
||||
|
||||
function clamp(n: number, min: number, max: number) {
|
||||
@ -26,6 +37,76 @@ function formatClock(totalSeconds: number) {
|
||||
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({
|
||||
imageCount,
|
||||
activeIndex,
|
||||
@ -33,8 +114,16 @@ export default function PreviewScrubber({
|
||||
onIndexClick,
|
||||
className,
|
||||
stepSeconds = 0,
|
||||
durationSeconds = 0,
|
||||
heatmapSegments = [],
|
||||
}: Props) {
|
||||
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 pendingIndexRef = useRef<number | undefined>(undefined)
|
||||
@ -119,10 +208,8 @@ export default function PreviewScrubber({
|
||||
[indexFromClientX, onIndexClick]
|
||||
)
|
||||
|
||||
if (!imageCount || imageCount < 1) return null
|
||||
|
||||
const markerLeftPct =
|
||||
typeof activeIndex === 'number'
|
||||
typeof activeIndex === 'number' && imageCount > 0
|
||||
? ((activeIndex + 0.5) / imageCount) * 100
|
||||
: undefined
|
||||
|
||||
@ -135,6 +222,160 @@ export default function PreviewScrubber({
|
||||
|
||||
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 =
|
||||
typeof markerLeftPct === 'number'
|
||||
? {
|
||||
@ -169,10 +410,80 @@ export default function PreviewScrubber({
|
||||
aria-valuemax={imageCount}
|
||||
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' ? (
|
||||
<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={{
|
||||
left: `${markerLeftPct}%`,
|
||||
transform: 'translateX(-50%)',
|
||||
|
||||
@ -6,12 +6,27 @@ import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type MouseEvent,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
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 {
|
||||
SegmentLabelIcon,
|
||||
getSegmentLabelItem,
|
||||
@ -31,385 +46,6 @@ type RatingEntry = {
|
||||
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[] {
|
||||
const position = typeof obj.position === 'string' && obj.position.trim()
|
||||
? `Position: ${prettyAiLabel(obj.position)}`
|
||||
@ -417,7 +53,7 @@ function readSegmentTags(obj: Record<string, unknown>): string[] {
|
||||
|
||||
const tags = [
|
||||
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),
|
||||
]
|
||||
|
||||
@ -1207,7 +843,7 @@ function readSegmentArray(metaRaw: unknown): unknown[] {
|
||||
])
|
||||
|
||||
const key = [
|
||||
normalizeSegmentLabel(label),
|
||||
tagsFromAiLabel(label).join('+'),
|
||||
typeof start === 'number' ? start.toFixed(1) : '',
|
||||
typeof end === 'number' ? end.toFixed(1) : '',
|
||||
].join('|')
|
||||
@ -1237,10 +873,10 @@ function iconLabelsFromRawValue(value: unknown): string[] {
|
||||
if (!raw) return []
|
||||
|
||||
if (raw.toLowerCase().startsWith('combo:')) {
|
||||
return rawSegmentLabelParts(raw)
|
||||
return rawAiLabelParts(raw)
|
||||
}
|
||||
|
||||
return splitMaybeComboOrList(raw)
|
||||
return splitAiComboOrList(raw)
|
||||
}
|
||||
|
||||
function readSegmentIconLabels(obj: Record<string, unknown>): string[] {
|
||||
@ -1270,7 +906,7 @@ function readSegmentIconLabels(obj: Record<string, unknown>): string[] {
|
||||
const clean = String(label ?? '').trim()
|
||||
if (!clean) continue
|
||||
|
||||
const key = normalizeSegmentLabel(clean)
|
||||
const key = normalizeAiLabel(clean)
|
||||
if (!key || seen.has(key)) continue
|
||||
|
||||
seen.add(key)
|
||||
@ -1322,7 +958,7 @@ export function readRatingSegments(metaRaw: unknown): RatingSegment[] {
|
||||
firstString(raw, ['title', 'label', 'category', 'type', 'kind', 'name']) ??
|
||||
`Segment ${index + 1}`
|
||||
|
||||
const title = segmentTitleFromRawLabel(rawTitle)
|
||||
const title = segmentTitleFromLabel(rawTitle)
|
||||
|
||||
const rawDetail =
|
||||
firstString(raw, ['summary', 'reason', 'description', 'text', 'caption', 'note']) ??
|
||||
|
||||
@ -42,11 +42,17 @@ export type SwipeCardProps = {
|
||||
onTap?: () => void
|
||||
onSwipeLeft: () => 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
|
||||
leftAction?: SwipeAction
|
||||
rightAction?: SwipeAction
|
||||
upAction?: SwipeAction
|
||||
downAction?: SwipeAction
|
||||
thresholdPx?: number
|
||||
thresholdRatio?: number
|
||||
verticalThresholdPx?: number
|
||||
verticalThresholdRatio?: number
|
||||
snapMs?: number
|
||||
commitMs?: number
|
||||
ignoreFromBottomPx?: number
|
||||
@ -65,6 +71,8 @@ export type SwipeCardProps = {
|
||||
export type SwipeCardHandle = {
|
||||
swipeLeft: (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
|
||||
}
|
||||
|
||||
@ -74,6 +82,9 @@ const FLICK_VELOCITY = 0.55
|
||||
const MAX_OVERSWIPE_PX = 64
|
||||
const ROTATE_MAX_DEG = 5
|
||||
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) {
|
||||
return Math.max(min, Math.min(max, v))
|
||||
@ -101,9 +112,17 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
||||
onTap,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
onSwipeUp,
|
||||
onSwipeDown,
|
||||
className,
|
||||
leftAction,
|
||||
rightAction,
|
||||
upAction = { label: 'Öffnen' },
|
||||
downAction = { label: 'Weiter' },
|
||||
thresholdPx = 140,
|
||||
thresholdRatio = 0.28,
|
||||
verticalThresholdPx = 110,
|
||||
verticalThresholdRatio = 0.24,
|
||||
ignoreFromBottomPx = 72,
|
||||
ignoreSelector = '[data-swipe-ignore]',
|
||||
snapMs = 220,
|
||||
@ -129,14 +148,22 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
||||
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const dxRef = useRef(0)
|
||||
const dyRef = useRef(0)
|
||||
|
||||
const thresholdRef = useRef(0)
|
||||
const verticalThresholdRef = useRef(0)
|
||||
|
||||
const widthRef = useRef(360)
|
||||
const heightRef = useRef(520)
|
||||
|
||||
const tapTimerRef = useRef<number | null>(null)
|
||||
const lastTapRef = useRef<{ t: number; x: number; y: number } | null>(null)
|
||||
|
||||
const velocityXRef = useRef(0)
|
||||
const velocityYRef = useRef(0)
|
||||
|
||||
const lastMoveXRef = useRef(0)
|
||||
const lastMoveYRef = useRef(0)
|
||||
const lastMoveTRef = useRef(0)
|
||||
|
||||
const pointer = useRef<{
|
||||
@ -160,9 +187,13 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
||||
})
|
||||
|
||||
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 hasVerticalSwipe = Boolean(onSwipeUp || onSwipeDown)
|
||||
const baseTouchAction = hasVerticalSwipe ? 'none' : 'pan-y'
|
||||
|
||||
const clearTapTimer = useCallback(() => {
|
||||
if (tapTimerRef.current != null) {
|
||||
window.clearTimeout(tapTimerRef.current)
|
||||
@ -186,12 +217,16 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
||||
onPressEnd?.()
|
||||
}, [clearPressTimer, onPressEnd])
|
||||
|
||||
const flushDx = useCallback((nextDx: number) => {
|
||||
const flushDrag = useCallback((nextDx: number, nextDy: number) => {
|
||||
dxRef.current = nextDx
|
||||
dyRef.current = nextDy
|
||||
|
||||
if (rafRef.current != null) return
|
||||
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null
|
||||
setDx(dxRef.current)
|
||||
setDy(dyRef.current)
|
||||
})
|
||||
}, [])
|
||||
|
||||
@ -205,66 +240,107 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
||||
}
|
||||
|
||||
dxRef.current = 0
|
||||
dyRef.current = 0
|
||||
|
||||
velocityXRef.current = 0
|
||||
velocityYRef.current = 0
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.style.touchAction = 'pan-y'
|
||||
cardRef.current.style.touchAction = baseTouchAction
|
||||
}
|
||||
|
||||
setAnimMs(snapMs)
|
||||
setDx(0)
|
||||
setDy(0)
|
||||
setArmedDir(null)
|
||||
|
||||
window.setTimeout(() => {
|
||||
setAnimMs(0)
|
||||
}, snapMs)
|
||||
}, [snapMs, clearPressTimer])
|
||||
}, [snapMs, clearPressTimer, baseTouchAction])
|
||||
|
||||
const commit = useCallback(
|
||||
async (dir: 'left' | 'right', runAction: boolean) => {
|
||||
async (dir: CommitDir, runAction: boolean) => {
|
||||
if (rafRef.current != null) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = null
|
||||
}
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.style.touchAction = 'pan-y'
|
||||
cardRef.current.style.touchAction = baseTouchAction
|
||||
}
|
||||
|
||||
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)
|
||||
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) => {
|
||||
window.setTimeout(resolve, commitMs)
|
||||
})
|
||||
|
||||
// Wichtig: erst rausanimieren lassen, dann Aktion starten
|
||||
await animPromise
|
||||
|
||||
let ok: boolean | void = true
|
||||
|
||||
if (runAction) {
|
||||
ok = await Promise.resolve(
|
||||
dir === 'right' ? onSwipeRight() : onSwipeLeft()
|
||||
).catch(() => false)
|
||||
const action =
|
||||
dir === 'right'
|
||||
? onSwipeRight
|
||||
: dir === 'left'
|
||||
? onSwipeLeft
|
||||
: dir === 'up'
|
||||
? onSwipeUp
|
||||
: onSwipeDown
|
||||
|
||||
if (!action) {
|
||||
ok = false
|
||||
} else {
|
||||
ok = await Promise.resolve(action()).catch(() => false)
|
||||
}
|
||||
}
|
||||
|
||||
if (ok === false) {
|
||||
setAnimMs(snapMs)
|
||||
setArmedDir(null)
|
||||
setDx(0)
|
||||
|
||||
dxRef.current = 0
|
||||
dyRef.current = 0
|
||||
|
||||
setDx(0)
|
||||
setDy(0)
|
||||
|
||||
window.setTimeout(() => setAnimMs(0), snapMs)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
[commitMs, onSwipeLeft, onSwipeRight, snapMs]
|
||||
[
|
||||
commitMs,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
onSwipeUp,
|
||||
onSwipeDown,
|
||||
snapMs,
|
||||
baseTouchAction,
|
||||
]
|
||||
)
|
||||
|
||||
const softResetForTap = useCallback(() => {
|
||||
@ -274,17 +350,20 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
||||
}
|
||||
|
||||
dxRef.current = 0
|
||||
dyRef.current = 0
|
||||
|
||||
setAnimMs(0)
|
||||
setDx(0)
|
||||
setDy(0)
|
||||
setArmedDir(null)
|
||||
|
||||
try {
|
||||
const el = cardRef.current
|
||||
if (el) el.style.touchAction = 'pan-y'
|
||||
if (el) el.style.touchAction = baseTouchAction
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
}, [baseTouchAction])
|
||||
|
||||
const runHotFx = useCallback(
|
||||
(clientX?: number, clientY?: number) => {
|
||||
@ -413,21 +492,67 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
||||
() => ({
|
||||
swipeLeft: (opts) => commit('left', 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(),
|
||||
}),
|
||||
[commit, reset]
|
||||
)
|
||||
|
||||
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 =
|
||||
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 revealSoft = clamp(absDx / Math.max(1, activeThreshold * 1.35), 0, 1)
|
||||
const activeVerticalThreshold =
|
||||
verticalThresholdRef.current ||
|
||||
Math.min(
|
||||
verticalThresholdPx,
|
||||
(cardRef.current?.offsetHeight || 520) * verticalThresholdRatio
|
||||
)
|
||||
|
||||
const tiltDeg = clamp(dx / 34, -ROTATE_MAX_DEG, ROTATE_MAX_DEG)
|
||||
const dragScale = dx === 0 ? 1 : Math.max(SCALE_MIN, 1 - revealSoft * 0.008)
|
||||
const reveal =
|
||||
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 (
|
||||
<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"
|
||||
style={{
|
||||
transform:
|
||||
dx !== 0
|
||||
? `translate3d(${dx}px,0,0) rotate(${tiltDeg}deg) scale(${dragScale})`
|
||||
: undefined,
|
||||
dx !== 0 || dy !== 0
|
||||
? `translate3d(${dx}px,${dy}px,0) rotate(${tiltDeg}deg) scale(${dragScale})`
|
||||
: undefined,
|
||||
transition: animMs
|
||||
? `transform ${animMs}ms cubic-bezier(0.22, 1, 0.36, 1)`
|
||||
: undefined,
|
||||
touchAction: 'pan-y',
|
||||
willChange: dx !== 0 ? 'transform' : undefined,
|
||||
borderRadius: dx !== 0 ? '12px' : undefined,
|
||||
filter: dx !== 0 ? `saturate(${1 + reveal * 0.06}) brightness(${1 + reveal * 0.015})` : undefined,
|
||||
touchAction: baseTouchAction,
|
||||
willChange: dx !== 0 || dy !== 0 ? 'transform' : undefined,
|
||||
borderRadius: dx !== 0 || dy !== 0 ? '12px' : undefined,
|
||||
filter:
|
||||
dx !== 0 || dy !== 0
|
||||
? `saturate(${1 + reveal * 0.06}) brightness(${1 + reveal * 0.015})`
|
||||
: undefined,
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (!enabled || disabled) return
|
||||
@ -523,8 +651,13 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
||||
}
|
||||
|
||||
const w = cardRef.current?.offsetWidth || 360
|
||||
const h = cardRef.current?.offsetHeight || 520
|
||||
|
||||
widthRef.current = w
|
||||
heightRef.current = h
|
||||
|
||||
thresholdRef.current = Math.min(thresholdPx, w * thresholdRatio)
|
||||
verticalThresholdRef.current = Math.min(verticalThresholdPx, h * verticalThresholdRatio)
|
||||
|
||||
pointer.current = {
|
||||
id: e.pointerId,
|
||||
@ -538,9 +671,14 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
||||
}
|
||||
|
||||
lastMoveXRef.current = e.clientX
|
||||
lastMoveYRef.current = e.clientY
|
||||
lastMoveTRef.current = performance.now()
|
||||
|
||||
velocityXRef.current = 0
|
||||
velocityYRef.current = 0
|
||||
|
||||
dxRef.current = 0
|
||||
dyRef.current = 0
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (!enabled || disabled) return
|
||||
@ -558,19 +696,24 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
||||
|
||||
if (absY > absX * LOCK_RATIO) {
|
||||
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'
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (pointer.current.axisLocked !== 'x') return
|
||||
if (pointer.current.axisLocked !== 'x' && pointer.current.axisLocked !== 'y') {
|
||||
return
|
||||
}
|
||||
|
||||
if (!pointer.current.dragging) {
|
||||
clearPressTimer()
|
||||
@ -588,28 +731,69 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
||||
|
||||
const now = performance.now()
|
||||
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
|
||||
lastMoveYRef.current = e.clientY
|
||||
lastMoveTRef.current = now
|
||||
|
||||
const next = rubberBand(ddx, widthRef.current)
|
||||
flushDx(next)
|
||||
if (pointer.current.axisLocked === 'x') {
|
||||
const next = rubberBand(ddx, widthRef.current)
|
||||
flushDrag(next, 0)
|
||||
|
||||
const threshold = thresholdRef.current
|
||||
const nextDir = next > threshold ? 'right' : next < -threshold ? 'left' : null
|
||||
const threshold = thresholdRef.current
|
||||
const nextDir =
|
||||
next > threshold
|
||||
? 'right'
|
||||
: next < -threshold
|
||||
? 'left'
|
||||
: null
|
||||
|
||||
setArmedDir((prev) => {
|
||||
if (prev === nextDir) return prev
|
||||
if (nextDir) {
|
||||
try {
|
||||
navigator.vibrate?.(10)
|
||||
} catch {
|
||||
// ignore
|
||||
setArmedDir((prev) => {
|
||||
if (prev === nextDir) return prev
|
||||
|
||||
if (nextDir) {
|
||||
try {
|
||||
navigator.vibrate?.(10)
|
||||
} 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) => {
|
||||
if (!enabled || disabled) return
|
||||
@ -711,19 +895,50 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
||||
}
|
||||
|
||||
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 vy = velocityYRef.current
|
||||
|
||||
const fastRight = vx > FLICK_VELOCITY
|
||||
const fastLeft = vx < -FLICK_VELOCITY
|
||||
const fastDown = vy > VERTICAL_FLICK_VELOCITY
|
||||
const fastUp = vy < -VERTICAL_FLICK_VELOCITY
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
if (finalDx < -threshold || (fastLeft && abs >= threshold * 0.5)) {
|
||||
if (finalDx < -threshold || (fastLeft && absX >= threshold * 0.5)) {
|
||||
void commit('left', true)
|
||||
return
|
||||
}
|
||||
@ -760,10 +975,13 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
||||
}
|
||||
|
||||
dxRef.current = 0
|
||||
dyRef.current = 0
|
||||
|
||||
velocityXRef.current = 0
|
||||
velocityYRef.current = 0
|
||||
|
||||
try {
|
||||
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
|
||||
;(e.currentTarget as HTMLElement).style.touchAction = baseTouchAction
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@ -780,10 +998,14 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
|
||||
opacity: dx === 0 ? 0 : 0.08 + revealSoft * 0.16,
|
||||
background:
|
||||
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'
|
||||
? 'linear-gradient(270deg, rgba(244,63,94,0.16) 0%, rgba(244,63,94,0.05) 42%, rgba(0,0,0,0) 100%)'
|
||||
: 'transparent',
|
||||
? 'linear-gradient(270deg, rgba(244,63,94,0.18) 0%, rgba(244,63,94,0.06) 42%, rgba(0,0,0,0) 100%)'
|
||||
: 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,
|
||||
boxShadow:
|
||||
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'
|
||||
? 'inset 0 0 0 1px rgba(244,63,94,0.40), inset 0 0 28px rgba(244,63,94,0.10)'
|
||||
: 'none',
|
||||
? 'inset 0 0 0 1px rgba(244,63,94,0.42), inset 0 0 28px rgba(244,63,94,0.12)'
|
||||
: 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>
|
||||
|
||||
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 = {
|
||||
id: string
|
||||
input: string
|
||||
host?: string
|
||||
input?: string
|
||||
host?: string | null
|
||||
modelKey: string
|
||||
watching: boolean
|
||||
watching?: boolean
|
||||
favorite?: boolean
|
||||
liked?: boolean | null
|
||||
tags?: string
|
||||
hot?: boolean
|
||||
keep?: boolean
|
||||
tags?: string | null
|
||||
isUrl?: boolean
|
||||
path?: string
|
||||
|
||||
gender?: string | null
|
||||
country?: string | null
|
||||
|
||||
lastSeenOnline?: boolean | null
|
||||
lastSeenOnlineAt?: string
|
||||
|
||||
roomStatus?: string
|
||||
isOnline?: boolean
|
||||
chatRoomUrl?: string
|
||||
@ -393,6 +402,12 @@ export type StoredModel = {
|
||||
lastOnlineAt?: string
|
||||
lastOfflineAt?: string
|
||||
lastRoomSyncAt?: string
|
||||
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
cbOnlineJson?: string | null
|
||||
cbOnlineFetchedAt?: string | null
|
||||
cbOnlineLastError?: string | null
|
||||
}
|
||||
|
||||
export type PendingWatchedRoom = {
|
||||
@ -465,3 +480,265 @@ export type RunFileMutationOptions<T> = {
|
||||
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