This commit is contained in:
Linrador 2026-06-10 09:39:07 +02:00
parent 98e7bc70f2
commit 43449405e8
18 changed files with 376 additions and 360 deletions

BIN
Icons/boobjob.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -1,4 +1,4 @@
# backend/ai_server.py
# backend\ai_server.py
import json
import os

View File

@ -1509,7 +1509,7 @@ func buildHighlightHitsFromPrediction(pred TrainingPrediction, t float64) []anal
continue
}
// Schwache Positions-Kontexte wie standing/sitting nicht alleine als Segment anzeigen.
// Schwache Positions-Kontexte wie standing nicht alleine als Segment anzeigen.
if sig.Group == "position" && segmentSeverityWeight(label) < 0.70 {
continue
}

View File

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

View File

@ -8,7 +8,7 @@ import (
"path/filepath"
)
//go:embed ml/*.py ml/*.json ai_server.py .env recorder-cert.pem recorder-key.pem
//go:embed ml/*.py ai_server.py .env recorder-cert.pem recorder-key.pem generated/training/detection_labels.json
var embeddedMLFiles embed.FS
func trainingEmbeddedMLDir() (string, error) {
@ -33,6 +33,10 @@ func trainingEmbeddedMLDir() (string, error) {
for _, name := range append(files, optionalFiles...) {
srcPath := filepath.ToSlash(filepath.Join("ml", name))
if name == "detection_labels.json" {
srcPath = "generated/training/detection_labels.json"
}
b, err := embeddedMLFiles.ReadFile(srcPath)
if err != nil {
// Pflichtdateien müssen vorhanden sein.

View File

@ -1,56 +0,0 @@
{
"people": [
"person_female",
"person_male"
],
"sexPositions": [
"unknown",
"missionary",
"doggy",
"cowgirl",
"reverse_cowgirl",
"cunnilingus",
"prone_bone",
"standing",
"standing_doggy",
"spooning",
"facesitting",
"handjob",
"blowjob",
"boobjob",
"toy_play",
"fingering",
"69"
],
"bodyParts": [
"anus",
"ass",
"breasts",
"penis",
"tongue",
"pussy"
],
"objects": [
"blindfold",
"buttplug",
"collar",
"dildo",
"handcuffs",
"shower",
"strapon",
"towel",
"vibrator"
],
"clothing": [
"bikini",
"bra",
"dress",
"heels",
"hotpants",
"lingerie",
"panties",
"skirt",
"stockings",
"croptop"
]
}

View File

@ -451,7 +451,7 @@ func contextualSegmentSeverityWeight(label string) float64 {
score += 0.015
}
// Standing/Sitting ohne Kontext bleibt schwach.
// Standing ohne Kontext bleibt schwach.
if set.Position < 0.60 && !set.HasBody && !set.HasObject && !set.HasClothing {
score = math.Min(score, 0.42)
}

View File

@ -464,9 +464,22 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
trainingRoot, appBaseDir := findTrainingRootDir(scriptDir)
mlDir, mlErr := trainingEmbeddedMLDir()
if mlErr != nil {
appLogln("⚠️ embedded ML-Dateien konnten nicht extrahiert werden:", mlErr)
} else {
appLogln("✅ embedded ML-Dateien extrahiert:", mlDir)
}
detectionLabelsPath := strings.TrimSpace(os.Getenv("DETECTION_LABELS_PATH"))
if detectionLabelsPath == "" {
detectionLabelsPath = filepath.Join(trainingRoot, "detection_labels.json")
if mlErr == nil && strings.TrimSpace(mlDir) != "" {
// ai_server.py soll die entpackte embedded detection_labels.json verwenden.
detectionLabelsPath = filepath.Join(mlDir, "detection_labels.json")
} else {
// Fallback für Entwicklung / falls embedded extraction fehlschlägt.
detectionLabelsPath = filepath.Join(trainingRoot, "detection_labels.json")
}
} else {
detectionLabelsPath = filepath.Clean(detectionLabelsPath)
}

View File

@ -4301,7 +4301,7 @@ export default function App() {
setSplitModalOpen(false)
setSplitJob(null)
}}
onApply={({ job, splits, segments, deleteOriginal }) => {
onApply={async ({ job, splits, segments, deleteOriginal, onProgress }) => {
const file = baseName(job.output || '')
if (!file) {
throw new Error('Kein Dateiname vorhanden.')
@ -4338,13 +4338,18 @@ export default function App() {
return segments[pos - 1] ?? null
}
const publish = (
progress: Omit<BackgroundSplitProgress, 'sourceFile' | 'startedAtMs'>
) => {
pushSplitProgress(file, progress)
type ModalSplitProgress = Parameters<NonNullable<typeof onProgress>>[0]
const publish = (progress: ModalSplitProgress) => {
pushSplitProgress(
file,
progress as Omit<BackgroundSplitProgress, 'sourceFile' | 'startedAtMs'>
)
onProgress?.(progress)
}
void (async () => {
return await (async () => {
let buffer = ''
let finalData: any = null
let total = totalFallback
@ -4562,6 +4567,7 @@ export default function App() {
})
notify.error('Split fehlgeschlagen', `${file}${message}`)
throw err
}
})()
}}

View File

@ -20,7 +20,6 @@ export const AI_POSITION_LABELS = [
'standing',
'standing_doggy',
'spooning',
'sitting',
'facesitting',
'handjob',
'blowjob',
@ -121,7 +120,6 @@ export const AI_LABEL_FALLBACKS: Record<string, string> = {
standing: 'Stehend',
standing_doggy: 'Standing Doggy',
spooning: 'Spooning',
sitting: 'Sitzend',
facesitting: 'Facesitting',
handjob: 'Handjob',
blowjob: 'Blowjob',

View File

@ -1324,10 +1324,6 @@ 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)
@ -1361,16 +1357,6 @@ 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
@ -1397,10 +1383,6 @@ function FinishedDownloadsCardsView({
{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"

View File

@ -64,38 +64,18 @@ export function FemaleBreastIcon(props: IconProps) {
}
export function BoobjobIcon(props: IconProps) {
const { title, className, ...rest } = props
return (
<svg
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={iconClassName(className, 'origin-center scale-[1.12]')}
aria-hidden={title ? undefined : true}
role={title ? 'img' : undefined}
{...rest}
<IconBase
{...props}
className={iconClassName(props.className, 'origin-center scale-[1.12]')}
viewBox="0 0 211 158"
>
{title ? <title>{title}</title> : null}
<FemaleBreastIcon
x="3"
y="20"
width="94"
height="58"
aria-hidden="true"
className="opacity-100"
/>
<MaleGenitaliaIcon
x="35"
y="25"
width="30"
height="54"
aria-hidden="true"
className="opacity-100"
/>
</svg>
<g transform="translate(0,158) scale(0.1,-0.1)" fill="currentColor" stroke="none">
<path d="M355 1308 c-54 -168 -88 -256 -140 -363 -35 -71 -71 -157 -80 -190 -41 -159 4 -323 124 -448 90 -95 196 -145 326 -152 39 -2 73 -4 78 -4 4 -1 7 -25 7 -53 0 -29 3 -63 6 -75 l6 -23 368 0 368 0 12 30 c8 19 10 50 7 82 l-6 51 62 -6 c127 -13 265 36 365 128 180 166 209 398 79 635 -42 77 -67 137 -117 285 -33 99 -39 110 -61 110 -16 0 -25 -6 -27 -20 -5 -33 75 -251 143 -391 75 -154 93 -230 76 -325 -24 -137 -117 -262 -240 -322 -61 -30 -74 -32 -166 -32 -130 0 -191 23 -281 107 -109 102 -159 241 -134 371 7 34 13 73 15 87 4 36 20 70 49 109 14 19 26 43 26 53 0 26 -59 85 -102 103 -37 15 -38 15 -38 -7 0 -13 -4 -29 -9 -37 -13 -21 -41 -5 -41 24 0 50 -79 21 -126 -47 l-26 -37 47 -48 c77 -80 90 -120 89 -283 -1 -40 -7 -77 -18 -97 -9 -18 -16 -35 -16 -38 1 -25 -54 -106 -105 -154 -89 -83 -150 -106 -280 -106 -91 0 -106 3 -165 31 -168 80 -261 240 -247 424 5 67 13 89 77 218 77 157 158 378 147 405 -7 20 -46 23 -52 5z"/>
<path d="M506 579 c-20 -16 -26 -29 -26 -57 1 -43 16 -67 53 -81 78 -30 142 75 82 134 -30 31 -73 32 -109 4z"/>
<path d="M1525 575 c-74 -73 35 -186 110 -115 52 49 16 140 -55 140 -19 0 -40 -9 -55 -25z"/>
</g>
</IconBase>
)
}

View File

@ -1999,7 +1999,8 @@ export default function Player({
className={cn(
'relative w-full h-full player-video-frame',
miniDesktop && 'vjs-mini',
expanded && !isLive && 'vjs-expanded-player'
expanded && !isLive && 'vjs-expanded-player',
!expanded && !miniDesktop && !isLive && 'vjs-mobile-mini-player'
)}
style={{ ['--vjs-controlbar-h' as any]: `${controlBarH}px` }}
>
@ -2817,6 +2818,39 @@ export default function Player({
flex: 0 0 auto !important;
}
/* Mobiler MiniPlayer: Zeit ebenfalls anzeigen */
.vjs-mobile-mini-player .video-js .vjs-current-time,
.vjs-mobile-mini-player .video-js .vjs-time-divider,
.vjs-mobile-mini-player .video-js .vjs-duration {
display: flex !important;
align-items: center !important;
flex: 0 0 auto !important;
}
.vjs-mobile-mini-player .video-js .vjs-time-control {
min-width: auto !important;
padding-left: 0.25em !important;
padding-right: 0.25em !important;
font-variant-numeric: tabular-nums;
}
.vjs-mobile-mini-player .video-js .vjs-current-time-display,
.vjs-mobile-mini-player .video-js .vjs-duration-display {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.vjs-mobile-mini-player .video-js .vjs-time-divider {
padding-left: 0.1em !important;
padding-right: 0.1em !important;
}
.vjs-mobile-mini-player .video-js .vjs-skip-backward-10,
.vjs-mobile-mini-player .video-js .vjs-skip-forward-10,
.vjs-mobile-mini-player .video-js .vjs-playback-rate {
display: none !important;
}
.vjs-expanded-player .video-js .vjs-time-control {
min-width: auto !important;
padding-left: 0.35em !important;

View File

@ -1072,7 +1072,6 @@ function segmentKindFromText(value: unknown): SegmentVisualKind {
clean.includes('prone_bone') ||
clean.includes('standing') ||
clean.includes('spooning') ||
clean.includes('sitting') ||
clean.includes('facesitting') ||
clean.includes('handjob') ||
clean.includes('blowjob') ||

View File

@ -42,17 +42,11 @@ 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
@ -71,8 +65,6 @@ 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
}
@ -82,9 +74,7 @@ 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
type CommitDir = 'left' | 'right'
function clamp(v: number, min: number, max: number) {
return Math.max(min, Math.min(max, v))
@ -112,17 +102,11 @@ 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,
@ -148,22 +132,17 @@ 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<{
@ -187,12 +166,10 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
})
const [dx, setDx] = useState(0)
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 baseTouchAction = 'pan-y'
const clearTapTimer = useCallback(() => {
if (tapTimerRef.current != null) {
@ -217,16 +194,14 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
onPressEnd?.()
}, [clearPressTimer, onPressEnd])
const flushDrag = useCallback((nextDx: number, nextDy: number) => {
const flushDx = useCallback((nextDx: number) => {
dxRef.current = nextDx
dyRef.current = nextDy
if (rafRef.current != null) return
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null
setDx(dxRef.current)
setDy(dyRef.current)
})
}, [])
@ -240,10 +215,8 @@ 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 = baseTouchAction
@ -251,7 +224,6 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
setAnimMs(snapMs)
setDx(0)
setDy(0)
setArmedDir(null)
window.setTimeout(() => {
@ -271,25 +243,17 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
}
const w = cardRef.current?.offsetWidth || widthRef.current || 360
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 }
? w + 56
: -(w + 56)
setAnimMs(commitMs)
setArmedDir(dir)
dxRef.current = out.x
dyRef.current = out.y
setDx(out.x)
setDy(out.y)
dxRef.current = out
setDx(out)
const animPromise = new Promise<void>((resolve) => {
window.setTimeout(resolve, commitMs)
@ -300,20 +264,8 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
let ok: boolean | void = true
if (runAction) {
const action =
dir === 'right'
? onSwipeRight
: dir === 'left'
? onSwipeLeft
: dir === 'up'
? onSwipeUp
: onSwipeDown
if (!action) {
ok = false
} else {
ok = await Promise.resolve(action()).catch(() => false)
}
const action = dir === 'right' ? onSwipeRight : onSwipeLeft
ok = await Promise.resolve(action()).catch(() => false)
}
if (ok === false) {
@ -321,10 +273,7 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
setArmedDir(null)
dxRef.current = 0
dyRef.current = 0
setDx(0)
setDy(0)
window.setTimeout(() => setAnimMs(0), snapMs)
return false
@ -336,8 +285,6 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
commitMs,
onSwipeLeft,
onSwipeRight,
onSwipeUp,
onSwipeDown,
snapMs,
baseTouchAction,
]
@ -350,11 +297,9 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
}
dxRef.current = 0
dyRef.current = 0
setAnimMs(0)
setDx(0)
setDy(0)
setArmedDir(null)
try {
@ -492,54 +437,28 @@ 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 absDy = Math.abs(dy)
const swipeDir: CommitDir | null =
absDx === 0 && absDy === 0
? null
: absDy > absDx
? dy > 0
? 'down'
: 'up'
: dx > 0
? 'right'
: 'left'
dx === 0 ? null : dx > 0 ? 'right' : 'left'
const activeThreshold =
thresholdRef.current ||
Math.min(thresholdPx, (cardRef.current?.offsetWidth || 360) * thresholdRatio)
const activeVerticalThreshold =
verticalThresholdRef.current ||
Math.min(
verticalThresholdPx,
(cardRef.current?.offsetHeight || 520) * verticalThresholdRatio
)
const reveal = clamp(absDx / Math.max(1, activeThreshold), 0, 1)
const reveal =
swipeDir === 'up' || swipeDir === 'down'
? clamp(absDy / Math.max(1, activeVerticalThreshold), 0, 1)
: clamp(absDx / Math.max(1, activeThreshold), 0, 1)
const revealSoft = clamp(absDx / Math.max(1, activeThreshold * 1.35), 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 tiltDeg = clamp(dx / 34, -ROTATE_MAX_DEG, ROTATE_MAX_DEG)
const dragScale =
absDx === 0 && absDy === 0
dx === 0
? 1
: Math.max(SCALE_MIN, 1 - revealSoft * 0.008)
@ -548,11 +467,7 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
? leftAction ?? { label: 'Löschen' }
: armedDir === 'right'
? rightAction ?? { label: 'Behalten' }
: armedDir === 'up'
? upAction
: armedDir === 'down'
? downAction
: null
: null
return (
<div ref={outerRef} className={cn('relative isolate overflow-visible rounded-lg', className)}>
@ -561,17 +476,17 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
className="relative"
style={{
transform:
dx !== 0 || dy !== 0
? `translate3d(${dx}px,${dy}px,0) rotate(${tiltDeg}deg) scale(${dragScale})`
: undefined,
dx !== 0
? `translate3d(${dx}px,0,0) rotate(${tiltDeg}deg) scale(${dragScale})`
: undefined,
transition: animMs
? `transform ${animMs}ms cubic-bezier(0.22, 1, 0.36, 1)`
: undefined,
touchAction: baseTouchAction,
willChange: dx !== 0 || dy !== 0 ? 'transform' : undefined,
borderRadius: dx !== 0 || dy !== 0 ? '12px' : undefined,
willChange: dx !== 0 ? 'transform' : undefined,
borderRadius: dx !== 0 ? '12px' : undefined,
filter:
dx !== 0 || dy !== 0
dx !== 0
? `saturate(${1 + reveal * 0.06}) brightness(${1 + reveal * 0.015})`
: undefined,
}}
@ -651,13 +566,10 @@ 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,
@ -671,14 +583,11 @@ 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
@ -696,24 +605,19 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
if (absY > absX * LOCK_RATIO) {
clearPressTimer()
if (!hasVerticalSwipe) {
pointer.current.axisLocked = 'y'
pointer.current.id = null
return
}
pointer.current.axisLocked = 'y'
} else if (absX > absY * LOCK_RATIO) {
pointer.current.id = null
return
}
if (absX > absY * LOCK_RATIO) {
pointer.current.axisLocked = 'x'
} else {
return
}
}
if (pointer.current.axisLocked !== 'x' && pointer.current.axisLocked !== 'y') {
return
}
if (pointer.current.axisLocked !== 'x') return
if (!pointer.current.dragging) {
clearPressTimer()
@ -733,67 +637,34 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
const dt = Math.max(1, now - lastMoveTRef.current)
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)
flushDrag(next, 0)
const next = rubberBand(ddx, widthRef.current)
flushDx(next)
const threshold = thresholdRef.current
const nextDir =
next > threshold
? 'right'
: next < -threshold
? 'left'
: null
const threshold = thresholdRef.current
const nextDir =
next > threshold
? 'right'
: next < -threshold
? 'left'
: null
setArmedDir((prev) => {
if (prev === nextDir) return prev
setArmedDir((prev) => {
if (prev === nextDir) return prev
if (nextDir) {
try {
navigator.vibrate?.(10)
} catch {
// ignore
}
if (nextDir) {
try {
navigator.vibrate?.(10)
} catch {
// 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
})
}
return nextDir
})
}}
onPointerUp={(e) => {
if (!enabled || disabled) return
@ -895,50 +766,21 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
}
const finalDx = dxRef.current
const finalDy = dyRef.current
const absX = Math.abs(finalDx)
const absY = Math.abs(finalDy)
const abs = Math.abs(finalDx)
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
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)) {
if (finalDx > threshold || (fastRight && abs >= threshold * 0.5)) {
void commit('right', true)
return
}
if (finalDx < -threshold || (fastLeft && absX >= threshold * 0.5)) {
if (finalDx < -threshold || (fastLeft && abs >= threshold * 0.5)) {
void commit('left', true)
return
}
@ -975,10 +817,8 @@ 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 = baseTouchAction
@ -1001,11 +841,7 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
? '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.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',
: 'transparent',
}}
/>
@ -1018,11 +854,7 @@ const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard
? '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.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',
: 'none',
}}
/>

View File

@ -5253,8 +5253,9 @@ export default function TrainingTab(props: {
}}
title={isDraft ? box.label : `${item.text} verschieben`}
>
<button
type="button"
<div
role={isDraft || uiLocked ? undefined : 'button'}
aria-disabled={Boolean(isDraft) || uiLocked}
className={[
'group/label flex h-5 max-w-full min-w-0 touch-none select-none items-center overflow-hidden rounded-full text-left',
isSmallBox ? 'gap-0.5 px-1' : 'gap-1 pl-1.5 pr-0.5',
@ -5276,7 +5277,6 @@ export default function TrainingTab(props: {
].join(' '),
isDraft ? 'cursor-default' : 'cursor-move',
].join(' ')}
disabled={Boolean(isDraft) || uiLocked}
onPointerDown={(e) => {
if (isDraft || uiLocked) return
@ -5349,20 +5349,20 @@ export default function TrainingTab(props: {
) : null}
{!isDraft ? (
<span
<button
data-box-trash="true"
role="button"
tabIndex={0}
type="button"
disabled={uiLocked}
className={[
'ml-0.5 flex h-4.5 w-4.5 shrink-0 cursor-pointer touch-none items-center justify-center rounded-full',
'!bg-red-600 !text-white ring-1 ring-red-800/40 transition',
'hover:!bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500/60',
'disabled:cursor-not-allowed disabled:opacity-50',
'dark:!bg-red-500 dark:!text-white dark:ring-white/20 dark:hover:!bg-red-600',
].join(' ')}
title={`${box.label} löschen`}
aria-label={`${box.label} löschen`}
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={(e) => {
@ -5370,20 +5370,14 @@ export default function TrainingTab(props: {
e.stopPropagation()
removeBox(index)
}}
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
e.stopPropagation()
removeBox(index)
}}
>
<TrashIcon
className="h-3 w-3 shrink-0 !text-white"
aria-hidden="true"
/>
</span>
</button>
) : null}
</button>
</div>
</div>
{!isDraft ? (

View File

@ -860,6 +860,143 @@ function normalizeAiSegmentsFromHits(
})
}
type SegmentVisualKind = 'position' | 'toy' | 'clothing' | 'body' | 'person' | 'default'
function segmentVisualKindFromText(value: unknown): SegmentVisualKind {
const clean = String(value ?? '').trim().toLowerCase()
if (!clean) return 'default'
if (
clean.includes('person_male') ||
clean.includes('male_person') ||
clean.includes('person_female') ||
clean.includes('female_person') ||
clean === 'person' ||
clean.startsWith('person:')
) {
return 'person'
}
if (
clean.startsWith('position:') ||
clean.includes('missionary') ||
clean.includes('doggy') ||
clean.includes('doggystyle') ||
clean.includes('cowgirl') ||
clean.includes('reverse cowgirl') ||
clean.includes('reverse_cowgirl') ||
clean.includes('cunnilingus') ||
clean.includes('prone bone') ||
clean.includes('prone_bone') ||
clean.includes('standing') ||
clean.includes('spooning') ||
clean.includes('facesitting') ||
clean.includes('handjob') ||
clean.includes('blowjob') ||
clean.includes('boobjob') ||
clean.includes('boob job') ||
clean.includes('boob_job') ||
clean.includes('titjob') ||
clean.includes('tit job') ||
clean.includes('tit_job') ||
clean.includes('fingering') ||
clean.includes('toy play') ||
clean.includes('toy_play') ||
clean === '69'
) {
return 'position'
}
if (
clean.includes('dildo') ||
clean.includes('vibrator') ||
clean.includes('buttplug') ||
clean.includes('strapon') ||
clean.includes('toy')
) {
return 'toy'
}
if (
clean.includes('lingerie') ||
clean.includes('bikini') ||
clean.includes('bra') ||
clean.includes('bh') ||
clean.includes('panties') ||
clean.includes('slip') ||
clean.includes('stocking') ||
clean.includes('strümpfe') ||
clean.includes('skirt') ||
clean.includes('rock') ||
clean.includes('dress') ||
clean.includes('kleid') ||
clean.includes('heels')
) {
return 'clothing'
}
if (
clean.includes('vagina') ||
clean.includes('vulva') ||
clean.includes('pussy') ||
clean.includes('penis') ||
clean.includes('anus') ||
clean.includes('ass') ||
clean.includes('hintern') ||
clean.includes('breasts') ||
clean.includes('brüste') ||
clean.includes('buttocks')
) {
return 'body'
}
return 'default'
}
function segmentVisualKind(seg: Segment): SegmentVisualKind {
const rawLabel = String(seg.label || '').trim()
const prettyLabel = getSegmentLabelText(rawLabel)
const values = [rawLabel, prettyLabel]
for (const value of values) {
const kind = segmentVisualKindFromText(value)
if (kind !== 'default') return kind
}
return 'default'
}
function reindexSegments(input: Segment[]): Segment[] {
return input.map((seg, idx) => ({
...seg,
index: idx + 1,
}))
}
function filterSegmentsLikeRatingOverlay(input: Segment[]): Segment[] {
const withoutPeople = input.filter((seg) => segmentVisualKind(seg) !== 'person')
const positionSegments = withoutPeople.filter((seg) => (
segmentVisualKind(seg) === 'position'
))
if (positionSegments.length > 0) {
return reindexSegments(positionSegments)
}
const bodyOrClothingSegments = withoutPeople.filter((seg) => {
const kind = segmentVisualKind(seg)
return kind === 'clothing' || kind === 'body'
})
if (bodyOrClothingSegments.length > 0) {
return reindexSegments(bodyOrClothingSegments)
}
return reindexSegments(withoutPeople)
}
function shouldAutoSelectAiHit(hit: {
label: string
}): boolean {
@ -1085,7 +1222,7 @@ export default function VideoSplitModal({
mapAiTimes
)
const metaAiSegments =
const rawMetaAiSegments =
metaAiSegmentsFromMeta.length > 0
? metaAiSegmentsFromMeta
: normalizeAiSegmentsFromHits(
@ -1094,6 +1231,8 @@ export default function VideoSplitModal({
mapAiTimes
)
const metaAiSegments = filterSegmentsLikeRatingOverlay(rawMetaAiSegments)
if (!cancelled) {
setMediaDuration(metaDuration > 0 ? metaDuration : 0)
setAiHits(metaAiHits)
@ -1610,6 +1749,31 @@ export default function VideoSplitModal({
)
}, [job?.output, anyJob?.startedAt, anyJob?.endedAt, anyJob?.createdAt, anyJob?.fileCreatedAt, anyJob?.ctime])
const showSplitProgress = Boolean(
splitProgress &&
(
busy ||
splitProgress.phase === 'done' ||
splitProgress.phase === 'error'
)
)
const splitOverallPercent = Math.round(
clamp(splitProgress?.overallProgress ?? 0, 0, 1) * 100
)
const splitSegmentPercent = Math.round(
clamp(splitProgress?.segmentProgress ?? 0, 0, 1) * 100
)
const splitCurrentLabel = splitProgress?.segment?.label
? getSegmentLabelText(splitProgress.segment.label)
: ''
const splitCurrentTime = splitProgress?.segment
? `${formatTime(splitProgress.segment.start)}${formatTime(splitProgress.segment.end)}`
: ''
return (
<Modal
open={open}
@ -1673,9 +1837,11 @@ export default function VideoSplitModal({
}
}}
>
{segmentsToApply.length === 1
? '1 Datei erzeugen'
: `${segmentsToApply.length} Dateien erzeugen`}
{busy
? 'Wird erzeugt…'
: segmentsToApply.length === 1
? '1 Datei erzeugen'
: `${segmentsToApply.length} Dateien erzeugen`}
</Button>
</>
}
@ -2105,6 +2271,73 @@ export default function VideoSplitModal({
</div>
</div>
{showSplitProgress ? (
<div
className={cn(
'mb-3 rounded-xl px-3 py-3 ring-1',
splitProgress?.phase === 'error'
? 'bg-red-50 text-red-900 ring-red-200 dark:bg-red-500/10 dark:text-red-100 dark:ring-red-400/30'
: splitProgress?.phase === 'done'
? 'bg-emerald-50 text-emerald-900 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-100 dark:ring-emerald-400/30'
: 'bg-indigo-50 text-indigo-950 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/30'
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold">
{splitProgress?.phase === 'done'
? 'Split abgeschlossen'
: splitProgress?.phase === 'error'
? 'Split fehlgeschlagen'
: 'Split läuft…'}
</div>
<div className="mt-0.5 text-xs opacity-80">
{splitProgress?.message || 'Bitte warten…'}
</div>
{splitCurrentLabel ? (
<div className="mt-1 truncate text-[11px] opacity-75">
{splitCurrentLabel}
{splitCurrentTime ? ` · ${splitCurrentTime}` : ''}
</div>
) : null}
</div>
<div className="shrink-0 text-right">
<div className="text-sm font-black">
{splitOverallPercent}%
</div>
<div className="mt-0.5 text-[11px] opacity-70">
{splitProgress?.completed ?? 0}/{splitProgress?.total ?? segmentsToApply.length}
</div>
</div>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-black/10 dark:bg-white/15">
<div
className={cn(
'h-full rounded-full transition-[width] duration-150',
splitProgress?.phase === 'error'
? 'bg-red-500'
: splitProgress?.phase === 'done'
? 'bg-emerald-500'
: 'bg-indigo-500'
)}
style={{ width: `${splitOverallPercent}%` }}
/>
</div>
{splitProgress?.phase === 'splitting' ? (
<div className="mt-1 flex items-center justify-between text-[10px] opacity-70">
<span>Aktuelles Segment</span>
<span>{splitSegmentPercent}%</span>
</div>
) : null}
</div>
) : null}
<div className="space-y-3">
{displaySegmentGroups.map((group, groupIdx) => {
const firstSeg = group.segments[0]