added heatmap + more filters + bugfixes

This commit is contained in:
Linrador 2026-06-09 14:24:50 +02:00
parent 2fb3d4f00b
commit f310804b4d
21 changed files with 4391 additions and 738 deletions

View File

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

View File

@ -0,0 +1,3 @@
{
"items": []
}

View File

@ -14,7 +14,6 @@
"standing",
"standing_doggy",
"spooning",
"sitting",
"facesitting",
"handjob",
"blowjob",

View File

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

View File

@ -13,8 +13,8 @@
"useMyFreeCamsApi": true,
"autoDeleteSmallDownloads": true,
"autoDeleteSmallDownloadsBelowMB": 300,
"autoDeleteSmallDownloadsKeepFavorites": true,
"autoDeleteLowRatedDownloads": false,
"autoDeleteSmallDownloadsKeepFavorites": false,
"autoDeleteLowRatedDownloads": true,
"autoDeleteLowRatedDownloadsMaxStars": 3,
"lowDiskPauseBelowGB": 5,
"blurPreviews": false,

View File

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

View File

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

View File

@ -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
const PHASE_SIDEBAR_FILTER_OPTIONS: Array<{
value: PhaseSidebarFilter
label: string
start: number
end: number
duration: number
}> = [
{ 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}

View File

@ -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,8 +1395,13 @@ function FinishedDownloadsCardsView({
</div>
)}
{mobileTopRow && mobileOrderedRows.length > 1 ? (
<div className="mt-3 flex justify-center">
{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"
@ -1380,6 +1410,7 @@ function FinishedDownloadsCardsView({
>
Überspringen
</Button>
) : null}
</div>
) : null}
</div>

View File

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

View File

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

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

View File

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

View File

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

View File

@ -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 inset-y-0 w-[2px] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.35)]"
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 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%)',

View File

@ -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']) ??

View File

@ -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})`
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()
if (!hasVerticalSwipe) {
pointer.current.axisLocked = 'y'
pointer.current.id = null
return
}
if (absX > absY * LOCK_RATIO) {
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,19 +731,29 @@ 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
if (pointer.current.axisLocked === 'x') {
const next = rubberBand(ddx, widthRef.current)
flushDx(next)
flushDrag(next, 0)
const threshold = thresholdRef.current
const nextDir = next > threshold ? 'right' : next < -threshold ? 'left' : null
const nextDir =
next > threshold
? 'right'
: next < -threshold
? 'left'
: null
setArmedDir((prev) => {
if (prev === nextDir) return prev
if (nextDir) {
try {
navigator.vibrate?.(10)
@ -608,8 +761,39 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
// ignore
}
}
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,9 +998,13 @@ 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%)'
? '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)'
? '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>

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

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

View File

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