2691 lines
88 KiB
TypeScript
2691 lines
88 KiB
TypeScript
// 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>
|
||
)
|
||
} |