"use client"; import { useEffect, useRef, useState } from "react"; import { captionFont } from "@/lib/video-canvas/smart-edit/fonts"; import { loadCaptionFont } from "./CaptionFonts"; import { usePreviewGain } from "./usePreviewGain"; import { editableCaptions, defaultCaptionStyle, type CaptionStyle, planTimeline, SECOND, type EditPlan, defaultVideoTransform, type VideoTransform, type TimelineCaption, } from "@/lib/video-canvas/smart-edit/contract"; export function EditPreview({ plan, time, playing, onTime, onPlaying, onError, fit = false, cover = false, externalClock = false, selectedClipId, selectedCaptionId, onSelectClip, onSelectCaption, onTransform, onCaptionPosition, }: { fit?: boolean; cover?: boolean; externalClock?: boolean; selectedClipId?: string; selectedCaptionId?: string; onSelectClip?: (id: string) => void; onSelectCaption?: (id: string) => void; onTransform?: (id: string, value: VideoTransform) => void; onCaptionPosition?: (id: string, value: { x: number; y: number }) => void; plan: EditPlan; time: number; playing: boolean; onTime: (t: number) => void; onPlaying: (v: boolean) => void; onError: (s: string) => void; }) { const videos = useRef(new Map()), music = useRef(null), audios = useRef(new Map()), latest = useRef({ time, plan, onTime, onPlaying }); latest.current = { time, plan, onTime, onPlaying }; const fontIds = [ ...new Set( editableCaptions(plan) .map((c) => c.style?.fontFamily) .filter(Boolean), ), ].join(","); useEffect(() => { let canceled = false; void Promise.all( [ ...new Set([...fontIds.split(",").filter(Boolean), ...(fontIds ? ["source-han"] : [])]), ].map(loadCaptionFont), ).catch(() => { if (!canceled) onError("字幕字体加载失败,请检查网络后重试"); }); return () => { canceled = true; }; }, [fontIds]); const audioGain = usePreviewGain(plan, onError); const frameRef = useRef(null); const [frameSize, setFrameSize] = useState({ width: 0, height: 0 }); useEffect(() => { const el = frameRef.current; if (!el) return; const observer = new ResizeObserver(([entry]) => setFrameSize({ width: entry.contentRect.width, height: entry.contentRect.height }), ); observer.observe(el); return () => observer.disconnect(); }, []); const timeline = planTimeline(plan), visibleTime = Math.min(time * SECOND, timeline.duration - 1), current = timeline.clips.filter( (c) => visibleTime >= c.renderStart && visibleTime < c.renderEnd, ), subs = plan.subtitles ? editableCaptions(plan).filter((s) => time * SECOND >= s.start && time * SECOND < s.end) : []; useEffect(() => { if (!playing || externalClock) return; let frame = 0, last = 0; const tick = (now: number) => { const state = latest.current; if (last) { const next = Math.min( planTimeline(state.plan).duration / SECOND, state.time + (now - last) / 1000, ); state.onTime(next); if (next >= planTimeline(state.plan).duration / SECOND) { state.onPlaying(false); return; } } last = now; frame = requestAnimationFrame(tick); }; const hide = () => { if (document.hidden) latest.current.onPlaying(false); }; frame = requestAnimationFrame(tick); document.addEventListener("visibilitychange", hide); return () => { cancelAnimationFrame(frame); document.removeEventListener("visibilitychange", hide); }; }, [playing, externalClock]); useEffect(() => { audioGain.retain([...videos.current.values(), ...audios.current.values()]); for (const clip of timeline.clips) { const video = videos.current.get(clip.id); if (!video) continue; const active = current.some((c) => c.id === clip.id); if (!active) { video.pause(); continue; } const target = Math.max( clip.sourceIn / SECOND, Math.min( clip.sourceOut / SECOND - 1 / 30, clip.sourceIn / SECOND + (time - clip.start / SECOND) * clip.speed, ), ); const held = time * SECOND < clip.start || time * SECOND >= clip.end; if (Number.isFinite(target) && Math.abs(video.currentTime - target) > 0.1) video.currentTime = target; video.playbackRate = clip.speed; const index = timeline.clips.findIndex((c) => c.id === clip.id), next = timeline.clips[index + 1]; let gain = 1; if (clip.overlap && time * SECOND < clip.transitionStart + clip.overlap) gain = (time * SECOND - clip.transitionStart) / clip.overlap; if (next?.overlap && time * SECOND > next.transitionStart) gain = Math.min( gain, (next.transitionStart + next.overlap - time * SECOND) / next.overlap, ); const source = plan.assets.find((a) => a.id === clip.assetId)!.url; if (!audioGain.ready(source)) { video.pause(); if (playing) { audioGain.retry(); onPlaying(false); onError("正在准备音量预览素材,请稍后点击播放"); } continue; } audioGain.volume( video, clip.audioDetached || clip.muted || held ? 0 : clip.gain * gain, playing, ); if (playing && !held) void video.play().catch((error) => { if (error?.name === "AbortError") return; onPlaying(false); onError("预览播放失败,请检查素材链接或点击播放重试"); }); else video.pause(); } for (const clip of timeline.audioClips) { const audio = audios.current.get(clip.id); if (!audio) continue; const relative = time - clip.start / SECOND; if (relative < 0 || time * SECOND >= clip.end) { audio.pause(); continue; } const target = clip.sourceIn / SECOND + relative * clip.speed; if (Math.abs(audio.currentTime - target) > 0.1) audio.currentTime = target; audio.playbackRate = clip.speed; const length = clip.duration / SECOND, fadeIn = Math.min(clip.fadeIn, length), fadeOut = Math.min(clip.fadeOut, length); const gain = Math.min( 1, fadeIn ? (clip.fadeInStart || 0) + ((1 - (clip.fadeInStart || 0)) * relative) / fadeIn : 1, ) * Math.min( 1, fadeOut ? (clip.fadeOutEnd || 0) + ((1 - (clip.fadeOutEnd || 0)) * (length - relative)) / fadeOut : 1, ); const source = plan.assets.find((a) => a.id === clip.assetId)!.url; if (!audioGain.ready(source)) { audio.pause(); if (playing) { audioGain.retry(); onPlaying(false); onError("正在准备音量预览素材,请稍后点击播放"); } continue; } audioGain.volume(audio, clip.muted ? 0 : clip.gain * gain, playing); if (playing) void audio.play().catch((error) => { if (error?.name === "AbortError") return; onPlaying(false); onError("独立音轨无法播放"); }); else audio.pause(); } const audio = music.current; if (audio && plan.music) { const target = time + plan.music.start / SECOND; if (Number.isFinite(audio.duration) && target >= audio.duration) { audio.pause(); return; } if (Math.abs(audio.currentTime - target) > 0.15) audio.currentTime = target; audio.volume = Math.min(1, plan.music.gain); if (playing) void audio.play().catch((error) => { if (error?.name === "AbortError") return; onPlaying(false); onError("背景音乐无法播放"); }); else audio.pause(); } }, [time, playing, plan]); const [ratioWidth, ratioHeight] = plan.ratio.split(":").map(Number); const ratio = ratioWidth / ratioHeight; const height = Math.min(frameSize.height || 720, (frameSize.width || 1280) / ratio); const incomingClip = current.find( (c) => c.overlap && visibleTime < c.transitionStart + c.overlap, ); const progress = incomingClip ? (visibleTime - incomingClip.transitionStart) / incomingClip.overlap : 1; const [gesture, setGesture] = useState<{ clipId?: string; captionId?: string; transform?: VideoTransform; x?: number; y?: number; }>(); const gestureCleanup = useRef<() => void>(); useEffect(() => () => gestureCleanup.current?.(), []); const moveTransform = (e: React.PointerEvent, id: string, corner?: [number, number]) => { e.preventDefault(); e.stopPropagation(); onPlaying(false); onSelectClip?.(id); const c = plan.clips.find((c) => c.id === id)!, a = plan.assets.find((a) => a.id === c.assetId)!, original = c.transform || defaultVideoTransform; const frameWidth = height * ratio, baseWidth = Math.min(frameWidth, (height * a.width) / a.height), baseHeight = (baseWidth * a.height) / a.width, startX = e.clientX, startY = e.clientY; let value = original; const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); const move = (ev: PointerEvent) => { const dx = ev.clientX - startX, dy = ev.clientY - startY; if (corner) { const nextScale = clamp( original.scale + ((dx * corner[0]) / baseWidth + (dy * corner[1]) / baseHeight) / 2, 0.1, 3, ), delta = nextScale - original.scale; value = { scale: nextScale, x: clamp(original.x + (delta * baseWidth * corner[0]) / 2 / frameWidth, -1, 1), y: clamp(original.y + (delta * baseHeight * corner[1]) / 2 / height, -1, 1), }; } else value = { ...original, x: clamp(original.x + dx / frameWidth, -1, 1), y: clamp(original.y + dy / height, -1, 1), }; setGesture({ clipId: id, transform: value }); }; const cleanup = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", end); window.removeEventListener("pointercancel", cancel); gestureCleanup.current = undefined; }; const end = () => { cleanup(); setGesture(undefined); onTransform?.(id, value); }, cancel = () => { cleanup(); setGesture(undefined); }; gestureCleanup.current?.(); gestureCleanup.current = cleanup; window.addEventListener("pointermove", move); window.addEventListener("pointerup", end); window.addEventListener("pointercancel", cancel); }; const moveCaption = (e: React.PointerEvent, id: string, style: CaptionStyle) => { e.preventDefault(); e.stopPropagation(); onPlaying(false); onSelectCaption?.(id); const startX = e.clientX, startY = e.clientY; let value = { x: style.x ?? 0.5, y: style.y ?? 0.95 }; const move = (ev: PointerEvent) => { value = { x: Math.max( 0, Math.min(1, (style.x ?? 0.5) + (ev.clientX - startX) / (height * ratio)), ), y: Math.max(0, Math.min(1, (style.y ?? 0.95) + (ev.clientY - startY) / height)), }; setGesture({ captionId: id, ...value }); }; const cleanup = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", end); window.removeEventListener("pointercancel", cancel); gestureCleanup.current = undefined; }; const end = () => { cleanup(); setGesture(undefined); onCaptionPosition?.(id, value); }, cancel = () => { cleanup(); setGesture(undefined); }; gestureCleanup.current?.(); gestureCleanup.current = cleanup; window.addEventListener("pointermove", move); window.addEventListener("pointerup", end); window.addEventListener("pointercancel", cancel); }; const selectedClip = current.find((c) => c.id === selectedClipId); const selectedAsset = selectedClip && plan.assets.find((a) => a.id === selectedClip.assetId); const selectedTransform = gesture && gesture.clipId === selectedClipId ? gesture.transform : selectedClip?.transform || defaultVideoTransform; return (
{timeline.clips.map((clip) => { const asset = plan.assets.find((a) => a.id === clip.assetId)!; const active = current.some((c) => c.id === clip.id), incoming = clip.overlap && visibleTime < clip.transitionStart + clip.overlap; return (
); }