// frontend\src\components\ui\FinishedVideoPreview.tsx 'use client' import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react' import type { RecordJob } from '../../types' import HoverPopover from './HoverPopover' import { DEFAULT_INLINE_MUTED, applyInlineVideoPolicy } from './videoPolicy' type Variant = 'thumb' | 'fill' type InlineVideoMode = false | true | 'always' | 'hover' type AnimatedMode = 'frames' | 'clips' | 'teaser' type AnimatedTrigger = 'always' | 'hover' type ProgressKind = 'inline' | 'teaser' | 'clips' export type FinishedVideoPreviewProps = { job: RecordJob getFileName: (path: string) => string /** optional legacy override (z.B. aus Cache im Parent) */ durationSeconds?: number /** Callbacks für Parent-State */ onDuration?: (job: RecordJob, seconds: number) => void onResolution?: (job: RecordJob, w: number, h: number) => void /** animated="true": frames = wechselnde Bilder, clips = 0.75s-Teaser-Clips (z.B. 12), teaser = vorgerendertes MP4 */ animated?: boolean animatedMode?: AnimatedMode animatedTrigger?: AnimatedTrigger /** nur für frames */ autoTickMs?: number thumbStepSec?: number thumbSpread?: boolean thumbSamples?: number /** nur für clips */ clipSeconds?: number clipCount?: number /** neu: thumb = w-20 h-16, fill = w-full h-full */ variant?: Variant className?: string showPopover?: boolean blur?: boolean /** * inline video: * - false: nur Bild/Teaser * - true/'always': immer inline abspielen (wenn inView) * - 'hover': nur bei Hover/Focus abspielen, sonst Bild */ inlineVideo?: InlineVideoMode /** wenn sich dieser Wert ändert, wird das inline-video neu gemounted -> startet bei 0 */ inlineNonce?: number /** Inline-Playback: Controls anzeigen? */ inlineControls?: boolean /** Inline-Playback: loopen? */ inlineLoop?: boolean assetNonce?: number /** alle Inline/Teaser/Clips muted? (Default: true) */ muted?: boolean /** Popover-Video muted? (Default: true) */ popoverMuted?: boolean noGenerateTeaser?: boolean /** Still-Preview (Bild) unabhängig vom inView-Gating laden */ alwaysLoadStill?: boolean /** Teaser-Datei vorladen, bevor das Element wirklich im Viewport ist */ teaserPreloadEnabled?: boolean /** Vorlade-Zone für Teaser (IntersectionObserver rootMargin) */ teaserPreloadRootMargin?: string scrubProgressRatio?: number preferScrubProgress?: boolean } export default function FinishedVideoPreview({ job, getFileName, durationSeconds, onDuration, onResolution, animated = false, animatedMode = 'frames', animatedTrigger = 'always', autoTickMs = 15000, thumbStepSec, thumbSpread, thumbSamples, clipSeconds = 0.75, clipCount = 12, variant = 'thumb', className, showPopover = true, blur = false, inlineVideo = false, inlineNonce = 0, inlineControls = false, inlineLoop = true, assetNonce = 0, muted = DEFAULT_INLINE_MUTED, popoverMuted = DEFAULT_INLINE_MUTED, noGenerateTeaser, alwaysLoadStill = false, teaserPreloadEnabled = false, teaserPreloadRootMargin = '700px 0px', scrubProgressRatio, preferScrubProgress = false, }: FinishedVideoPreviewProps) { const file = getFileName(job.output || '') const blurCls = blur ? 'blur-md' : '' // ✅ meta robust normalisieren (job.meta kann string sein) const meta = useMemo(() => { const m: any = (job as any)?.meta if (!m) return null if (typeof m === 'string') { try { return JSON.parse(m) } catch { return null } } return m }, [job]) // ✅ falls job.meta keine previewClips enthält: meta.json nachladen const [fetchedMeta, setFetchedMeta] = useState(null) // ✅ verhindert mehrfachen fallback-fetch pro Datei in derselben Komponenteninstanz const fetchedMetaFilesRef = useRef>(new Set()) // ✅ merge statt "meta ?? fetchedMeta" // job.meta bleibt Basis, fetchedMeta ergänzt fehlende Felder (z.B. previewClips) const metaForPreview = useMemo(() => { if (!meta && !fetchedMeta) return null if (!meta) return fetchedMeta if (!fetchedMeta) return meta return { ...meta, ...fetchedMeta } }, [meta, fetchedMeta]) // ✅ stabiler Guard für den fallback-fetch (berücksichtigt auch bereits geholtes fetchedMeta) const hasPreviewClipsInAnyMeta = useMemo(() => { let pcs: any = (metaForPreview as any)?.previewClips if (typeof pcs === 'string') { const s = pcs.trim() if (s.length > 0) { try { const parsed = JSON.parse(s) if (Array.isArray(parsed) && parsed.length > 0) return true // falls string vorhanden aber nicht parsebar, behandeln wir ihn trotzdem als "vorhanden" return true } catch { return true } } } if (Array.isArray(pcs) && pcs.length > 0) return true const nested = (metaForPreview as any)?.preview?.clips if (Array.isArray(nested) && nested.length > 0) return true return false }, [metaForPreview]) const [progressMountTick, setProgressMountTick] = useState(0) // previewClips mapping: preview.mp4 ist Concatenation von Segmenten type PreviewClip = { startSeconds: number; durationSeconds: number } type PreviewClipMap = { start: number; dur: number; cumStart: number; cumEnd: number } const previewClipMap = useMemo(() => { let pcsAny: any = (metaForPreview as any)?.previewClips // ✅ falls previewClips als JSON-string gespeichert ist if (typeof pcsAny === 'string') { try { pcsAny = JSON.parse(pcsAny) } catch { pcsAny = null } } // ✅ falls es verschachtelt ist (falls du sowas irgendwo hast) if (!Array.isArray(pcsAny) && Array.isArray((metaForPreview as any)?.preview?.clips)) { pcsAny = (metaForPreview as any).preview.clips } const pcs = pcsAny as PreviewClip[] | null if (!Array.isArray(pcs) || pcs.length === 0) return null let cum = 0 const out: PreviewClipMap[] = [] for (const c of pcs) { const start = Number((c as any)?.startSeconds) const dur = Number((c as any)?.durationSeconds) if (!Number.isFinite(start) || start < 0) continue if (!Number.isFinite(dur) || dur <= 0) continue const cumStart = cum const cumEnd = cum + dur out.push({ start, dur, cumStart, cumEnd }) cum = cumEnd } return out.length ? out : null }, [metaForPreview]) const previewClipMapKey = useMemo(() => { if (!previewClipMap) return '' // stabiler key für effect-deps return previewClipMap.map((c) => `${c.start.toFixed(3)}:${c.dur.toFixed(3)}`).join('|') }, [previewClipMap]) // (aktuell ungenutzt, aber bewusst drin gelassen) const mapPreviewTimeToGlobalTime = (tPreview: number, totalSeconds?: number) => { const m = previewClipMap if (!m || !m.length) return tPreview // clamp if (tPreview <= 0) return m[0].start const last = m[m.length - 1] if (tPreview >= last.cumEnd) { // ✅ Wenn wir die Vollvideo-Dauer kennen: Teaser-Ende = 100% (Vollvideo-Ende) if (typeof totalSeconds === 'number' && Number.isFinite(totalSeconds) && totalSeconds > 0) return totalSeconds // Fallback return last.start + last.dur } // binary search nach cumStart/cumEnd let lo = 0 let hi = m.length - 1 while (lo <= hi) { const mid = (lo + hi) >> 1 const c = m[mid] if (tPreview < c.cumStart) hi = mid - 1 else if (tPreview >= c.cumEnd) lo = mid + 1 else { // innerhalb des Segments return c.start + (tPreview - c.cumStart) } } // Fallback: nächster Clip const idx = Math.max(0, Math.min(m.length - 1, lo)) const c = m[idx] return c.start } void mapPreviewTimeToGlobalTime const effectiveW = (typeof meta?.videoWidth === 'number' && Number.isFinite(meta.videoWidth) && meta.videoWidth > 0 ? meta.videoWidth : undefined) ?? (typeof job.videoWidth === 'number' && Number.isFinite(job.videoWidth) && job.videoWidth > 0 ? job.videoWidth : undefined) const effectiveH = (typeof meta?.videoHeight === 'number' && Number.isFinite(meta.videoHeight) && meta.videoHeight > 0 ? meta.videoHeight : undefined) ?? (typeof job.videoHeight === 'number' && Number.isFinite(job.videoHeight) && job.videoHeight > 0 ? job.videoHeight : undefined) const effectiveSizeBytes = (typeof meta?.fileSize === 'number' && Number.isFinite(meta.fileSize) && meta.fileSize > 0 ? meta.fileSize : undefined) ?? (typeof job.sizeBytes === 'number' && Number.isFinite(job.sizeBytes) && job.sizeBytes > 0 ? job.sizeBytes : undefined) const effectiveFPS = (typeof meta?.fps === 'number' && Number.isFinite(meta.fps) && meta.fps > 0 ? meta.fps : undefined) ?? (typeof job.fps === 'number' && Number.isFinite(job.fps) && job.fps > 0 ? job.fps : undefined) // --- Duration normalisieren: manche Quellen liefern ms statt s const rawDuration = (typeof (metaForPreview as any)?.durationSeconds === 'number' && Number.isFinite((metaForPreview as any).durationSeconds) && (metaForPreview as any).durationSeconds > 0 ? (metaForPreview as any).durationSeconds : undefined) ?? (typeof job.durationSeconds === 'number' && Number.isFinite(job.durationSeconds) && job.durationSeconds > 0 ? job.durationSeconds : undefined) ?? (typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : undefined) // Heuristik: > 24h in Sekunden ist unplausibel für Clips; außerdem sieht ms typischerweise wie 600000 aus. const effectiveDurationSec = typeof rawDuration === 'number' && Number.isFinite(rawDuration) && rawDuration > 0 ? rawDuration > 24 * 60 * 60 ? rawDuration / 1000 : rawDuration : undefined const hasDuration = typeof effectiveDurationSec === 'number' && Number.isFinite(effectiveDurationSec) && effectiveDurationSec > 0 const hasResolution = typeof effectiveW === 'number' && typeof effectiveH === 'number' && Number.isFinite(effectiveW) && Number.isFinite(effectiveH) && effectiveW > 0 && effectiveH > 0 const commonVideoProps = { muted, playsInline: true, preload: 'metadata' as const, } const [thumbOk, setThumbOk] = useState(true) const [videoOk, setVideoOk] = useState(true) const [metaLoaded, setMetaLoaded] = useState(false) const [teaserReady, setTeaserReady] = useState(false) const [teaserOk, setTeaserOk] = useState(true) // ✅ verhindert doppelte Parent-Callbacks (z.B. Count/Meta-Refresh im Parent) const emittedDurationRef = useRef('') const emittedResolutionRef = useRef('') // inView (Viewport) const rootRef = useRef(null) const [inView, setInView] = useState(false) const [nearView, setNearView] = useState(false) // ✅ sobald einmal im Viewport gewesen -> true (damit wir danach nicht wieder entladen) const [everInView, setEverInView] = useState(false) // Tick nur für frames-Mode const [localTick, setLocalTick] = useState(0) // Hover-State (für inline hover ODER teaser hover) const [hovered, setHovered] = useState(false) const inlineMode: 'never' | 'always' | 'hover' = inlineVideo === true || inlineVideo === 'always' ? 'always' : inlineVideo === 'hover' ? 'hover' : 'never' // --- Hover-Events brauchen wir, wenn inline hover ODER teaser hover const wantsHover = inlineMode === 'hover' || (animated && (animatedMode === 'clips' || animatedMode === 'teaser') && animatedTrigger === 'hover') const stripHot = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s) const previewId = useMemo(() => { const f = getFileName(job.output || '') if (!f) return '' const base = f.replace(/\.[^.]+$/, '') // ext weg return stripHot(base).trim() }, [job.output, getFileName]) // Vollvideo (für Inline-Playback + Fallback-Metadaten via loadedmetadata) const videoSrc = useMemo(() => (file ? `/api/record/video?file=${encodeURIComponent(file)}` : ''), [file]) const sizeClass = variant === 'fill' ? 'w-full h-full' : 'w-20 h-16' const inlineRef = useRef(null) const teaserMp4Ref = useRef(null) const clipsRef = useRef(null) const lastMountedRef = useRef<{ inline: HTMLVideoElement | null teaser: HTMLVideoElement | null clips: HTMLVideoElement | null }>({ inline: null, teaser: null, clips: null, }) const bumpMountTickIfNew = (slot: 'inline' | 'teaser' | 'clips', el: HTMLVideoElement | null) => { if (!el) return if (lastMountedRef.current[slot] === el) return // ✅ gleicher DOM-Node -> NICHT nochmal tickern lastMountedRef.current[slot] = el // ✅ erst merken, dann state setzen setProgressMountTick((x) => x + 1) } // ▶️ Progressbar für abgespieltes Preview/Inline-Video const [playRatio, setPlayRatio] = useState(0) const [, setPlayGlobalSec] = useState(0) const clamp01 = (x: number) => (x < 0 ? 0 : x > 1 ? 1 : x) // ✅ FIX: Teaser-Mapping darf NICHT von currentSrc abhängen. // Ratio basiert auf vvDur (z.B. 2/18) — unabhängig von totalSeconds. const readProgressStepped = ( vv: HTMLVideoElement | null, totalSeconds: number | undefined, stepSec = clipSeconds, forceTeaserMap = false ): { ratio: number; globalSec: number; vvDur: number } => { if (!vv) return { ratio: 0, globalSec: 0, vvDur: 0 } const vvDur = Number(vv.duration) const vvDurOk = Number.isFinite(vvDur) && vvDur > 0 if (!vvDurOk) return { ratio: 0, globalSec: 0, vvDur: 0 } const tPreview = Number(vv.currentTime) if (!Number.isFinite(tPreview) || tPreview < 0) return { ratio: 0, globalSec: 0, vvDur } const m = previewClipMap let globalSec = 0 if (forceTeaserMap && Array.isArray(m) && m.length > 0) { const last = m[m.length - 1] if (tPreview >= last.cumEnd) { globalSec = typeof totalSeconds === 'number' && Number.isFinite(totalSeconds) && totalSeconds > 0 ? totalSeconds : last.start + last.dur } else { let lo = 0 let hi = m.length - 1 let segIdx = 0 while (lo <= hi) { const mid = (lo + hi) >> 1 const c = m[mid] if (tPreview < c.cumStart) hi = mid - 1 else if (tPreview >= c.cumEnd) lo = mid + 1 else { segIdx = mid break } } const seg = m[segIdx] globalSec = seg.start // ✅ Sichtbarer Teaser-Progress exakt am Segmentindex ausrichten // springt auf Beginn des aktuell aktiven Preview-Segments (kein floor-lag über currentTime) const ratio = m.length > 0 ? clamp01(segIdx / m.length) : 0 return { ratio, globalSec: Math.max(0, globalSec), vvDur } } } // inline/clips fallback if (Number.isFinite(stepSec) && stepSec > 0) { globalSec = Math.floor(tPreview / stepSec) * stepSec } else { globalSec = tPreview } const ratio = clamp01(Math.min(globalSec, vvDur) / vvDur) return { ratio, globalSec: Math.max(0, globalSec), vvDur } } const hardStop = (v: HTMLVideoElement | null) => { if (!v) return try { v.pause() } catch {} try { v.removeAttribute('src') // @ts-ignore v.src = '' v.load() } catch {} } useEffect(() => { setTeaserReady(false) setTeaserOk(true) setFetchedMeta(null) // ✅ neue Datei/Asset -> alten fallback-meta cache in state verwerfen // ✅ neue Datei/Asset -> Callback-Dedupe zurücksetzen emittedDurationRef.current = '' emittedResolutionRef.current = '' setMetaLoaded(false) }, [previewId, assetNonce, noGenerateTeaser]) useEffect(() => { const onRelease = (ev: any) => { const f = String(ev?.detail?.file ?? '') if (!f || f !== file) return hardStop(inlineRef.current) hardStop(teaserMp4Ref.current) hardStop(clipsRef.current) } window.addEventListener('player:release', onRelease as EventListener) window.addEventListener('player:close', onRelease as EventListener) return () => { window.removeEventListener('player:release', onRelease as EventListener) window.removeEventListener('player:close', onRelease as EventListener) } }, [file]) // --- IntersectionObserver: echtes inView (für Playback) useEffect(() => { const el = rootRef.current if (!el) return const obs = new IntersectionObserver( (entries) => { const hit = Boolean(entries[0]?.isIntersecting) setInView(hit) if (hit) setEverInView(true) }, { threshold: 0.01, rootMargin: '0px', } ) obs.observe(el) return () => obs.disconnect() }, []) // --- IntersectionObserver: nearView (für Teaser-Preload / Vorwärmen) useEffect(() => { const el = rootRef.current if (!el) return // Falls Preload deaktiviert ist: nearView folgt inView if (!teaserPreloadEnabled) { setNearView(inView) return } let armed = true const obs = new IntersectionObserver( (entries) => { const hit = Boolean(entries[0]?.isIntersecting) if (hit) { setNearView(true) // einmal "armed" reicht (wir wollen nicht wieder entladen) if (armed) { armed = false obs.disconnect() } } }, { threshold: 0, rootMargin: teaserPreloadRootMargin, } ) obs.observe(el) return () => obs.disconnect() }, [teaserPreloadEnabled, teaserPreloadRootMargin, inView]) // --- Tick für "frames" useEffect(() => { if (!animated) return if (animatedMode !== 'frames') return if (!inView || document.hidden) return const id = window.setInterval(() => setLocalTick((t) => t + 1), autoTickMs) return () => window.clearInterval(id) }, [animated, animatedMode, inView, autoTickMs]) // --- Thumbnail time (nur frames!) const thumbTimeSec = useMemo(() => { if (!animated) return null if (animatedMode !== 'frames') return null if (!hasDuration) return null const dur = effectiveDurationSec! const step = Math.max(0.25, thumbStepSec ?? 3) if (thumbSpread) { const count = Math.max(4, Math.min(thumbSamples ?? 16, Math.floor(dur))) const idx = localTick % count const span = Math.max(0.1, dur - step) const base = Math.min(0.25, span * 0.02) const t = (idx / count) * span + base return Math.min(dur - 0.05, Math.max(0.05, t)) } const total = Math.max(dur - 0.1, step) const t = (localTick * step) % total return Math.min(dur - 0.05, Math.max(0.05, t)) }, [animated, animatedMode, hasDuration, effectiveDurationSec, localTick, thumbStepSec, thumbSpread, thumbSamples]) const v = assetNonce ?? 0 const thumbSrc = useMemo(() => { if (!previewId) return '' if (thumbTimeSec == null) return `/api/preview?id=${encodeURIComponent(previewId)}&v=${v}` return `/api/preview?id=${encodeURIComponent(previewId)}&t=${thumbTimeSec}&v=${v}` }, [previewId, thumbTimeSec, v]) const teaserSrc = useMemo(() => { if (!previewId) return '' const noGen = noGenerateTeaser ? '&noGenerate=1' : '' return `/api/generated/teaser?id=${encodeURIComponent(previewId)}${noGen}&v=${v}` }, [previewId, v, noGenerateTeaser]) // --- Inline Video sichtbar? const showingInlineVideo = inlineMode !== 'never' && inView && videoOk && (inlineMode === 'always' || (inlineMode === 'hover' && hovered)) // --- Teaser/Clips aktiv? (inView, nicht inline, optional nur hover) const teaserActive = animated && inView && !document.hidden && videoOk && !showingInlineVideo && (animatedTrigger === 'always' || hovered) && ((animatedMode === 'teaser' && teaserOk && Boolean(teaserSrc)) || (animatedMode === 'clips' && hasDuration)) const progressTotalSeconds = hasDuration && typeof effectiveDurationSec === 'number' ? effectiveDurationSec : undefined // ✅ Still-Bild: optional immer laden (entkoppelt vom inView-Gating) const shouldLoadStill = alwaysLoadStill || inView || everInView || (wantsHover && hovered) // ✅ Teaser/Clips "vorwärmen": schon in nearView erlauben const shouldPreloadAnimatedAssets = nearView || inView || everInView || (wantsHover && hovered) // ✅ Wenn meta.json schon alles hat: sofort Callbacks auslösen (kein hidden