nsfwapp/frontend/src/components/ui/VideoSplitModal.tsx
2026-03-27 09:20:47 +01:00

1714 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// frontend\src\components\ui\VideoSplitModal.tsx
'use client'
import * as React from 'react'
import type { RecordJob } from '../../types'
import Modal from './Modal'
import Button from './Button'
import Checkbox from './Checkbox'
type Segment = {
index: number
label: string
start: number
end: number
duration: number
markerTime?: number
}
type Props = {
open: boolean
onClose: () => void
job: RecordJob | null
videoSrc: string
timelinePreviewCount?: number
onApply?: (payload: {
job: RecordJob
splits: number[]
segments: Segment[]
}) => void | Promise<void>
}
type PreviewSpriteMeta = {
exists: boolean
path?: string
count?: number
cols?: number
rows?: number
stepSeconds?: number
}
type TimelinePreviewTile = {
index: number
time: number
label: string
}
type PreviewClipMap = {
start: number
dur: number
cumStart: number
cumEnd: number
}
function mapOptionalTime(
value: unknown,
timeMapper?: (timeSec: number) => number
): number | undefined {
const n = Number(value)
if (!Number.isFinite(n)) return undefined
const mapped = timeMapper ? Number(timeMapper(n)) : n
return Number.isFinite(mapped) ? mapped : undefined
}
function extractPreviewClipMap(data: any): PreviewClipMap[] | null {
let raw =
data?.meta?.preview?.clips ??
data?.meta?.preview?.teaser?.clips ??
data?.preview?.clips ??
data?.preview?.teaser?.clips ??
data?.previewClips ??
data?.teaserClips ??
null
if (typeof raw === 'string') {
try {
raw = JSON.parse(raw)
} catch {
raw = null
}
}
if (!Array.isArray(raw) || raw.length === 0) return null
let cum = 0
const out: PreviewClipMap[] = []
for (const item of raw) {
const start = Number(
pickFirstDefined(
item?.startSeconds,
item?.StartSeconds,
item?.start,
item?.Start
)
)
const dur = Number(
pickFirstDefined(
item?.durationSeconds,
item?.DurationSeconds,
item?.duration,
item?.Duration
)
)
if (!Number.isFinite(start) || start < 0) continue
if (!Number.isFinite(dur) || dur <= 0) continue
out.push({
start,
dur,
cumStart: cum,
cumEnd: cum + dur,
})
cum += dur
}
return out.length > 0 ? out : null
}
function mapPreviewTimeToVideoTime(
timeSec: number,
clipMap: PreviewClipMap[]
): number {
if (!Number.isFinite(timeSec) || clipMap.length === 0) return timeSec
if (timeSec <= 0) return clipMap[0].start
const last = clipMap[clipMap.length - 1]
if (timeSec >= last.cumEnd) {
return last.start + last.dur
}
let lo = 0
let hi = clipMap.length - 1
while (lo <= hi) {
const mid = (lo + hi) >> 1
const clip = clipMap[mid]
if (timeSec < clip.cumStart) {
hi = mid - 1
} else if (timeSec >= clip.cumEnd) {
lo = mid + 1
} else {
return clip.start + (timeSec - clip.cumStart)
}
}
const idx = clamp(lo, 0, clipMap.length - 1)
return clipMap[idx].start
}
function maxAiRawTime(ai: any): number {
if (!ai || typeof ai !== 'object') return 0
let max = 0
const collect = (v: unknown) => {
const n = Number(v)
if (Number.isFinite(n) && n > max) max = n
}
const hits = Array.isArray(ai?.hits ?? ai?.Hits) ? (ai?.hits ?? ai?.Hits) : []
for (const item of hits) {
collect(item?.time)
collect(item?.Time)
collect(item?.at)
collect(item?.timestamp)
collect(item?.start)
collect(item?.Start)
collect(item?.startSeconds)
collect(item?.StartSeconds)
collect(item?.end)
collect(item?.End)
collect(item?.endSeconds)
collect(item?.EndSeconds)
}
const segments = Array.isArray(ai?.segments ?? ai?.Segments) ? (ai?.segments ?? ai?.Segments) : []
for (const item of segments) {
collect(item?.start)
collect(item?.Start)
collect(item?.startSeconds)
collect(item?.StartSeconds)
collect(item?.end)
collect(item?.End)
collect(item?.endSeconds)
collect(item?.EndSeconds)
}
return max
}
function shouldMapAiPreviewTimes(
ai: any,
clipMap: PreviewClipMap[] | null,
durationSec: number
): boolean {
if (!clipMap || clipMap.length === 0) return false
if (!Number.isFinite(durationSec) || durationSec <= 0) return false
const previewDuration = clipMap[clipMap.length - 1]?.cumEnd ?? 0
const aiMax = maxAiRawTime(ai)
if (!Number.isFinite(previewDuration) || previewDuration <= 0) return false
if (!Number.isFinite(aiMax) || aiMax <= 0) return false
// Nur mappen, wenn AI-Zeiten klar auf die Preview-Zeitachse passen
// und die Preview deutlich kürzer ist als das Vollvideo.
return aiMax <= previewDuration * 1.05 && previewDuration < durationSec * 0.95
}
function frameIndexFromTime(
timeSec: number,
durationSec: number,
meta: PreviewSpriteMeta
): number {
const count = Math.max(1, Number(meta.count || 0))
const stepSeconds = Number(meta.stepSeconds || 0)
if (count <= 1) return 0
const spriteCoverageSec =
stepSeconds > 0
? stepSeconds * Math.max(1, count - 1)
: 0
const stepLooksUsable =
stepSeconds > 0 &&
durationSec > 0 &&
spriteCoverageSec >= durationSec * 0.7 &&
spriteCoverageSec <= durationSec * 1.3
if (stepLooksUsable) {
const idx = Math.round(timeSec / stepSeconds)
return clamp(idx, 0, count - 1)
}
if (durationSec > 0) {
const ratio = clamp(timeSec / durationSec, 0, 1)
return clamp(Math.round(ratio * (count - 1)), 0, count - 1)
}
return 0
}
function buildTimelinePreviewTiles(
durationSec: number,
meta: PreviewSpriteMeta,
visibleCount: number
): TimelinePreviewTile[] {
const count = Math.max(1, Number(meta.count || 0))
if (visibleCount <= 0 || count <= 0) return []
const tiles = Array.from({ length: visibleCount }, (_, i) => {
const ratio = visibleCount <= 1 ? 0 : i / (visibleCount - 1)
const time = durationSec > 0 ? ratio * durationSec : 0
return {
index: frameIndexFromTime(time, durationSec, meta),
time,
label: formatTime(time),
}
})
for (let i = 1; i < tiles.length; i++) {
if (tiles[i].index < tiles[i - 1].index) {
tiles[i].index = tiles[i - 1].index
}
}
return tiles
}
function spriteSheetImageStyle(
meta: PreviewSpriteMeta,
frameIndex: number
): React.CSSProperties {
const cols = Math.max(1, Number(meta.cols || 1))
const rows = Math.max(1, Number(meta.rows || 1))
const count = Math.max(1, Number(meta.count || cols * rows))
const idx = clamp(frameIndex, 0, count - 1)
const col = idx % cols
const row = Math.floor(idx / cols)
return {
position: 'absolute',
left: `${-col * 100}%`,
top: `${-row * 100}%`,
width: `${cols * 100}%`,
height: `${rows * 100}%`,
maxWidth: 'none',
pointerEvents: 'none',
userSelect: 'none',
objectFit: 'fill',
}
}
function spriteFrameImgSrc(meta: PreviewSpriteMeta): string {
return String(meta.path || '').trim()
}
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
}
function clamp(v: number, min: number, max: number) {
return Math.min(max, Math.max(min, v))
}
function baseName(p: string) {
return (p || '').replaceAll('\\', '/').trim().split('/').pop() || ''
}
function stripHotPrefix(name: string) {
return name.startsWith('HOT ') ? name.slice(4) : name
}
function formatTime(totalSeconds: number): string {
const s = Math.max(0, 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 formatBytes(bytes?: number | null): string {
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
const digits = i === 0 ? 0 : v >= 100 ? 0 : v >= 10 ? 1 : 2
return `${v.toFixed(digits)} ${units[i]}`
}
function formatDateTime(v?: string | number | Date | null): string {
if (!v) return '—'
const d = v instanceof Date ? v : new Date(v)
const t = d.getTime()
if (!Number.isFinite(t)) return '—'
return d.toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function pickNum(...vals: any[]): number | null {
for (const v of vals) {
const n = typeof v === 'string' ? Number(v) : v
if (typeof n === 'number' && Number.isFinite(n) && n > 0) return n
}
return null
}
function formatFps(n?: number | null): string {
if (!n || !Number.isFinite(n)) return '—'
const digits = n >= 10 ? 0 : 2
return `${n.toFixed(digits)} fps`
}
function formatResolution(h?: number | null, w?: number | null): string {
if (w && h && Number.isFinite(w) && Number.isFinite(h)) return `${Math.round(w)}×${Math.round(h)}`
if (h && Number.isFinite(h)) return `${Math.round(h)}p`
return '—'
}
const reModel = /^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}/
function modelNameFromOutput(output?: string) {
const fileRaw = baseName(output || '')
const file = stripHotPrefix(fileRaw)
if (!file) return '—'
const stem = file.replace(/\.[^.]+$/, '')
const m = stem.match(reModel)
if (m?.[1]) return m[1]
const i = stem.lastIndexOf('_')
return i > 0 ? stem.slice(0, i) : stem
}
function parseDateFromOutput(output?: string): Date | null {
const fileRaw = baseName(output || '')
const file = stripHotPrefix(fileRaw)
if (!file) return null
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) return null
const mm = Number(m[1])
const dd = Number(m[2])
const yyyy = Number(m[3])
const hh = Number(m[4])
const mi = Number(m[5])
const ss = Number(m[6])
if (![mm, dd, yyyy, hh, mi, ss].every((n) => Number.isFinite(n))) return null
return new Date(yyyy, mm - 1, dd, hh, mi, ss)
}
function previewIdFromJob(job: RecordJob | null): string {
if (!job) return ''
const file = baseName(job.output || '')
const stem = stripHotPrefix(file.replace(/\.[^.]+$/, '')).trim()
return String(job.status).toLowerCase() === 'running' ? String(job.id || '') : stem || String(job.id || '')
}
function previewStillSrcFromJob(job: RecordJob | null): string {
const id = previewIdFromJob(job)
if (!id) return ''
return `/api/preview?id=${encodeURIComponent(id)}&file=preview.jpg`
}
function previewSpritesSrcFromJob(job: RecordJob | null): string {
const id = previewIdFromJob(job)
if (!id) return ''
return `/api/preview-sprite/${encodeURIComponent(id)}`
}
function buildSegmentPreviewSrc(job: RecordJob | null, seg: Segment): string {
const id = previewIdFromJob(job)
if (!id) return ''
const t = Number(seg.start || 0)
const safeTime = Math.max(0, Number.isFinite(t) ? t : 0)
return `/api/preview?id=${encodeURIComponent(id)}&t=${encodeURIComponent(safeTime.toFixed(3))}`
}
const AUTO_SELECTED_AI_LABELS = new Set([
'anus_exposed',
'female_genitalia_exposed',
'male_genitalia_exposed',
'female_breast_exposed',
'buttocks_exposed',
])
const AI_SEGMENT_PADDING_SECONDS = 3
function pickFirstDefined<T = any>(...vals: T[]): T | undefined {
for (const v of vals) {
if (v !== undefined && v !== null) return v
}
return undefined
}
function extractAiPayload(data: any): any {
if (!data || typeof data !== 'object') return null
const candidates = [
data?.meta?.analysis?.ai,
data?.meta?.analysis?.AI,
data?.analysis?.ai,
data?.analysis?.AI,
data?.ai,
data?.AI,
data?.videoAI,
data?.videoAi,
data?.meta?.ai,
data?.meta?.AI,
data?.meta?.videoAI,
data?.meta?.videoAi,
data?.metaJson?.analysis?.ai,
data?.metaJson?.ai,
data?.metadata?.analysis?.ai,
data?.metadata?.ai,
]
for (const candidate of candidates) {
if (candidate && typeof candidate === 'object') {
return candidate
}
}
return null
}
function extractPreviewSpriteMeta(data: any): PreviewSpriteMeta | null {
const ps =
data?.meta?.preview?.sprite ??
data?.preview?.sprite ??
data?.previewSprite ??
null
if (!ps || typeof ps !== 'object') return null
const path = String(ps.path || '').trim()
const count = Number(ps.count || 0)
const cols = Number(ps.cols || 0)
const rows = Number(ps.rows || 0)
const stepSeconds = Number(ps.stepSeconds || 0)
if (!path || count <= 0 || cols <= 0 || rows <= 0) {
return null
}
return {
exists: true,
path,
count,
cols,
rows,
stepSeconds: Number.isFinite(stepSeconds) && stepSeconds > 0 ? stepSeconds : 0,
}
}
function extractMetaDuration(data: any): number {
const d = Number(
data?.meta?.media?.durationSeconds ??
data?.media?.durationSeconds ??
data?.durationSeconds ??
0
)
return Number.isFinite(d) && d > 0 ? d : 0
}
function normalizeAiHits(
raw: unknown,
timeMapper?: (timeSec: number) => number
): Array<{
time: number
label: string
score?: number
start?: number
end?: number
}> {
if (!Array.isArray(raw)) return []
const out: Array<{
time: number
label: string
score?: number
start?: number
end?: number
}> = []
for (const item of raw) {
if (!item || typeof item !== 'object') continue
const x = item as any
const time = mapOptionalTime(
pickFirstDefined(
x.time,
x.Time,
x.at,
x.timestamp,
),
timeMapper
)
if (typeof time !== 'number' || !Number.isFinite(time) || time < 0) continue
const label = String(
pickFirstDefined(
x.label,
x.Label,
x.name,
'Treffer'
) || 'Treffer'
).trim()
const scoreRaw = Number(
pickFirstDefined(
x.score,
x.Score,
x.confidence,
)
)
const startRaw = mapOptionalTime(
pickFirstDefined(
x.start,
x.Start,
x.startSeconds,
x.StartSeconds,
),
timeMapper
)
const endRaw = mapOptionalTime(
pickFirstDefined(
x.end,
x.End,
x.endSeconds,
x.EndSeconds,
),
timeMapper
)
out.push({
time,
label,
score: Number.isFinite(scoreRaw) ? scoreRaw : undefined,
start: Number.isFinite(startRaw) ? startRaw : undefined,
end: Number.isFinite(endRaw) ? endRaw : undefined,
})
}
out.sort((a, b) => a.time - b.time)
return out
}
function normalizeAiSegmentsFromMeta(
raw: unknown,
durationSec?: number,
timeMapper?: (timeSec: number) => number
): Segment[] {
if (!Array.isArray(raw)) return []
const hasDuration =
typeof durationSec === 'number' &&
Number.isFinite(durationSec) &&
durationSec > 0
const out: Segment[] = []
for (const item of raw) {
if (!item || typeof item !== 'object') continue
const x = item as any
const mappedStart = mapOptionalTime(
pickFirstDefined(
x.startSeconds,
x.StartSeconds,
x.start,
x.Start,
),
timeMapper
)
const mappedEnd = mapOptionalTime(
pickFirstDefined(
x.endSeconds,
x.EndSeconds,
x.end,
x.End,
),
timeMapper
)
if (
typeof mappedStart !== 'number' ||
!Number.isFinite(mappedStart) ||
typeof mappedEnd !== 'number' ||
!Number.isFinite(mappedEnd)
) {
continue
}
let startBase = mappedStart
let endBase = mappedEnd
if (endBase < startBase) {
const tmp = startBase
startBase = endBase
endBase = tmp
}
const rawDuration = Math.max(0, endBase - startBase)
const markerTime = startBase + rawDuration / 2
let start = startBase - AI_SEGMENT_PADDING_SECONDS
let end = endBase + AI_SEGMENT_PADDING_SECONDS
if (hasDuration) {
start = clamp(start, 0, durationSec!)
end = clamp(end, 0, durationSec!)
} else {
start = Math.max(0, start)
end = Math.max(0, end)
}
if (end <= start) continue
const label = String(
pickFirstDefined(
x.label,
x.Label,
x.name,
'Segment'
) || 'Segment'
).trim()
out.push({
index: out.length + 1,
label,
start,
end,
duration: Math.max(0, end - start),
markerTime,
})
}
out.sort((a, b) => {
if ((a.markerTime ?? a.start) !== (b.markerTime ?? b.start)) {
return (a.markerTime ?? a.start) - (b.markerTime ?? b.start)
}
if (a.start !== b.start) return a.start - b.start
if (a.end !== b.end) return a.end - b.end
return a.label.localeCompare(b.label)
})
return out.map((seg, idx) => ({
...seg,
index: idx + 1,
}))
}
function normalizeAiSegmentsFromHits(
raw: unknown,
durationSec?: number,
timeMapper?: (timeSec: number) => number
): Segment[] {
const hits = normalizeAiHits(raw, timeMapper)
if (hits.length === 0) return []
const hasDuration =
typeof durationSec === 'number' &&
Number.isFinite(durationSec) &&
durationSec > 0
const windows = hits
.map((hit) => {
const markerTime = Number(hit.time)
if (!Number.isFinite(markerTime)) return null
let start = markerTime - AI_SEGMENT_PADDING_SECONDS
let end = markerTime + AI_SEGMENT_PADDING_SECONDS
if (hasDuration) {
start = clamp(start, 0, durationSec!)
end = clamp(end, 0, durationSec!)
} else {
start = Math.max(0, start)
end = Math.max(0, end)
}
if (end <= start) return null
return {
start,
end,
markerTime,
labels: [String(hit.label || 'Segment').trim() || 'Segment'],
}
})
.filter(Boolean) as Array<{
start: number
end: number
markerTime: number
labels: string[]
}>
if (windows.length === 0) return []
windows.sort((a, b) => a.markerTime - b.markerTime)
const merged: Array<{
start: number
end: number
markerTime: number
labels: string[]
}> = []
for (const win of windows) {
const last = merged[merged.length - 1]
if (!last || win.start > last.end) {
merged.push({
start: win.start,
end: win.end,
markerTime: win.markerTime,
labels: [...win.labels],
})
continue
}
last.end = Math.max(last.end, win.end)
for (const label of win.labels) {
if (!last.labels.includes(label)) {
last.labels.push(label)
}
}
}
return merged.map((seg, idx) => {
const label =
seg.labels.length <= 1
? seg.labels[0]
: `${seg.labels[0]} +${seg.labels.length - 1}`
return {
index: idx + 1,
label,
start: seg.start,
end: seg.end,
duration: Math.max(0, seg.end - seg.start),
markerTime: seg.markerTime,
}
})
}
function shouldAutoSelectAiHit(hit: {
label: string
}): boolean {
return AUTO_SELECTED_AI_LABELS.has(String(hit.label || '').trim().toLowerCase())
}
export default function VideoSplitModal({
open,
onClose,
job,
videoSrc,
timelinePreviewCount = 10,
onApply,
}: Props) {
const videoRef = React.useRef<HTMLVideoElement | null>(null)
const timelineRef = React.useRef<HTMLDivElement | null>(null)
const [mediaDuration, setMediaDuration] = React.useState(0)
const [duration, setDuration] = React.useState(0)
const [currentTime, setCurrentTime] = React.useState(0)
const [playing, setPlaying] = React.useState(false)
const [splits, setSplits] = React.useState<number[]>([])
const [busy, setBusy] = React.useState(false)
const [aiError, setAiError] = React.useState('')
const [spriteMeta, setSpriteMeta] = React.useState<PreviewSpriteMeta | null>(null)
const [hoverRatio, setHoverRatio] = React.useState<number | null>(null)
const [selectedSegmentIndices, setSelectedSegmentIndices] = React.useState<number[]>([])
const [aiSegments, setAiSegments] = React.useState<Segment[]>([])
const [aiHits, setAiHits] = React.useState<Array<{
time: number
label: string
score?: number
start?: number
end?: number
}>>([])
React.useEffect(() => {
if (!open) return
setCurrentTime(0)
setDuration(0)
setMediaDuration(0)
setPlaying(false)
setSplits([])
setSelectedSegmentIndices([])
setAiSegments([])
setAiHits([])
setAiError('')
}, [open, job?.id])
React.useEffect(() => {
const video = videoRef.current
if (!video || !open) return
const onLoaded = () => {
const d = Number(video.duration || 0)
setDuration(Number.isFinite(d) ? d : 0)
}
const onTime = () => {
setCurrentTime(video.currentTime || 0)
}
const onPlay = () => setPlaying(true)
const onPause = () => setPlaying(false)
video.addEventListener('loadedmetadata', onLoaded)
video.addEventListener('durationchange', onLoaded)
video.addEventListener('timeupdate', onTime)
video.addEventListener('play', onPlay)
video.addEventListener('pause', onPause)
return () => {
video.removeEventListener('loadedmetadata', onLoaded)
video.removeEventListener('durationchange', onLoaded)
video.removeEventListener('timeupdate', onTime)
video.removeEventListener('play', onPlay)
video.removeEventListener('pause', onPause)
}
}, [open, videoSrc])
React.useEffect(() => {
if (!open || !job) {
setSpriteMeta(null)
setAiHits([])
setAiSegments([])
setMediaDuration(0)
return
}
const file = baseName(job.output || '')
if (!file) {
setSpriteMeta(null)
setAiHits([])
setAiSegments([])
setMediaDuration(0)
setSplits([])
return
}
let cancelled = false
;(async () => {
try {
const res = await fetch(`/api/record/done/meta?file=${encodeURIComponent(file)}`, {
cache: 'no-store',
})
if (!res.ok) {
if (!cancelled) {
setSpriteMeta(null)
setMediaDuration(0)
}
return
}
const data = await res.json().catch(() => null)
const ps = extractPreviewSpriteMeta(data)
const ai = extractAiPayload(data)
const clipMap = extractPreviewClipMap(data)
const metaDuration = extractMetaDuration(data)
const resolvedDuration = metaDuration > 0 ? metaDuration : 0
const shouldMapPreviewAi =
Boolean(shouldMapAiPreviewTimes(ai, clipMap, resolvedDuration) && clipMap)
const mapAiTimes =
shouldMapPreviewAi && clipMap
? (timeSec: number) => mapPreviewTimeToVideoTime(timeSec, clipMap)
: undefined
const metaAiHits = normalizeAiHits(
ai?.hits ?? ai?.Hits ?? [],
mapAiTimes
)
const metaAiSegmentsFromMeta = normalizeAiSegmentsFromMeta(
ai?.segments ?? ai?.Segments ?? [],
resolvedDuration,
mapAiTimes
)
const metaAiSegments =
metaAiSegmentsFromMeta.length > 0
? metaAiSegmentsFromMeta
: normalizeAiSegmentsFromHits(
ai?.hits ?? ai?.Hits ?? [],
resolvedDuration,
mapAiTimes
)
if (!cancelled) {
setMediaDuration(metaDuration > 0 ? metaDuration : 0)
setAiHits(metaAiHits)
setAiSegments(metaAiSegments)
if (metaAiSegments.length > 0) {
setSplits([])
}
setSpriteMeta(ps)
}
} catch {
if (!cancelled) {
setSpriteMeta(null)
setMediaDuration(0)
}
}
})()
return () => {
cancelled = true
}
}, [open, job?.id, job?.output])
const effectiveDuration = mediaDuration > 0 ? mediaDuration : duration
const updateHoverFromClientX = React.useCallback((clientX: number) => {
const el = timelineRef.current
if (!el) return
const rect = el.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
setHoverRatio(ratio)
}, [])
const onTimelinePointerMove = React.useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
updateHoverFromClientX(e.clientX)
},
[updateHoverFromClientX]
)
const onTimelinePointerEnter = React.useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
updateHoverFromClientX(e.clientX)
},
[updateHoverFromClientX]
)
const onTimelinePointerLeave = React.useCallback(() => {
setHoverRatio(null)
}, [])
const seekTo = React.useCallback(
(next: number) => {
const video = videoRef.current
if (!video) return
const clamped = clamp(next, 0, Math.max(0, effectiveDuration || 0))
video.currentTime = clamped
setCurrentTime(clamped)
},
[effectiveDuration]
)
const togglePlayback = React.useCallback(async () => {
const video = videoRef.current
if (!video) return
if (video.paused) {
try {
await video.play()
} catch {
// ignore
}
} else {
video.pause()
}
}, [])
const addSplitAt = React.useCallback(
(rawTime: number) => {
const d = effectiveDuration || 0
if (!Number.isFinite(d) || d <= 0) return
const t = clamp(rawTime, 0, d)
if (t <= 0.2 || t >= d - 0.2) return
setAiSegments([])
setSplits((prev) => {
const exists = prev.some((x) => Math.abs(x - t) < 0.2)
if (exists) return prev
return [...prev, t].sort((a, b) => a - b)
})
},
[effectiveDuration]
)
const removeSplit = React.useCallback((time: number) => {
setAiSegments([])
setSplits((prev) => prev.filter((x) => x !== time))
}, [])
const clearSplits = React.useCallback(() => {
setAiSegments([])
setSplits([])
}, [])
const onTimelineClick = React.useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!timelineRef.current || effectiveDuration <= 0) return
const rect = timelineRef.current.getBoundingClientRect()
const ratio = clamp((e.clientX - rect.left) / rect.width, 0, 1)
setHoverRatio(ratio)
const t = ratio * effectiveDuration
seekTo(t)
},
[effectiveDuration, seekTo]
)
const manualSegments = React.useMemo<Segment[]>(() => {
const points = [0, ...splits, effectiveDuration].filter((v) => Number.isFinite(v))
const out: Segment[] = []
for (let i = 0; i < points.length - 1; i++) {
const start = points[i]
const end = points[i + 1]
const segDuration = Math.max(0, end - start)
out.push({
index: i + 1,
label: `Segment ${i + 1}`,
start,
end,
duration: segDuration,
})
}
return out
}, [splits, effectiveDuration])
const segments = React.useMemo<Segment[]>(() => {
if (aiSegments.length > 0) {
return aiSegments.map((seg, idx) => ({
...seg,
index: idx + 1,
}))
}
return manualSegments
}, [aiSegments, manualSegments])
const timelinePreviewTiles = React.useMemo(() => {
if (!spriteMeta?.exists || !spriteMeta.path) return []
const count = Math.max(1, Number(spriteMeta.count || 0))
const visibleCount = Math.min(Math.max(1, timelinePreviewCount), count)
return buildTimelinePreviewTiles(effectiveDuration, spriteMeta, visibleCount)
}, [spriteMeta, effectiveDuration, timelinePreviewCount])
const selectedSegments = React.useMemo(() => {
return segments.filter((seg) => selectedSegmentIndices.includes(seg.index))
}, [segments, selectedSegmentIndices])
React.useEffect(() => {
if (segments.length === 0) {
setSelectedSegmentIndices([])
return
}
if (aiSegments.length === 0) {
setSelectedSegmentIndices((prev) => {
const valid = prev.filter((idx) => segments.some((seg) => seg.index === idx))
if (valid.length > 0) return valid
return segments.map((seg) => seg.index)
})
return
}
const autoMatchedSegmentIndices = segments
.filter((seg) =>
aiHits.some((hit) => {
if (!shouldAutoSelectAiHit(hit)) return false
const hitStart = hit.start ?? hit.time
const hitEnd = hit.end ?? hit.time
return hitEnd >= seg.start && hitStart <= seg.end
})
)
.map((seg) => seg.index)
if (autoMatchedSegmentIndices.length > 0) {
setSelectedSegmentIndices(autoMatchedSegmentIndices)
return
}
setSelectedSegmentIndices((prev) => {
const valid = prev.filter((idx) => segments.some((seg) => seg.index === idx))
if (valid.length > 0) return valid
return segments.map((seg) => seg.index)
})
}, [segments, aiHits, aiSegments.length])
const hoverTime = React.useMemo(() => {
if (hoverRatio == null || effectiveDuration <= 0) return null
return clamp(hoverRatio, 0, 1) * effectiveDuration
}, [hoverRatio, effectiveDuration])
const hoverFrameIndex = React.useMemo(() => {
if (!spriteMeta?.exists || !spriteMeta.path || hoverTime == null) return null
return frameIndexFromTime(hoverTime, effectiveDuration, spriteMeta)
}, [spriteMeta, hoverTime, effectiveDuration])
const toggleSegmentSelection = React.useCallback((segmentIndex: number) => {
setSelectedSegmentIndices((prev) => {
if (prev.includes(segmentIndex)) {
return prev.filter((x) => x !== segmentIndex)
}
return [...prev, segmentIndex].sort((a, b) => a - b)
})
}, [])
const selectAllSegments = React.useCallback(() => {
setSelectedSegmentIndices(segments.map((seg) => seg.index))
}, [segments])
const clearSegmentSelection = React.useCallback(() => {
setSelectedSegmentIndices([])
}, [])
const isSegmentSelected = React.useCallback(
(segmentIndex: number) => selectedSegmentIndices.includes(segmentIndex),
[selectedSegmentIndices]
)
const currentRatio =
effectiveDuration > 0
? clamp(currentTime / effectiveDuration, 0, 1)
: 0
const title = job ? `Video schneiden ${baseName(job.output || '')}` : 'Video schneiden'
const previewStillSrc = React.useMemo(() => previewStillSrcFromJob(job), [job])
const previewSpritesSrc = React.useMemo(() => previewSpritesSrcFromJob(job), [job])
const anyJob = job as any
const model = React.useMemo(() => modelNameFromOutput(job?.output), [job?.output])
const file = React.useMemo(() => stripHotPrefix(baseName(job?.output || '')), [job?.output])
const videoWidth = pickNum(anyJob?.videoWidth, anyJob?.width, anyJob?.meta?.width)
const videoHeight = pickNum(anyJob?.videoHeight, anyJob?.height, anyJob?.meta?.height)
const fps = pickNum(anyJob?.fps, anyJob?.frameRate, anyJob?.meta?.fps, anyJob?.meta?.frameRate)
const resolutionLabel = formatResolution(videoHeight, videoWidth)
const fpsLabel = formatFps(fps)
const sizeLabel = formatBytes(
pickNum(anyJob?.sizeBytes, anyJob?.fileSizeBytes, anyJob?.bytes, anyJob?.size)
)
const dateLabel = React.useMemo(() => {
const fromName = parseDateFromOutput(job?.output)
if (fromName) return formatDateTime(fromName)
return formatDateTime(
anyJob?.startedAt ??
anyJob?.endedAt ??
anyJob?.createdAt ??
anyJob?.fileCreatedAt ??
anyJob?.ctime ??
null
)
}, [job?.output, anyJob?.startedAt, anyJob?.endedAt, anyJob?.createdAt, anyJob?.fileCreatedAt, anyJob?.ctime])
return (
<Modal
open={open}
onClose={onClose}
title={title}
width="max-w-[96vw]"
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={busy}>
Schließen
</Button>
<Button
variant="primary"
disabled={!job || !videoSrc || busy || selectedSegments.length === 0}
onClick={async () => {
if (!job) return
setBusy(true)
try {
await onApply?.({
job,
splits: aiSegments.length > 0 ? [] : splits,
segments: selectedSegments,
})
onClose()
} finally {
setBusy(false)
}
}}
>
Ausgewählte Segmente übernehmen
</Button>
</>
}
>
<div className="p-4 sm:p-6">
<div className="grid gap-4 xl:grid-cols-[300px_minmax(0,1fr)_320px]">
<aside className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="space-y-4">
<div className="overflow-hidden rounded-xl bg-black ring-1 ring-black/10 dark:ring-white/10">
{previewStillSrc ? (
<img
src={previewStillSrc}
alt=""
className="block aspect-video w-full object-contain bg-black"
draggable={false}
/>
) : (
<div className="grid aspect-video place-items-center bg-black text-sm text-white/70">
Kein Preview verfügbar
</div>
)}
</div>
<div className="space-y-1">
<div className="truncate text-lg font-semibold text-gray-900 dark:text-white">{model}</div>
<div className="break-all text-xs text-gray-500 dark:text-gray-400">{file || '—'}</div>
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-2 text-sm">
<div className="text-gray-500 dark:text-gray-400">Status</div>
<div className="font-medium text-gray-900 dark:text-white">{job?.status || '—'}</div>
<div className="text-gray-500 dark:text-gray-400">Auflösung</div>
<div className="font-medium text-gray-900 dark:text-white">{resolutionLabel}</div>
<div className="text-gray-500 dark:text-gray-400">FPS</div>
<div className="font-medium text-gray-900 dark:text-white">{fpsLabel}</div>
<div className="text-gray-500 dark:text-gray-400">Laufzeit</div>
<div className="font-medium text-gray-900 dark:text-white">
{effectiveDuration > 0 ? formatTime(effectiveDuration) : '—'}
</div>
<div className="text-gray-500 dark:text-gray-400">Größe</div>
<div className="font-medium text-gray-900 dark:text-white">{sizeLabel}</div>
<div className="text-gray-500 dark:text-gray-400">Datum</div>
<div className="font-medium text-gray-900 dark:text-white">{dateLabel}</div>
</div>
</div>
</aside>
<section className="min-w-0 space-y-4">
<div className="overflow-hidden rounded-xl bg-black ring-1 ring-black/10 dark:ring-white/10">
{videoSrc ? (
<video
ref={videoRef}
src={videoSrc}
controls
preload="metadata"
className="mx-auto block h-auto max-h-[46vh] w-full rounded-xl bg-black object-contain"
/>
) : (
<div className="grid h-[320px] place-items-center text-sm text-white/70">
Kein Video verfügbar
</div>
)}
</div>
<div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="mb-3 flex flex-wrap items-center gap-2">
<Button variant="secondary" onClick={() => seekTo(currentTime - 5)} disabled={!videoSrc}>
-5s
</Button>
<Button variant="primary" onClick={togglePlayback} disabled={!videoSrc}>
{playing ? 'Pause' : 'Play'}
</Button>
<Button variant="secondary" onClick={() => seekTo(currentTime + 5)} disabled={!videoSrc}>
+5s
</Button>
<Button variant="secondary" onClick={() => addSplitAt(currentTime)} disabled={!videoSrc || effectiveDuration <= 0}>
Split bei aktueller Zeit
</Button>
<Button variant="secondary" onClick={clearSplits} disabled={splits.length === 0}>
Alle Splits löschen
</Button>
<div className="ml-auto text-sm text-gray-600 dark:text-gray-300">
{formatTime(currentTime)} / {formatTime(effectiveDuration)}
</div>
</div>
{aiError ? (
<div className="mb-3 rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700 ring-1 ring-red-200 dark:bg-red-500/10 dark:text-red-200 dark:ring-red-400/20">
{aiError}
</div>
) : null}
<div className="space-y-3">
<div
ref={timelineRef}
className="relative h-24 cursor-pointer select-none rounded-xl ring-1 ring-gray-200 dark:ring-white/10"
onClick={onTimelineClick}
onPointerMove={onTimelinePointerMove}
onPointerEnter={onTimelinePointerEnter}
onPointerLeave={onTimelinePointerLeave}
onPointerDown={(e) => {
e.stopPropagation()
}}
title="Klicken zum Springen"
>
{spriteMeta?.exists && spriteMeta.path && timelinePreviewTiles.length > 0 ? (
<div
className="absolute inset-0 grid gap-0 bg-black/20"
style={{
gridTemplateColumns: `repeat(${timelinePreviewTiles.length}, minmax(0, 1fr))`,
}}
aria-hidden="true"
>
{timelinePreviewTiles.map((tile, i) => (
<div
key={`${tile.index}-${i}-${tile.time}`}
className="relative h-full overflow-hidden"
title={tile.label}
>
<div className="absolute inset-0 overflow-hidden bg-black">
<img
src={spriteFrameImgSrc(spriteMeta)}
alt=""
aria-hidden="true"
draggable={false}
style={spriteSheetImageStyle(spriteMeta, tile.index)}
/>
</div>
<div className="absolute inset-0 bg-black/10 dark:bg-black/20" />
</div>
))}
</div>
) : previewSpritesSrc ? (
<div
className="absolute inset-0 bg-left bg-no-repeat opacity-90"
style={{
backgroundImage: `url("${previewSpritesSrc}")`,
backgroundSize: 'auto 100%',
}}
aria-hidden="true"
/>
) : (
<div className="absolute inset-0 bg-gray-100 dark:bg-white/10" />
)}
{spriteMeta?.exists && spriteMeta.path && hoverFrameIndex != null && hoverTime != null ? (
<div
className="pointer-events-none absolute bottom-full z-40 mb-2"
style={{
left: `clamp(56px, ${(hoverRatio ?? 0) * 100}%, calc(100% - 56px))`,
transform: 'translateX(-50%)',
}}
>
<div className="overflow-hidden rounded-lg border border-white/15 bg-black shadow-2xl">
<div className="relative h-20 w-36 overflow-hidden bg-black">
<div className="absolute inset-0 overflow-hidden bg-black">
<img
src={spriteFrameImgSrc(spriteMeta)}
alt=""
aria-hidden="true"
draggable={false}
style={spriteSheetImageStyle(spriteMeta, hoverFrameIndex)}
/>
</div>
</div>
<div className="bg-black/85 px-2 py-1 text-center text-[11px] font-medium text-white">
{formatTime(hoverTime)}
</div>
</div>
<div className="mx-auto -mt-1 h-2 w-2 rotate-45 border-b border-r border-white/15 bg-black" />
</div>
) : null}
<div className="pointer-events-none absolute inset-0 bg-white/30 dark:bg-black/30" />
<div
className="pointer-events-none absolute inset-y-0 left-0 bg-indigo-500/20 dark:bg-indigo-400/20"
style={{ width: `${currentRatio * 100}%` }}
/>
{segments.map((seg) => {
if (effectiveDuration <= 0) return null
const left = (seg.start / effectiveDuration) * 100
const width = ((seg.end - seg.start) / effectiveDuration) * 100
const markerTime = typeof seg.markerTime === 'number' ? seg.markerTime : seg.start
const markerLeft = (markerTime / effectiveDuration) * 100
const selected = isSegmentSelected(seg.index)
return (
<div
key={`segment-marker-${seg.index}`}
className="pointer-events-none absolute inset-0"
title={`${seg.label} · ${formatTime(seg.start)}${formatTime(seg.end)}`}
>
{/* Gelber Hintergrund */}
<span
className={cn(
'absolute inset-y-0 rounded-sm',
selected ? 'bg-amber-400/20' : 'bg-amber-400/12'
)}
style={{
left: `${left}%`,
width: `${Math.max(width, 0.35)}%`,
}}
/>
{/* Gelbe Linie */}
<span
className={cn(
'absolute inset-y-0 w-[2px] -translate-x-1/2',
selected ? 'bg-amber-400/80' : 'bg-amber-400/35'
)}
style={{ left: `${markerLeft}%` }}
/>
{/* AI-Label */}
<span
className={cn(
'absolute top-1 -translate-x-1/2 rounded-full px-1.5 py-0.5 text-[10px] font-semibold',
selected
? 'bg-amber-400/90 text-black shadow'
: 'bg-amber-400/18 text-black/50 shadow-none'
)}
style={{ left: `${markerLeft}%` }}
>
AI
</span>
</div>
)
})}
{aiSegments.length === 0 && splits.map((time) => {
const left = effectiveDuration > 0 ? (time / effectiveDuration) * 100 : 0
return (
<button
key={time}
type="button"
className="absolute top-0 h-full -translate-x-1/2"
style={{ left: `${left}%` }}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
seekTo(time)
}}
title={`Split ${formatTime(time)}`}
>
<span className="absolute inset-y-0 left-1/2 w-[2px] -translate-x-1/2 bg-rose-500" />
<span className="absolute left-1/2 top-1 -translate-x-1/2 rounded-full bg-rose-500 px-1.5 py-0.5 text-[10px] font-semibold text-white shadow">
|
</span>
</button>
)
})}
<div
className="pointer-events-none absolute top-0 h-full w-[2px] bg-indigo-600 dark:bg-indigo-300"
style={{ left: `${currentRatio * 100}%` }}
/>
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between bg-black/45 px-2 py-1 text-[11px] font-medium text-white">
<span>0:00</span>
<span>{formatTime(effectiveDuration / 2)}</span>
<span>{formatTime(effectiveDuration)}</span>
</div>
</div>
{aiSegments.length === 0 && (
<div className="flex flex-wrap gap-2">
{splits.length === 0 ? (
<div className="text-sm text-gray-500 dark:text-gray-400">
Noch keine Splits gesetzt.
</div>
) : (
splits.map((time, idx) => (
<div
key={time}
className="inline-flex items-center gap-2 rounded-full bg-gray-100 px-3 py-1.5 text-sm ring-1 ring-gray-200 dark:bg-white/10 dark:ring-white/10"
>
<button
type="button"
className="font-medium text-gray-900 hover:underline dark:text-white"
onClick={() => seekTo(time)}
>
Split {idx + 1}: {formatTime(time)}
</button>
<button
type="button"
className="text-red-600 hover:underline dark:text-red-300"
onClick={() => removeSplit(time)}
>
entfernen
</button>
</div>
))
)}
</div>
)}
</div>
</div>
</section>
<aside className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="mb-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Segmente ({selectedSegments.length}/{segments.length})
</div>
{segments.length > 0 ? (
<div className="text-xs text-gray-500 dark:text-gray-400">
Nur ausgewählte Segmente werden gesplittet
</div>
) : null}
</div>
{segments.length > 0 ? (
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={selectAllSegments}>
Alle wählen
</Button>
<Button variant="secondary" onClick={clearSegmentSelection}>
Auswahl aufheben
</Button>
</div>
) : null}
</div>
<div className="max-h-[62vh] space-y-3 overflow-y-auto pr-1">
{segments.map((seg) => {
const selected = isSegmentSelected(seg.index)
const previewSrc = buildSegmentPreviewSrc(job, seg)
return (
<div
key={seg.index}
role="button"
tabIndex={0}
className={cn(
'group block w-full overflow-hidden rounded-2xl border text-left transition',
selected
? 'border-indigo-400/60 bg-indigo-500/10 shadow-[0_0_0_1px_rgba(99,102,241,0.08)] dark:border-indigo-400/45'
: 'border-gray-200 bg-white hover:border-gray-300 dark:border-white/10 dark:bg-white/5 dark:hover:border-white/15'
)}
onClick={() => toggleSegmentSelection(seg.index)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
toggleSegmentSelection(seg.index)
}
}}
>
<div className="relative aspect-video overflow-hidden">
{previewSrc ? (
<img
src={previewSrc}
alt={seg.label}
className="absolute inset-0 h-full w-full object-cover"
draggable={false}
/>
) : (
<div className="absolute inset-0 grid place-items-center bg-black text-xs text-white/70">
Kein Preview
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/35 to-black/20" />
<div className="absolute inset-0 flex flex-col justify-between p-3">
<div className="flex items-start gap-2">
<Checkbox
checked={selected}
onChange={() => toggleSegmentSelection(seg.index)}
ariaLabel={`${seg.label.replaceAll('_', ' ')} auswählen`}
className="size-5"
/>
<div className="min-w-0 flex-1">
<div
className="truncate text-sm font-semibold text-white"
style={{
textShadow:
'0 1px 2px rgba(0,0,0,0.95), 0 0 6px rgba(0,0,0,0.85), 0 0 12px rgba(0,0,0,0.55)',
}}
>
{seg.label.replaceAll('_', ' ')}
</div>
</div>
</div>
<div className="flex justify-end">
<button
type="button"
className="rounded-full cursor-pointer select-none bg-black/45 px-2.5 py-1 text-[11px] font-medium text-white backdrop-blur-sm transition hover:bg-black/60"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
seekTo(seg.start)
}}
title={`Zur Position ${formatTime(seg.start)} springen`}
>
{formatTime(seg.start)} {formatTime(seg.end)}
</button>
</div>
</div>
</div>
</div>
)
})}
{segments.length === 0 ? (
<div className="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-500 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10">
Noch keine Segmente vorhanden.
</div>
) : null}
</div>
</aside>
</div>
</div>
</Modal>
)
}