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