// frontend\src\components\ui\VideoSplitModal.tsx 'use client' import { useEffect, useRef, useState, useMemo, useCallback, type MouseEvent, type PointerEvent, type CSSProperties } from 'react' import type { RecordJob } from '../../types' import Modal from './Modal' import Button from './Button' import Checkbox from './Checkbox' import { LinkIcon } from '@heroicons/react/24/outline' import { getSegmentLabelText, getSegmentLabelItem, } from './Icons' import { isKnownFrontendPositionLabel, normalizeAiLabel, rawAiLabelParts, segmentTitleFromLabel, } from '../../aiLabels' import { formatDateTime } from './formatters' type Segment = { index: number label: string start: number end: number duration: number markerTime?: number } type SplitApplyProgress = { phase: 'preparing' | 'splitting' | 'done' | 'error' total: number current: number completed: number segmentProgress?: number overallProgress?: number message?: string segment?: Pick } type Props = { open: boolean onClose: () => void job: RecordJob | null videoSrc: string timelinePreviewCount?: number onApply?: (payload: { job: RecordJob splits: number[] segments: Segment[] deleteOriginal: boolean onProgress?: (progress: SplitApplyProgress) => void }) => 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 ): 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 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 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 parseAiCandidate(candidate: unknown): any { if (!candidate) return null if (typeof candidate === 'string') { try { const parsed = JSON.parse(candidate) return parsed && typeof parsed === 'object' ? parsed : null } catch { return null } } return typeof candidate === 'object' ? candidate : null } function extractAiPayload(data: any): any { if (!data || typeof data !== 'object') return null const candidates = [ // ✅ neuer Backend-Pfad data?.meta?.analysis?.highlights, data?.meta?.analysis?.Highlights, data?.analysis?.highlights, data?.analysis?.Highlights, data?.highlights, data?.Highlights, // Altbestand weiter unterstützen 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, // alternative Wrapper, falls API/Meta anders verpackt ist data?.metaJson?.analysis?.highlights, data?.metaJson?.analysis?.Highlights, data?.metaJson?.analysis?.ai, data?.metaJson?.ai, data?.metadata?.analysis?.highlights, data?.metadata?.analysis?.Highlights, data?.metadata?.analysis?.ai, data?.metadata?.ai, ] for (const candidate of candidates) { const parsed = parseAiCandidate(candidate) if (parsed && typeof parsed === 'object') { const hasHits = Array.isArray(parsed?.hits) || Array.isArray(parsed?.Hits) const hasSegments = Array.isArray(parsed?.segments) || Array.isArray(parsed?.Segments) if (hasHits || hasSegments) { return parsed } } } 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, } }) } 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() // Combo-Labels werden in Backend-Prioritätsreihenfolge gebaut (position > body > object > clothing). // Jeden Teil einzeln prüfen und das erste Ergebnis zurückgeben — so verhält sich auch RatingOverlay. if (rawLabel.toLowerCase().startsWith('combo:')) { const parts = rawLabel.slice(6).split('+') for (const part of parts) { const kind = segmentVisualKindFromText(part.trim()) if (kind !== 'default') return kind } return 'default' } 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 { const label = String(hit.label || '').trim().toLowerCase() return ( label.startsWith('combo:') || label.startsWith('position:') || label.startsWith('object:') || label.startsWith('clothing:') || label.startsWith('body:') ) } function segmentPairKey(aIndex: number, bIndex: number): string { const a = Math.min(aIndex, bIndex) const b = Math.max(aIndex, bIndex) return `${a}:${b}` } // Höchstpriorisierter Roh-Label-Teil (Position > Objekt > Körper > Kleidung), // damit Icon und Text genauso wie im RatingOverlay die Position bevorzugen // statt z.B. "Vagina" aus einem Combo-Label zu ziehen. function primaryIconLabelFromParts(parts: string[]): string { if (parts.length === 0) return '' if (parts.length === 1) return parts[0] return ( parts.find((part) => ( part.startsWith('position:') || isKnownFrontendPositionLabel(normalizeAiLabel(part)) )) ?? parts.find((part) => part.startsWith('object:')) ?? parts.find((part) => part.startsWith('body:')) ?? parts.find((part) => part.startsWith('clothing:')) ?? parts[0] ) } function groupPrimaryIconLabel(group: Segment[]): string { const parts = group.flatMap((seg) => rawAiLabelParts(seg.label)) return primaryIconLabelFromParts(parts) } function buildMergedSegmentLabel(group: Segment[]): string { // segmentTitleFromLabel bevorzugt die Position (wie im RatingOverlay), // statt über getSegmentLabelText auf das Körperteil zurückzufallen. const labels = Array.from( new Set( group .map((seg) => segmentTitleFromLabel(seg.label).trim()) .filter(Boolean) ) ) if (labels.length === 0) return `Segment ${group[0]?.index ?? 1}` if (labels.length === 1) return labels[0] return labels.join(' + ') } function buildSegmentGroupKey(group: Segment[]): string { return group.map((seg) => seg.index).join(':') } export default function VideoSplitModal({ open, onClose, job, videoSrc, timelinePreviewCount = 10, onApply, }: Props) { const videoRef = useRef(null) const timelineRef = useRef(null) const fusionTimerRef = useRef(null) const mergedPulseTimerRef = useRef(null) const fusionRafRef = useRef(null) const [fusionStage, setFusionStage] = useState<'idle' | 'fadeOut' | 'fadeIn'>('idle') const [pendingFusionPair, setPendingFusionPair] = useState(null) const [enteringMergedGroupKey, setEnteringMergedGroupKey] = useState(null) const [showEnteringMergedGroup, setShowEnteringMergedGroup] = useState(false) const [mediaDuration, setMediaDuration] = useState(0) const [duration, setDuration] = useState(0) const [currentTime, setCurrentTime] = useState(0) const [playing, setPlaying] = useState(false) const [splits, setSplits] = useState([]) const [busy, setBusy] = useState(false) const [aiError, setAiError] = useState('') const [applyError, setApplyError] = useState('') const [splitProgress, setSplitProgress] = useState(null) const [deleteOriginal, setDeleteOriginal] = useState(false) const [connectedSegmentPairs, setConnectedSegmentPairs] = useState([]) const [spriteMeta, setSpriteMeta] = useState(null) const [hoverRatio, setHoverRatio] = useState(null) const [selectedSegmentIndices, setSelectedSegmentIndices] = useState([]) const [previewCycleTick, setPreviewCycleTick] = useState(0) const [aiSegments, setAiSegments] = useState([]) const [aiHits, setAiHits] = useState>([]) useEffect(() => { return () => { if (fusionTimerRef.current) window.clearTimeout(fusionTimerRef.current) if (mergedPulseTimerRef.current) window.clearTimeout(mergedPulseTimerRef.current) if (fusionRafRef.current) window.cancelAnimationFrame(fusionRafRef.current) } }, []) useEffect(() => { if (!open) return if (fusionTimerRef.current) window.clearTimeout(fusionTimerRef.current) if (mergedPulseTimerRef.current) window.clearTimeout(mergedPulseTimerRef.current) if (fusionRafRef.current) window.cancelAnimationFrame(fusionRafRef.current) setCurrentTime(0) setDuration(0) setMediaDuration(0) setPlaying(false) setSplits([]) setSelectedSegmentIndices([]) setAiSegments([]) setAiHits([]) setAiError('') setApplyError('') setSplitProgress(null) setDeleteOriginal(false) setConnectedSegmentPairs([]) setPreviewCycleTick(0) setPendingFusionPair(null) setEnteringMergedGroupKey(null) setShowEnteringMergedGroup(false) setFusionStage('idle') }, [open, job?.id]) const fusionActive = pendingFusionPair !== null || fusionStage !== 'idle' || enteringMergedGroupKey !== null useEffect(() => { if (!open || fusionActive) return const id = window.setInterval(() => { setPreviewCycleTick((prev) => prev + 1) }, 3000) return () => { window.clearInterval(id) } }, [open, fusionActive]) 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]) 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 rawMetaAiSegments = metaAiSegmentsFromMeta.length > 0 ? metaAiSegmentsFromMeta : normalizeAiSegmentsFromHits( ai?.hits ?? ai?.Hits ?? [], resolvedDuration, mapAiTimes ) const metaAiSegments = filterSegmentsLikeRatingOverlay(rawMetaAiSegments) 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 = 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 = useCallback( (e: PointerEvent) => { updateHoverFromClientX(e.clientX) }, [updateHoverFromClientX] ) const onTimelinePointerEnter = useCallback( (e: PointerEvent) => { updateHoverFromClientX(e.clientX) }, [updateHoverFromClientX] ) const onTimelinePointerLeave = useCallback(() => { setHoverRatio(null) }, []) const seekTo = 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 = useCallback(async () => { const video = videoRef.current if (!video) return if (video.paused) { try { await video.play() } catch { // ignore } } else { video.pause() } }, []) const addSplitAt = useCallback( (rawTime: number) => { if (busy) return 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, busy] ) const removeSplit = useCallback((time: number) => { if (busy) return setAiSegments([]) setSplits((prev) => prev.filter((x) => x !== time)) }, [busy]) const clearSplits = useCallback(() => { if (busy) return setAiSegments([]) setSplits([]) }, [busy]) const onTimelineClick = useCallback( (e: MouseEvent) => { if (busy) return 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, busy] ) const manualSegments = 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 = useMemo(() => { if (aiSegments.length > 0) { return aiSegments.map((seg, idx) => ({ ...seg, index: idx + 1, })) } return manualSegments }, [aiSegments, manualSegments]) const displaySegmentGroups = useMemo(() => { if (segments.length === 0) return [] as Array<{ key: string segments: Segment[] }> const ordered = [...segments].sort((a, b) => a.index - b.index) const groups: Array<{ key: string segments: Segment[] }> = [] let currentGroup: Segment[] = [ordered[0]] for (let i = 1; i < ordered.length; i++) { const prev = ordered[i - 1] const next = ordered[i] const connected = next.index === prev.index + 1 && connectedSegmentPairs.includes(segmentPairKey(prev.index, next.index)) if (connected) { currentGroup.push(next) } else { groups.push({ key: currentGroup.map((seg) => seg.index).join(':'), segments: currentGroup, }) currentGroup = [next] } } groups.push({ key: currentGroup.map((seg) => seg.index).join(':'), segments: currentGroup, }) return groups }, [segments, connectedSegmentPairs]) const isGroupFullySelected = useCallback( (group: Segment[]) => group.every((seg) => selectedSegmentIndices.includes(seg.index)), [selectedSegmentIndices] ) const isGroupPartiallySelected = useCallback( (group: Segment[]) => group.some((seg) => selectedSegmentIndices.includes(seg.index)) && !group.every((seg) => selectedSegmentIndices.includes(seg.index)), [selectedSegmentIndices] ) const toggleSegmentGroupSelection = useCallback((group: Segment[]) => { if (busy) return const groupIndices = group.map((seg) => seg.index) setSelectedSegmentIndices((prev) => { const allSelected = groupIndices.every((idx) => prev.includes(idx)) if (allSelected) { return prev.filter((idx) => !groupIndices.includes(idx)) } return Array.from(new Set([...prev, ...groupIndices])).sort((a, b) => a - b) }) }, [busy]) const adjacentSegmentPairs = useMemo(() => { return segments.slice(0, -1).map((seg, idx) => { const next = segments[idx + 1] return { left: seg, right: next, key: segmentPairKey(seg.index, next.index), } }) }, [segments]) useEffect(() => { const validKeys = new Set(adjacentSegmentPairs.map((pair) => pair.key)) setConnectedSegmentPairs((prev) => prev.filter((key) => validKeys.has(key))) }, [adjacentSegmentPairs]) const connectSegmentGroups = useCallback( (leftGroup: Segment[], rightGroup: Segment[]) => { if (busy || leftGroup.length === 0 || rightGroup.length === 0) return const leftLast = leftGroup[leftGroup.length - 1] const rightFirst = rightGroup[0] const pairKey = segmentPairKey(leftLast.index, rightFirst.index) const mergedKey = buildSegmentGroupKey([...leftGroup, ...rightGroup]) setSelectedSegmentIndices((prev) => Array.from( new Set([ ...prev, ...leftGroup.map((seg) => seg.index), ...rightGroup.map((seg) => seg.index), ]) ).sort((a, b) => a - b) ) if (fusionTimerRef.current) window.clearTimeout(fusionTimerRef.current) if (mergedPulseTimerRef.current) window.clearTimeout(mergedPulseTimerRef.current) if (fusionRafRef.current) window.cancelAnimationFrame(fusionRafRef.current) setPendingFusionPair(pairKey) setEnteringMergedGroupKey(null) setShowEnteringMergedGroup(false) setFusionStage('fadeOut') fusionTimerRef.current = window.setTimeout(() => { setConnectedSegmentPairs((prev) => prev.includes(pairKey) ? prev : [...prev, pairKey] ) setPendingFusionPair(null) setEnteringMergedGroupKey(mergedKey) setShowEnteringMergedGroup(false) setFusionStage('fadeIn') fusionRafRef.current = window.requestAnimationFrame(() => { fusionRafRef.current = window.requestAnimationFrame(() => { setShowEnteringMergedGroup(true) }) }) mergedPulseTimerRef.current = window.setTimeout(() => { setFusionStage('idle') setEnteringMergedGroupKey(null) setShowEnteringMergedGroup(false) }, 220) }, 180) }, [busy] ) const disconnectSegmentGroup = useCallback((group: Segment[]) => { if (busy || group.length < 2) return const keysToRemove = new Set() for (let i = 0; i < group.length - 1; i++) { keysToRemove.add(segmentPairKey(group[i].index, group[i + 1].index)) } setConnectedSegmentPairs((prev) => prev.filter((key) => !keysToRemove.has(key))) }, [busy]) const timelinePreviewTiles = 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 = useMemo(() => { return segments.filter((seg) => selectedSegmentIndices.includes(seg.index)) }, [segments, selectedSegmentIndices]) const segmentGroupsToApply = useMemo(() => { if (selectedSegments.length === 0) return [] const ordered = [...selectedSegments].sort((a, b) => a.index - b.index) const groups: Segment[][] = [] let currentGroup: Segment[] = [ordered[0]] for (let i = 1; i < ordered.length; i++) { const prev = ordered[i - 1] const next = ordered[i] const directlyAdjacent = next.index === prev.index + 1 const connected = directlyAdjacent && connectedSegmentPairs.includes(segmentPairKey(prev.index, next.index)) if (connected) { currentGroup.push(next) } else { groups.push(currentGroup) currentGroup = [next] } } groups.push(currentGroup) return groups }, [selectedSegments, connectedSegmentPairs]) const segmentsToApply = useMemo(() => { return segmentGroupsToApply.map((group, idx) => { const start = group[0].start const end = group[group.length - 1].end return { index: idx + 1, label: buildMergedSegmentLabel(group), start, end, duration: Math.max(0, end - start), markerTime: start + (end - start) / 2, } }) }, [segmentGroupsToApply]) const selectedSegmentApplyOrderMap = useMemo(() => { const map = new Map() segmentGroupsToApply.forEach((group, groupIdx) => { group.forEach((seg) => { map.set(seg.index, groupIdx + 1) }) }) return map }, [segmentGroupsToApply]) const splitStateBySegmentIndex = useMemo(() => { const out = new Map() if (!busy || !splitProgress) return out const currentOrder = Number(splitProgress.current || 0) const completed = Number(splitProgress.completed || 0) const runningOrder = splitProgress.segment?.index != null ? Number(splitProgress.segment.index) : splitProgress.phase === 'splitting' ? currentOrder : null const runningProgress = clamp(splitProgress.segmentProgress ?? 0, 0, 1) for (const seg of selectedSegments) { const order = selectedSegmentApplyOrderMap.get(seg.index) ?? 0 if (splitProgress.phase === 'done' || (order > 0 && order <= completed)) { out.set(seg.index, { state: 'done', progress: 1 }) continue } if (runningOrder != null && order === runningOrder) { out.set(seg.index, { state: 'running', progress: runningProgress }) continue } out.set(seg.index, { state: 'queued', progress: 0 }) } return out }, [busy, splitProgress, selectedSegments, selectedSegmentApplyOrderMap]) 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 = useMemo(() => { if (hoverRatio == null || effectiveDuration <= 0) return null return clamp(hoverRatio, 0, 1) * effectiveDuration }, [hoverRatio, effectiveDuration]) const hoverFrameIndex = useMemo(() => { if (!spriteMeta?.exists || !spriteMeta.path || hoverTime == null) return null return frameIndexFromTime(hoverTime, effectiveDuration, spriteMeta) }, [spriteMeta, hoverTime, effectiveDuration]) const selectAllSegments = useCallback(() => { if (busy) return setSelectedSegmentIndices(segments.map((seg) => seg.index)) }, [segments, busy]) const clearSegmentSelection = useCallback(() => { if (busy) return setSelectedSegmentIndices([]) }, [busy]) const currentRatio = effectiveDuration > 0 ? clamp(currentTime / effectiveDuration, 0, 1) : 0 const title = job ? `Video schneiden – ${baseName(job.output || '')}` : 'Video schneiden' const previewStillSrc = useMemo(() => previewStillSrcFromJob(job), [job]) const previewSpritesSrc = useMemo(() => previewSpritesSrcFromJob(job), [job]) const anyJob = job as any const model = useMemo(() => modelNameFromOutput(job?.output), [job?.output]) const file = 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 = 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]) 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 ( } >
{videoSrc ? (
{formatTime(currentTime)} / {formatTime(effectiveDuration)}
{aiError ? (
{aiError}
) : null} {applyError ? (
{applyError}
) : null}
{ e.stopPropagation() }} title="Klicken zum Springen" > {spriteMeta?.exists && spriteMeta.path && timelinePreviewTiles.length > 0 ? (
) }