"use client"; import { transformDirectorMotion } from "@/lib/video-canvas/director-extensions"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { presetDirectorMotion, sampleDirectorMotion, readDirectorMotion, resizeDirectorMotion, validDirectorPose, type DirectorMotion, type DirectorPose, } from "@/lib/video-canvas/director-motion"; export function DirectorMotionPanel({ motion, hasObjectMotion = false, externalSeek, compact = false, pose, disabled, onChange, onPose, onPreview, onExport, onSend, exportProgress, onCancel, onOverview, }: { compact?: boolean; hasObjectMotion?: boolean; externalSeek?: { time: number; editing?: boolean }; motion: DirectorMotion; pose: DirectorPose; disabled: boolean; onChange: (motion: DirectorMotion) => void; onPose: (pose: DirectorPose) => void; onPreview: (time: number | null) => void; onExport: () => void; onSend: () => void; exportProgress: number | null; onCancel: () => void; onOverview: () => void; }) { const [autoKeyframe, setAutoKeyframe] = useState(false); const previousPose = useRef(JSON.stringify(pose)); const [time, setTime] = useState(0), [playing, setPlaying] = useState(false), [preview, setPreview] = useState(false); const timeRef = useRef(time), latest = useRef({ motion, onPreview }); latest.current = { motion, onPreview }; timeRef.current = time; const input = useRef(null); const button = "rounded border border-white/15 px-2 py-1.5 text-xs hover:bg-white/10 disabled:opacity-40"; const seek = (t: number) => { const next = Math.max(0, Math.min(motion.duration, t)); setTime(next); timeRef.current = next; setPreview(true); onPreview(next); }; const edit = () => { if (preview) { const sampled = sampleDirectorMotion(motion, timeRef.current); if (sampled) onPose(sampled); } setPlaying(false); setPreview(false); onPreview(null); }; useEffect(() => { if (!playing) return; let frame = 0, previous = 0; const tick = (now: number) => { const m = latest.current.motion; const next = timeRef.current + (previous ? (now - previous) / 1000 : 0); previous = now; const t = next >= m.duration ? (m.loop ? next % m.duration : m.duration) : next; timeRef.current = t; setTime(t); latest.current.onPreview(t); if (next >= m.duration && !m.loop) { setPlaying(false); return; } frame = requestAnimationFrame(tick); }; const hide = () => { if (document.hidden) { setPlaying(false); toast.info("页面已切换,运镜预览已暂停"); } }; frame = requestAnimationFrame(tick); document.addEventListener("visibilitychange", hide); return () => { cancelAnimationFrame(frame); document.removeEventListener("visibilitychange", hide); }; }, [playing]); useEffect(() => { if (disabled) setPlaying(false); }, [disabled]); useEffect(() => { if (timeRef.current > motion.duration) { setTime(motion.duration); onPreview(preview ? motion.duration : null); } }, [motion.duration, onPreview, preview]); const add = () => { if (!validDirectorPose(pose)) { toast.error("相机不能与注视点重合"); return; } const existing = motion.keyframes.find((k) => Math.abs(k.time - time) < 0.01); if (!existing && motion.keyframes.length >= 100) { toast.error("最多支持 100 个关键帧"); return; } const key = { id: existing?.id || crypto.randomUUID(), time: existing?.time ?? Number(time.toFixed(2)), position: [...pose.position] as DirectorPose["position"], target: [...pose.target] as DirectorPose["target"], fov: pose.fov, }; onChange({ ...motion, keyframes: [...motion.keyframes.filter((k) => k.id !== key.id), key].sort( (a, b) => a.time - b.time, ), }); }; useEffect(() => { const signature = JSON.stringify(pose), changed = signature !== previousPose.current; previousPose.current = signature; if (autoKeyframe && changed && !preview && !disabled) add(); }, [pose, autoKeyframe, preview, disabled]); useEffect(() => { if (!externalSeek) return; setPlaying(false); setTime(externalSeek.time); timeRef.current = externalSeek.time; setPreview(!externalSeek.editing); }, [externalSeek]); const downloadJson = () => { const url = URL.createObjectURL( new Blob([JSON.stringify({ version: 1, motion }, null, 2)], { type: "application/json", }), ); const a = document.createElement("a"); a.href = url; a.download = "director-motion.json"; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }; return (
镜头轨迹
{(["push", "pull", "orbit", "pan"] as const).map((kind, i) => ( ))}

预设会替换当前轨迹;黄色线条为相机路径。编辑机位后,在指定时间记录关键帧。

{preview ? (

正在预览轨迹;点击“编辑机位”后可调整并记录机位。

) : null}
{motion.keyframes.map((k, i) => (
{ const n = Number(e.target.value); if (n < 0 || n > motion.duration || !Number.isFinite(n)) return; if ( motion.keyframes.some( (other) => other.id !== k.id && Math.abs(other.time - n) < 0.01, ) ) { toast.error("关键帧时间不能重复"); return; } onChange({ ...motion, keyframes: motion.keyframes .map((other) => (other.id === k.id ? { ...k, time: n } : other)) .sort((a, b) => a.time - b.time), }); }} />
))}

真实导出严格使用本场景轨迹,不含音频。AI 生视频使用首帧与运镜描述作参考,不保证精确复现轨迹。

{exportProgress !== null ? (
正在导出 {exportProgress.toFixed(1)} / {motion.duration} 秒{" "}
) : null} { const file = e.target.files?.[0]; e.target.value = ""; if (!file) return; try { if (file.size > 256 * 1024) throw Error("轨迹文件不能超过 256 KiB"); const data = JSON.parse(await file.text()); if (data.version !== 1) throw Error("不支持的轨迹文件版本"); const m = readDirectorMotion(data.motion); if (m.keyframes.length < 2 || m.keyframes.length !== data.motion?.keyframes?.length) throw Error("轨迹数据无效或包含重复时间"); edit(); onChange(m); setTime(0); toast.success("轨迹已导入"); } catch (e) { toast.error(e instanceof Error ? e.message : "轨迹导入失败"); } }} />
); }