nsfwapp/frontend/src/components/ui/VideoSplitModal.tsx
2026-06-12 10:18:40 +02:00

2691 lines
88 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 { 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<Segment, 'index' | 'label' | 'start' | 'end' | 'duration'>
}
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<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
): 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 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<T = any>(...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<HTMLVideoElement | null>(null)
const timelineRef = useRef<HTMLDivElement | null>(null)
const fusionTimerRef = useRef<number | null>(null)
const mergedPulseTimerRef = useRef<number | null>(null)
const fusionRafRef = useRef<number | null>(null)
const [fusionStage, setFusionStage] = useState<'idle' | 'fadeOut' | 'fadeIn'>('idle')
const [pendingFusionPair, setPendingFusionPair] = useState<string | null>(null)
const [enteringMergedGroupKey, setEnteringMergedGroupKey] = useState<string | null>(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<number[]>([])
const [busy, setBusy] = useState(false)
const [aiError, setAiError] = useState('')
const [applyError, setApplyError] = useState('')
const [splitProgress, setSplitProgress] = useState<SplitApplyProgress | null>(null)
const [deleteOriginal, setDeleteOriginal] = useState(false)
const [connectedSegmentPairs, setConnectedSegmentPairs] = useState<string[]>([])
const [spriteMeta, setSpriteMeta] = useState<PreviewSpriteMeta | null>(null)
const [hoverRatio, setHoverRatio] = useState<number | null>(null)
const [selectedSegmentIndices, setSelectedSegmentIndices] = useState<number[]>([])
const [previewCycleTick, setPreviewCycleTick] = useState(0)
const [aiSegments, setAiSegments] = useState<Segment[]>([])
const [aiHits, setAiHits] = useState<Array<{
time: number
label: string
score?: number
start?: number
end?: number
}>>([])
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<HTMLDivElement>) => {
updateHoverFromClientX(e.clientX)
},
[updateHoverFromClientX]
)
const onTimelinePointerEnter = useCallback(
(e: PointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<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 = useMemo<Segment[]>(() => {
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<string>()
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<Segment[][]>(() => {
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<Segment[]>(() => {
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<number, number>()
segmentGroupsToApply.forEach((group, groupIdx) => {
group.forEach((seg) => {
map.set(seg.index, groupIdx + 1)
})
})
return map
}, [segmentGroupsToApply])
const splitStateBySegmentIndex = useMemo(() => {
const out = new Map<number, { state: 'queued' | 'running' | 'done'; progress: number }>()
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 (
<Modal
open={open}
onClose={onClose}
title={title}
width="max-w-[96vw]"
footer={
<>
<Button variant="secondary" onClick={onClose}>
Schließen
</Button>
<Button
variant="primary"
disabled={!job || !videoSrc || busy || segmentsToApply.length === 0}
onClick={async () => {
if (!job) return
setBusy(true)
setApplyError('')
setSplitProgress({
phase: 'preparing',
total: segmentsToApply.length,
current: 0,
completed: 0,
segmentProgress: 0,
overallProgress: 0,
message: 'Split wird vorbereitet…',
})
try {
await onApply?.({
job,
splits: [],
segments: segmentsToApply,
deleteOriginal,
onProgress: (progress) => {
setSplitProgress(progress)
},
})
// absichtlich NICHT automatisch schließen
// onClose()
} catch (err) {
const message =
err instanceof Error ? err.message : 'Split fehlgeschlagen.'
setApplyError(message)
setSplitProgress((prev) => ({
phase: 'error',
total: prev?.total ?? segmentsToApply.length,
current: prev?.current ?? 0,
completed: prev?.completed ?? 0,
segmentProgress: prev?.segmentProgress ?? 0,
overallProgress: prev?.overallProgress ?? 0,
message,
segment: prev?.segment,
}))
} finally {
setBusy(false)
}
}}
>
{busy
? 'Wird erzeugt…'
: segmentsToApply.length === 1
? '1 Datei erzeugen'
: `${segmentsToApply.length} Dateien erzeugen`}
</Button>
</>
}
>
<div className="p-4 sm:p-6 xl:h-[75vh] xl:max-h-[75vh] xl:overflow-hidden">
<div className="grid gap-4 xl:grid-cols-[300px_minmax(0,1fr)_320px] xl:h-full xl:min-h-0">
<aside className="min-h-0 xl:h-full rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5 xl:flex xl:flex-col xl:overflow-hidden">
<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 xl:h-full xl:min-h-0 xl:flex xl:flex-col xl: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 xl:flex-1 xl:min-h-0">
<div className="mb-3 flex flex-wrap items-center gap-2">
<Button variant="secondary" onClick={() => seekTo(currentTime - 5)} disabled={!videoSrc || busy}>
-5s
</Button>
<Button variant="primary" onClick={togglePlayback} disabled={!videoSrc || busy}>
{playing ? 'Pause' : 'Play'}
</Button>
<Button variant="secondary" onClick={() => seekTo(currentTime + 5)} disabled={!videoSrc || busy}>
+5s
</Button>
<Button variant="secondary" onClick={() => addSplitAt(currentTime)} disabled={!videoSrc || effectiveDuration <= 0 || busy}>
Split bei aktueller Zeit
</Button>
<Button variant="secondary" onClick={clearSplits} disabled={splits.length === 0 || busy}>
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}
{applyError ? (
<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">
{applyError}
</div>
) : null}
<div className="space-y-3">
<div className="relative">
<div
ref={timelineRef}
className="relative h-24 cursor-pointer select-none overflow-hidden 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}%` }}
/>
{displaySegmentGroups.map((group) => {
if (effectiveDuration <= 0) return null
const firstSeg = group.segments[0]
const lastSeg = group.segments[group.segments.length - 1]
const left = (firstSeg.start / effectiveDuration) * 100
const width = ((lastSeg.end - firstSeg.start) / effectiveDuration) * 100
const safeWidth = Math.max(width, 0.35)
const selected = isGroupFullySelected(group.segments)
const partiallySelected = isGroupPartiallySelected(group.segments)
const mergedLabel = buildMergedSegmentLabel(group.segments)
const cleanLabel = mergedLabel
const topGroupLabelItem = getSegmentLabelItem(
groupPrimaryIconLabel(group.segments)
)
const showTextLabel = width >= 8
const isTinySegment = width < 8
return (
<div
key={`segment-overlay-group-${group.key}`}
className="pointer-events-none absolute inset-y-0"
style={{
left: `${left}%`,
width: `${safeWidth}%`,
}}
title={`${cleanLabel} · ${formatTime(firstSeg.start)}${formatTime(lastSeg.end)}`}
>
<span
className={cn(
'absolute inset-y-0 inset-x-0 rounded-sm ring-1',
selected
? 'bg-amber-400/20 ring-amber-300/55'
: partiallySelected
? 'bg-amber-400/15 ring-amber-300/40'
: 'bg-amber-400/10 ring-amber-300/25'
)}
/>
<span
className={cn(
'absolute inset-y-0 left-0 w-[2px]',
selected ? 'bg-amber-300/80' : 'bg-amber-300/35'
)}
/>
<span
className={cn(
'absolute inset-y-0 right-0 w-[2px]',
selected ? 'bg-amber-300/80' : 'bg-amber-300/35'
)}
/>
<div
className={cn(
'absolute top-2 z-10 flex',
isTinySegment ? 'left-1/2 -translate-x-1/2' : 'inset-x-1 justify-center'
)}
>
<span
className={cn(
'inline-flex items-center rounded-full font-semibold backdrop-blur-sm',
selected
? 'bg-amber-400/90 text-black shadow'
: 'bg-black/45 text-white/90',
showTextLabel
? 'max-w-full gap-1 truncate px-2 py-1 text-[10px]'
: 'h-8 w-8 justify-center p-0'
)}
>
<span className={cn('inline-flex items-center', showTextLabel ? 'gap-1' : '')}>
{(() => {
const Icon = topGroupLabelItem.icon
return (
<span
className={cn(
'inline-flex items-center justify-center rounded-full',
showTextLabel ? 'size-4' : 'size-5'
)}
title={topGroupLabelItem.text}
>
<Icon
className={cn(
showTextLabel ? 'size-5 shrink-0' : 'size-5'
)}
aria-hidden="true"
/>
</span>
)
})()}
{showTextLabel ? <span className="truncate">{cleanLabel}</span> : null}
</span>
</span>
</div>
</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) => {
if (busy) return
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>
</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 disabled:cursor-not-allowed disabled:opacity-50 dark:text-white"
onClick={() => {
if (busy) return
seekTo(time)
}}
disabled={busy}
>
Split {idx + 1}: {formatTime(time)}
</button>
<button
type="button"
className="text-red-600 hover:underline disabled:cursor-not-allowed disabled:opacity-50 dark:text-red-300"
onClick={() => removeSplit(time)}
disabled={busy}
>
entfernen
</button>
</div>
))
)}
</div>
)}
</div>
</div>
</section>
<aside className="min-h-0 xl:h-full xl:overflow-y-auto xl:overflow-x-hidden xl:overscroll-contain rounded-2xl border border-gray-200 bg-white p-4 pr-3 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="space-y-3 overflow-x-hidden">
<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}) Ausgabe: {segmentsToApply.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} disabled={busy}>
Alle wählen
</Button>
<Button variant="secondary" onClick={clearSegmentSelection} disabled={busy}>
Auswahl aufheben
</Button>
</div>
) : null}
</div>
<div className="mt-3 mb-3 shrink-0 space-y-3">
<div className="rounded-xl bg-gray-50 px-3 py-3 ring-1 ring-gray-200 dark:bg-white/5 dark:ring-white/10">
<label
className={cn(
'flex items-start gap-3',
busy ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
)}
>
<Checkbox
checked={deleteOriginal}
disabled={busy}
onChange={setDeleteOriginal}
className="mt-0.5 shrink-0"
/>
<span className="min-w-0">
<span className="block text-sm font-medium text-gray-900 dark:text-white">
Originaldatei löschen
</span>
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-400">
Nach erfolgreichem Split wird die Originaldatei entfernt.
</span>
</span>
</label>
</div>
</div>
{showSplitProgress ? (
<div
className={cn(
'mb-3 rounded-xl px-3 py-3 ring-1',
splitProgress?.phase === 'error'
? 'bg-red-50 text-red-900 ring-red-200 dark:bg-red-500/10 dark:text-red-100 dark:ring-red-400/30'
: splitProgress?.phase === 'done'
? 'bg-emerald-50 text-emerald-900 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-100 dark:ring-emerald-400/30'
: 'bg-indigo-50 text-indigo-950 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/30'
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold">
{splitProgress?.phase === 'done'
? 'Split abgeschlossen'
: splitProgress?.phase === 'error'
? 'Split fehlgeschlagen'
: 'Split läuft…'}
</div>
<div className="mt-0.5 text-xs opacity-80">
{splitProgress?.message || 'Bitte warten…'}
</div>
{splitCurrentLabel ? (
<div className="mt-1 truncate text-[11px] opacity-75">
{splitCurrentLabel}
{splitCurrentTime ? ` · ${splitCurrentTime}` : ''}
</div>
) : null}
</div>
<div className="shrink-0 text-right">
<div className="text-sm font-black">
{splitOverallPercent}%
</div>
<div className="mt-0.5 text-[11px] opacity-70">
{splitProgress?.completed ?? 0}/{splitProgress?.total ?? segmentsToApply.length}
</div>
</div>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-black/10 dark:bg-white/15">
<div
className={cn(
'h-full rounded-full transition-[width] duration-150',
splitProgress?.phase === 'error'
? 'bg-red-500'
: splitProgress?.phase === 'done'
? 'bg-emerald-500'
: 'bg-indigo-500'
)}
style={{ width: `${splitOverallPercent}%` }}
/>
</div>
{splitProgress?.phase === 'splitting' ? (
<div className="mt-1 flex items-center justify-between text-[10px] opacity-70">
<span>Aktuelles Segment</span>
<span>{splitSegmentPercent}%</span>
</div>
) : null}
</div>
) : null}
<div className="space-y-3">
{displaySegmentGroups.map((group, groupIdx) => {
const firstSeg = group.segments[0]
const lastSeg = group.segments[group.segments.length - 1]
const mergedLabel = buildMergedSegmentLabel(group.segments)
const previewSources = Array.from(
new Set(
group.segments
.map((seg) => buildSegmentPreviewSrc(job, seg))
.filter(Boolean)
)
)
const isConnectedGroup = group.segments.length > 1
const activePreviewIndex =
isConnectedGroup && previewSources.length > 1
? (previewCycleTick + groupIdx) % previewSources.length
: 0
const selected = isGroupFullySelected(group.segments)
const partiallySelected = isGroupPartiallySelected(group.segments)
const splitUi = splitStateBySegmentIndex.get(firstSeg.index)
const splitState = splitUi?.state
const splitPercent = Math.round((splitUi?.progress ?? 0) * 100)
const nextGroup = displaySegmentGroups[groupIdx + 1] ?? null
const prevGroup = displaySegmentGroups[groupIdx - 1] ?? null
const canConnectToNext =
nextGroup &&
lastSeg.index + 1 === nextGroup.segments[0].index
const fusingIntoNext =
nextGroup &&
pendingFusionPair === segmentPairKey(lastSeg.index, nextGroup.segments[0].index)
const fusingFromPrev =
prevGroup &&
pendingFusionPair ===
segmentPairKey(
prevGroup.segments[prevGroup.segments.length - 1].index,
firstSeg.index
)
const isFusingConnector = Boolean(
nextGroup &&
pendingFusionPair ===
segmentPairKey(lastSeg.index, nextGroup.segments[0].index)
)
const isFadingOut = (fusingIntoNext || fusingFromPrev) && fusionStage === 'fadeOut'
const isEnteringMerged = enteringMergedGroupKey === group.key
const fusionCardClass = cn(
isFadingOut && 'opacity-0 scale-[0.985]',
isEnteringMerged && !showEnteringMergedGroup && 'opacity-0 scale-[0.985]',
isEnteringMerged && showEnteringMergedGroup && 'opacity-100 scale-100'
)
const topGroupLabelItem = getSegmentLabelItem(
groupPrimaryIconLabel(group.segments)
)
return (
<div key={group.key} className="space-y-2">
<div
role="button"
aria-disabled={busy}
tabIndex={busy ? -1 : 0}
className={cn(
'group relative block w-full overflow-hidden rounded-2xl border text-left transform-gpu transition-[transform,opacity] duration-200 ease-out',
busy && 'cursor-not-allowed',
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'
: partiallySelected
? 'border-indigo-300/40 bg-indigo-500/5 dark:border-indigo-400/25'
: 'border-gray-200 bg-white hover:border-gray-300 dark:border-white/10 dark:bg-white/5 dark:hover:border-white/15',
splitState === 'running' && 'ring-2 ring-indigo-400/60',
splitState === 'done' && 'border-emerald-400/60 bg-emerald-500/10 dark:border-emerald-400/45',
fusionCardClass
)}
onClick={() => {
if (busy) return
toggleSegmentGroupSelection(group.segments)
}}
onKeyDown={(e) => {
if (busy) return
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
toggleSegmentGroupSelection(group.segments)
}
}}
>
<div className="relative aspect-video overflow-hidden">
{previewSources.length > 0 ? (
<>
{previewSources.map((src, previewIdx) => (
<img
key={`${group.key}-${previewIdx}-${src}`}
src={src}
alt={mergedLabel}
draggable={false}
className={cn(
'absolute inset-0 h-full w-full object-cover transition-opacity duration-200 ease-linear',
previewIdx === activePreviewIndex ? 'opacity-100' : 'opacity-0',
!selected ? 'grayscale-60' : ''
)}
/>
))}
</>
) : (
<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">
<div
className="shrink-0"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={selected}
disabled={busy}
onChange={() => {
if (busy) return
toggleSegmentGroupSelection(group.segments)
}}
ariaLabel={`${mergedLabel} auswählen`}
className="size-5"
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<div
className="flex items-center gap-2 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)',
}}
>
{(() => {
const Icon = topGroupLabelItem.icon
return (
<span
className="inline-flex size-5 items-center justify-center rounded-full"
title={topGroupLabelItem.text}
>
<Icon className="size-4 shrink-0 text-white" aria-hidden="true" />
</span>
)
})()}
<span className="truncate">{mergedLabel}</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{busy && selected && splitState ? (
<span
className={cn(
'rounded-full px-2.5 py-1 text-[11px] font-semibold backdrop-blur-sm',
splitState === 'queued' && 'bg-white/90 text-gray-900 ring-1 ring-gray-200',
splitState === 'running' && 'bg-indigo-600 text-white',
splitState === 'done' && 'bg-emerald-600 text-white'
)}
>
{splitState === 'queued' && 'Wartet'}
{splitState === 'running' && `${splitPercent}%`}
{splitState === 'done' && 'Fertig'}
</span>
) : null}
{isConnectedGroup ? (
<button
type="button"
disabled={busy}
title="Verbindung lösen"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
disconnectSegmentGroup(group.segments)
}}
className={cn(
'inline-flex size-8 shrink-0 items-center justify-center rounded-full ring-1 backdrop-blur-sm transition-all duration-200',
busy
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer bg-indigo-600/90 text-white ring-indigo-400/60 hover:scale-105 hover:bg-indigo-500'
)}
>
<LinkIcon className="size-4" />
</button>
) : null}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between gap-2">
<div className="rounded-full bg-black/45 px-2.5 py-1 text-[11px] font-medium text-white/90 backdrop-blur-sm">
{group.segments.length > 1 ? (
`${group.segments.length} verbundene Segmente`
) : null}
</div>
<button
type="button"
className="
rounded-full cursor-pointer select-none
px-2.5 py-1 text-[11px] font-medium backdrop-blur-sm transition
bg-white/95 text-gray-900 ring-1 ring-gray-200 shadow-sm hover:bg-white
disabled:cursor-not-allowed disabled:opacity-50
dark:bg-black/70 dark:text-white dark:ring-white/10 dark:hover:bg-black/80
"
disabled={busy}
onClick={(e) => {
if (busy) return
e.preventDefault()
e.stopPropagation()
seekTo(firstSeg.start)
}}
title={`Zur Position ${formatTime(firstSeg.start)} springen`}
>
{formatTime(firstSeg.start)} {formatTime(lastSeg.end)}
</button>
</div>
{busy && selected && splitState ? (
<div className="absolute inset-x-0 bottom-0 h-1 bg-black/25">
<div
className={cn(
'h-full transition-[width] duration-150',
splitState === 'queued' && 'bg-white/40',
splitState === 'running' && 'bg-indigo-400',
splitState === 'done' && 'bg-emerald-400'
)}
style={{
width: `${splitState === 'done' ? 100 : splitState === 'running' ? splitPercent : 0}%`,
}}
/>
</div>
) : null}
</div>
</div>
</div>
{canConnectToNext ? (
<div className="relative flex h-9 items-center gap-2 px-1 overflow-hidden">
<div
className={cn(
'relative z-10 h-px flex-1 bg-gray-200 transition-opacity duration-200 ease-out dark:bg-white/10',
isFusingConnector && 'opacity-0'
)}
/>
<button
type="button"
disabled={busy || Boolean(isFusingConnector)}
title="Segmente verbinden"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
connectSegmentGroups(group.segments, nextGroup.segments)
}}
className={cn(
'relative z-10 inline-flex size-9 items-center justify-center rounded-full ring-1 transition-opacity duration-200 ease-out',
busy || isFusingConnector
? 'cursor-not-allowed'
: 'cursor-pointer bg-gray-50 text-gray-600 ring-gray-200 hover:bg-indigo-50 hover:text-indigo-700 hover:ring-indigo-200 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-indigo-500/10 dark:hover:text-indigo-200 dark:hover:ring-indigo-400/20',
isFusingConnector && 'opacity-0'
)}
>
<LinkIcon className="size-4" />
</button>
<div
className={cn(
'relative z-10 h-px flex-1 bg-gray-200 transition-opacity duration-200 ease-out dark:bg-white/10',
isFusingConnector && 'opacity-0'
)}
/>
</div>
) : null}
</div>
)
})}
{displaySegmentGroups.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>
)
}