From f310804b4dd472997e7dcf4d98021c35e54ed709 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:24:50 +0200 Subject: [PATCH] added heatmap + more filters + bugfixes --- backend/analyze.go | 2 +- backend/data/pending-autostart/admin.json | 3 + backend/ml/detection_labels.json | 1 - backend/rating.go | 3 +- backend/recorder_settings.json | 4 +- backend/server.go | 140 ++- frontend/src/App.tsx | 1 - frontend/src/aiLabels.ts | 453 +++++++++ .../src/components/ui/FinishedDownloads.tsx | 569 ++++++++++- .../ui/FinishedDownloadsCardsView.tsx | 57 +- .../ui/FinishedDownloadsDesktopCard.tsx | 132 ++- .../ui/FinishedDownloadsGalleryCard.tsx | 37 +- .../src/components/ui/FinishedReportModal.tsx | 846 +++++++++++++++++ frontend/src/components/ui/Icons.tsx | 31 - frontend/src/components/ui/ModelDetails.tsx | 889 ++++++++++++++---- .../src/components/ui/PreviewScrubber.tsx | 323 ++++++- frontend/src/components/ui/RatingOverlay.tsx | 410 +------- frontend/src/components/ui/SwipeCard.tsx | 366 +++++-- frontend/src/components/ui/autoTags.ts | 248 +++++ frontend/src/components/ui/videoHeatmap.ts | 329 +++++++ frontend/src/types.ts | 285 +++++- 21 files changed, 4391 insertions(+), 738 deletions(-) create mode 100644 backend/data/pending-autostart/admin.json create mode 100644 frontend/src/aiLabels.ts create mode 100644 frontend/src/components/ui/FinishedReportModal.tsx create mode 100644 frontend/src/components/ui/autoTags.ts create mode 100644 frontend/src/components/ui/videoHeatmap.ts diff --git a/backend/analyze.go b/backend/analyze.go index 9bfbfe8..b72c76c 100644 --- a/backend/analyze.go +++ b/backend/analyze.go @@ -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 diff --git a/backend/data/pending-autostart/admin.json b/backend/data/pending-autostart/admin.json new file mode 100644 index 0000000..fc69ce2 --- /dev/null +++ b/backend/data/pending-autostart/admin.json @@ -0,0 +1,3 @@ +{ + "items": [] +} \ No newline at end of file diff --git a/backend/ml/detection_labels.json b/backend/ml/detection_labels.json index ae2c685..6889c34 100644 --- a/backend/ml/detection_labels.json +++ b/backend/ml/detection_labels.json @@ -14,7 +14,6 @@ "standing", "standing_doggy", "spooning", - "sitting", "facesitting", "handjob", "blowjob", diff --git a/backend/rating.go b/backend/rating.go index 54eb004..b0014af 100644 --- a/backend/rating.go +++ b/backend/rating.go @@ -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 diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json index 571ff28..19c714a 100644 --- a/backend/recorder_settings.json +++ b/backend/recorder_settings.json @@ -13,8 +13,8 @@ "useMyFreeCamsApi": true, "autoDeleteSmallDownloads": true, "autoDeleteSmallDownloadsBelowMB": 300, - "autoDeleteSmallDownloadsKeepFavorites": true, - "autoDeleteLowRatedDownloads": false, + "autoDeleteSmallDownloadsKeepFavorites": false, + "autoDeleteLowRatedDownloads": true, "autoDeleteLowRatedDownloadsMaxStars": 3, "lowDiskPauseBelowGB": 5, "blurPreviews": false, diff --git a/backend/server.go b/backend/server.go index b8457fd..1a3c6d4 100644 --- a/backend/server.go +++ b/backend/server.go @@ -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) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1fb7bb5..dccd439 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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' ? ( diff --git a/frontend/src/aiLabels.ts b/frontend/src/aiLabels.ts new file mode 100644 index 0000000..e1a0842 --- /dev/null +++ b/frontend/src/aiLabels.ts @@ -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 = { + 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 = { + 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(AI_POSITION_LABELS) +const BODY_SET = new Set(AI_BODY_LABELS) +const OBJECT_SET = new Set(AI_OBJECT_LABELS) +const CLOTHING_SET = new Set(AI_CLOTHING_LABELS) +const PERSON_SET = new Set(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 +): 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): 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) +} \ No newline at end of file diff --git a/frontend/src/components/ui/FinishedDownloads.tsx b/frontend/src/components/ui/FinishedDownloads.tsx index 66e669e..c431635 100644 --- a/frontend/src/components/ui/FinishedDownloads.tsx +++ b/frontend/src/components/ui/FinishedDownloads.tsx @@ -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 +): 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 ( + + + + ) +} + +function RatingStarsFilter({ + value, + onChange, +}: { + value: RatingSidebarFilter + onChange: (next: RatingSidebarFilter) => void +}) { + const selected = ratingFilterStars(value) + const [hoverStars, setHoverStars] = useState(null) + + const previewStars = hoverStars ?? selected ?? 0 + + return ( +
setHoverStars(null)} + > + {[1, 2, 3, 4, 5].map((stars) => { + const checked = selected === stars + const filled = stars <= previewStars + + return ( + + ) + })} +
+ ) +} + +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('all') + + const [resolutionFilter, setResolutionFilter] = + useState('all') + + const [phaseFilter, setPhaseFilter] = + useState('all') + const [postworkByFile, setPostworkByFile] = useState>({}) @@ -2226,18 +2454,41 @@ export default function FinishedDownloads({ const seen = new Set() 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( () => ({ @@ -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 => { + 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 ( +
+
+
+
+ Analysefilter +
+
+ Bewertung, abgeschlossene Phasen und Auflösung +
+
+ + {anyAnalysisFilterActive ? ( + + ) : null} +
+ +
+
+
+
+ Bewertung +
+
+ +
+ + + +
+ +
+ { + if (page !== 1) onPageChange(1) + setRatingFilter(next) + }} + /> +
+
+ +
+
+
+ Phase +
+
+ +
+ {PHASE_SIDEBAR_FILTER_OPTIONS.map((option) => { + const active = phaseFilter === option.value + + return ( + + ) + })} +
+
+ +
+
+
+ Auflösung +
+
+ +
+ {RESOLUTION_SIDEBAR_FILTER_OPTIONS.map((option) => { + const active = resolutionFilter === option.value + + return ( + + ) + })} +
+
+
+
+ ) + } + // ----------------------------------------------------------------------------- // render // ----------------------------------------------------------------------------- return ( <> + setReportOpen(false)} + /> +
{/* Toolbar links neben dem Content, nicht per Transform aus dem Container geschoben */} -
@@ -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 ) } ) diff --git a/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx b/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx index cd538e2..46f9834 100644 --- a/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx +++ b/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx @@ -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() - 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} /> ) : null} diff --git a/frontend/src/components/ui/FinishedReportModal.tsx b/frontend/src/components/ui/FinishedReportModal.tsx new file mode 100644 index 0000000..952f2c5 --- /dev/null +++ b/frontend/src/components/ui/FinishedReportModal.tsx @@ -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 | 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 + : null + } catch { + return null + } + } + + return typeof metaRaw === 'object' && !Array.isArray(metaRaw) + ? metaRaw as Record + : 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, 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, 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() + const tagCounts = new Map() + + const dailyMap = new Map() + 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 ( +
+
+
+
+ {label} +
+ +
+ {value} +
+ + {sub ? ( +
+ {sub} +
+ ) : null} +
+ +
+ {icon} +
+
+
+ ) +} + +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 ( +
+
+
+
+ {title} +
+
+ {items.length > 0 ? `${fmtInt(total)} Treffer gesamt` : 'Keine Daten'} +
+
+ + + Top {fmtInt(items.length)} + +
+ +
+ {items.length === 0 ? ( +
+ {empty} +
+ ) : ( +
+ {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 ( +
+
+
+ + #{index + 1} + + + {icons ? ( + + + + ) : null} + +
+
+ {item.label} +
+
+ {share}% Anteil +
+
+
+ + + {fmtInt(item.count)} + +
+ +
+
+
+
+ ) + })} +
+ )} +
+
+ ) +} + +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 ( +
+
+
+
+ Verlauf +
+
+ Downloads pro Tag +
+
+ + +
+ +
+
+ {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 ( +
+
+ + {item.label} + + + {fmtInt(item.count)} · {fmtDuration(item.durationSeconds)} · {share}% + +
+ +
+
+
+
+ ) + })} +
+
+
+ ) +} + +function RatingDistribution({ stats }: { stats: FinishedReportStats }) { + const max = Math.max(1, ...([1, 2, 3, 4, 5] as const).map((n) => stats.ratingCounts[n])) + + return ( +
+
+
+
+ Bewertung +
+
+ {fmtInt(stats.ratedCount)} bewertete Downloads +
+
+ + +
+ +
+
+ {([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 ( +
+
+ + {'★'.repeat(stars)} + + {'★'.repeat(5 - stars)} + + + + {fmtInt(count)} + +
+ +
+
+
+
+ ) + })} +
+
+
+ ) +} + +export default function FinishedReportModal({ + open, + onClose, +}: { + open: boolean + onClose: () => void +}) { + const [mode, setMode] = useState('day') + const [rows, setRows] = useState([]) + 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 = ( + setMode(id as FinishedReportMode)} + size="md" + ariaLabel="Report-Zeitraum" + items={[ + { + id: 'day', + label: 'Heute', + icon: , + }, + { + id: 'week', + label: 'Diese Woche', + icon: , + }, + ]} + /> + ) + + const refreshButton = ( + + ) + + return ( + + {reportModeSwitch} + {refreshButton} + + } + > +
+
+ {reportModeSwitch} + {refreshButton} +
+
+
+
+
+
+ + {mode === 'day' ? 'Tagesreport' : 'Wochenreport'} +
+ +

+ {mode === 'day' ? 'Heute' : 'Diese Woche'} +

+ +
+ {rangeLabel(stats.start, stats.end)} +
+
+
+ +
+ {loading ? ( + + + Lädt… + + ) : ( + + {partialHint} + + )} + + + Ø Dauer: {fmtDuration(avgDuration)} + + + + Ø Größe: {avgSize > 0 ? fmtBytes(avgSize) : '—'} + +
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+ } + tone="sky" + /> + + } + /> + + 0 ? fmtBytes(stats.totalSizeBytes) : '—'} + sub={stats.knownSizeCount > 0 ? `${fmtInt(stats.knownSizeCount)} Dateien` : 'keine Größen'} + icon={} + tone="emerald" + /> + + } + tone="amber" + /> +
+
+
+ +
+ + +
+ +
+ + + +
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/ui/Icons.tsx b/frontend/src/components/ui/Icons.tsx index 099c869..5185519 100644 --- a/frontend/src/components/ui/Icons.tsx +++ b/frontend/src/components/ui/Icons.tsx @@ -256,32 +256,6 @@ export function ProneBoneIcon(props: IconProps) { ) } -export function SittingSexIcon(props: IconProps) { - return ( - - {/* sitzende Person */} - - - - - - - - {/* zweite Person unten / Schoß */} - - - - - {/* Kontakt */} - - - ) -} - export function SpooningSexIcon(props: IconProps) { return ( | null -} - -type BioCacheEntry = { - at: number - bio: BioContext | null - meta: Pick | null -} - // In-Memory (über Modal-Open/Close hinweg, solange Tab offen) const mdOnlineMem = new Map() const mdBioMem = new Map() @@ -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 } +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, + 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 +) { + 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() + + 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 ( +
+ Für diese Kategorie wurden noch keine Inhalte erkannt. +
+ ) + } + + return ( +
+
+
+ {title} +
+
+ {description} +
+
+ +
+ {values.map((item) => { + const shareWidth = countPercent(item.count, total) + + return ( +
+
+
+
+ +
+ +
+
+ {item.label} +
+
+ {fmtInt(item.count)} Treffer +
+
+
+ +
+ {shareWidth} +
+
+ +
+
+
+
+ ) + })} +
+
+ ) +} + +function ModelProfileStatsPanel({ + stats, + loading, +}: { + stats: ModelProfileStats + loading: boolean +}) { + const [activeTab, setActiveTab] = + useState('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 ( +
+
+
+
+
+ Profil-Stats +
+
+ Wie TrainingStats: Kennzahlen und erkannte Label-Gruppen +
+
+ + {loading ? ( + + Lädt… + + ) : ( + + {loadedHint} + + )} +
+
+ +
+
+
+
+ Downloads +
+
+ {fmtInt(stats.totalCount)} +
+
+ {stats.runningCount > 0 + ? `${fmtInt(stats.runningCount)} laufen` + : 'abgeschlossen'} +
+
+ +
+
+ Laufzeit +
+
+ {fmtHms(stats.totalDurationSec)} +
+
+ gesamte Videodauer +
+
+ +
+
+ Speicher +
+
+ {stats.knownBytesCount > 0 ? fmtBytes(stats.totalBytes) : '—'} +
+
+ Ø {avgStorage != null ? fmtBytes(avgStorage) : '—'} +
+
+
+ +
+
+ {tabItems.map((item) => { + const active = item.key === activeTab + const top = item.topLabel + const TopIconLabel = top?.iconLabel ?? top?.label ?? item.title + + return ( + + ) + })} +
+
+ + +
+
+ ) +} + 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([]) const [doneLoading, setDoneLoading] = useState(false) + const [statsDone, setStatsDone] = useState([]) + const [statsLoading, setStatsLoading] = useState(false) + const [running, setRunning] = useState([]) 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('info') const bioReqRef = useRef(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' ? (