// 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 } 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) { 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(...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(null) const timelineRef = React.useRef(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([]) const [busy, setBusy] = React.useState(false) const [aiError, setAiError] = React.useState('') const [spriteMeta, setSpriteMeta] = React.useState(null) const [hoverRatio, setHoverRatio] = React.useState(null) const [selectedSegmentIndices, setSelectedSegmentIndices] = React.useState([]) const [aiSegments, setAiSegments] = React.useState([]) const [aiHits, setAiHits] = React.useState>([]) 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) => { updateHoverFromClientX(e.clientX) }, [updateHoverFromClientX] ) const onTimelinePointerEnter = React.useCallback( (e: React.PointerEvent) => { 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) => { 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(() => { 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(() => { 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 ( } >
{videoSrc ? (
{formatTime(currentTime)} / {formatTime(effectiveDuration)}
{aiError ? (
{aiError}
) : null}
{ e.stopPropagation() }} title="Klicken zum Springen" > {spriteMeta?.exists && spriteMeta.path && timelinePreviewTiles.length > 0 ? (
) }