471 lines
15 KiB
TypeScript
471 lines
15 KiB
TypeScript
"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<HTMLInputElement>(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 (
|
||
<section className="space-y-3 border-t border-white/15 pt-3" aria-label="镜头轨迹">
|
||
<div className="text-sm font-semibold">镜头轨迹</div>
|
||
<fieldset
|
||
disabled={disabled}
|
||
className={
|
||
compact
|
||
? "grid grid-cols-1 gap-3 sm:grid-cols-3 lg:grid-cols-4 disabled:opacity-50"
|
||
: "space-y-3 disabled:opacity-50"
|
||
}
|
||
>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<label className="text-xs">
|
||
时长(秒)
|
||
<input
|
||
aria-label="运镜时长"
|
||
type="number"
|
||
min={2}
|
||
max={15}
|
||
step={1}
|
||
value={motion.duration}
|
||
className="mt-1 w-full rounded bg-white/10 p-1"
|
||
onChange={(e) => {
|
||
const n = Number(e.target.value);
|
||
if (n >= 2 && n <= 15) {
|
||
edit();
|
||
onChange(resizeDirectorMotion(motion, n));
|
||
}
|
||
}}
|
||
/>
|
||
</label>
|
||
<label className="text-xs">
|
||
速度
|
||
<select
|
||
aria-label="运镜插值"
|
||
value={motion.easing}
|
||
className="mt-1 w-full rounded bg-[#303030] p-1"
|
||
onChange={(e) =>
|
||
onChange({
|
||
...motion,
|
||
easing: e.target.value as DirectorMotion["easing"],
|
||
})
|
||
}
|
||
>
|
||
<option value="smooth">缓入缓出</option>
|
||
<option value="linear">匀速</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div className="flex flex-wrap gap-1">
|
||
{(["push", "pull", "orbit", "pan"] as const).map((kind, i) => (
|
||
<button
|
||
type="button"
|
||
className={button}
|
||
key={kind}
|
||
onClick={() => {
|
||
const base =
|
||
(preview ? sampleDirectorMotion(motion, timeRef.current) : pose) || pose;
|
||
if (!validDirectorPose(base)) {
|
||
toast.error("相机不能与注视点重合");
|
||
return;
|
||
}
|
||
edit();
|
||
setTime(0);
|
||
onChange(presetDirectorMotion(base, kind, motion.duration));
|
||
}}
|
||
>
|
||
{["推进轨迹", "拉远轨迹", "环绕轨迹", "横移轨迹"][i]}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={button}
|
||
disabled={motion.keyframes.length < 2 && !hasObjectMotion}
|
||
onClick={() => {
|
||
edit();
|
||
onOverview();
|
||
}}
|
||
>
|
||
查看完整轨迹
|
||
</button>
|
||
<p className="text-xs text-white/50">
|
||
预设会替换当前轨迹;黄色线条为相机路径。编辑机位后,在指定时间记录关键帧。
|
||
</p>
|
||
<label className="block text-xs">
|
||
时间 {time.toFixed(2)} / {motion.duration.toFixed(2)} 秒
|
||
<input
|
||
aria-label="运镜时间轴"
|
||
className="w-full"
|
||
type="range"
|
||
min={0}
|
||
max={motion.duration}
|
||
step={0.01}
|
||
value={time}
|
||
onChange={(e) => {
|
||
setPlaying(false);
|
||
seek(Number(e.target.value));
|
||
}}
|
||
/>
|
||
</label>
|
||
<div className="flex flex-wrap gap-1">
|
||
<button
|
||
type="button"
|
||
className={button}
|
||
disabled={motion.keyframes.length < 2 && !hasObjectMotion}
|
||
onClick={() => {
|
||
if (playing) {
|
||
setPlaying(false);
|
||
return;
|
||
}
|
||
seek(time >= motion.duration ? 0 : time);
|
||
setPlaying(true);
|
||
}}
|
||
>
|
||
{playing ? "暂停运镜" : "播放运镜"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={button}
|
||
onClick={() => {
|
||
setPlaying(false);
|
||
seek(0);
|
||
}}
|
||
>
|
||
回到起点
|
||
</button>
|
||
<button type="button" className={button} onClick={edit}>
|
||
编辑机位
|
||
</button>
|
||
<label className="flex items-center gap-1 text-xs">
|
||
<input
|
||
type="checkbox"
|
||
aria-label="循环运镜"
|
||
checked={motion.loop}
|
||
onChange={(e) => onChange({ ...motion, loop: e.target.checked })}
|
||
/>
|
||
循环
|
||
</label>
|
||
</div>
|
||
<label className="flex items-center gap-2 text-xs">
|
||
<input
|
||
type="checkbox"
|
||
aria-label="自动记录关键帧"
|
||
checked={autoKeyframe}
|
||
onChange={(e) => setAutoKeyframe(e.target.checked)}
|
||
/>
|
||
编辑机位时自动记录当前帧
|
||
</label>
|
||
<div className="flex flex-wrap gap-1">
|
||
<button
|
||
type="button"
|
||
className={button}
|
||
disabled={!motion.keyframes.length}
|
||
onClick={() => {
|
||
edit();
|
||
onChange(transformDirectorMotion(motion, [1, 0, 0], 0));
|
||
}}
|
||
>
|
||
整条轨迹右移 1 米
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={button}
|
||
disabled={!motion.keyframes.length}
|
||
onClick={() => {
|
||
edit();
|
||
onChange(transformDirectorMotion(motion, [0, 0, 0], 90));
|
||
}}
|
||
>
|
||
轨迹与朝向旋转 90°
|
||
</button>
|
||
</div>
|
||
<button type="button" className={button} disabled={preview} onClick={add}>
|
||
记录/更新当前机位
|
||
</button>
|
||
{preview ? (
|
||
<p className="text-xs text-amber-200">正在预览轨迹;点击“编辑机位”后可调整并记录机位。</p>
|
||
) : null}
|
||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||
{motion.keyframes.map((k, i) => (
|
||
<div key={k.id} className="flex items-center gap-1 rounded bg-white/5 p-1">
|
||
<button
|
||
type="button"
|
||
className={button}
|
||
aria-label={`编辑关键帧 ${i + 1}`}
|
||
onClick={() => {
|
||
edit();
|
||
setTime(k.time);
|
||
onPose(k);
|
||
}}
|
||
>
|
||
帧 {i + 1}
|
||
</button>
|
||
<input
|
||
aria-label={`关键帧 ${i + 1} 时间`}
|
||
type="number"
|
||
step={0.1}
|
||
min={0}
|
||
max={motion.duration}
|
||
value={Number(k.time.toFixed(3))}
|
||
className="min-w-0 flex-1 rounded bg-white/10 p-1 text-xs"
|
||
onChange={(e) => {
|
||
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),
|
||
});
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className={button}
|
||
aria-label={`删除关键帧 ${i + 1}`}
|
||
onClick={() => {
|
||
edit();
|
||
onChange({
|
||
...motion,
|
||
keyframes: motion.keyframes.filter((other) => other.id !== k.id),
|
||
});
|
||
}}
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="flex flex-wrap gap-1">
|
||
<button type="button" className={button} onClick={downloadJson}>
|
||
保存轨迹文件
|
||
</button>
|
||
<button type="button" className={button} onClick={() => input.current?.click()}>
|
||
导入轨迹文件
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={`${button} w-full`}
|
||
disabled={motion.keyframes.length < 2 && !hasObjectMotion}
|
||
onClick={() => {
|
||
edit();
|
||
onExport();
|
||
}}
|
||
>
|
||
导出真实运镜视频
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`${button} w-full`}
|
||
disabled={motion.keyframes.length < 2}
|
||
onClick={() => {
|
||
edit();
|
||
onSend();
|
||
}}
|
||
>
|
||
发送首帧与运镜到视频节点
|
||
</button>
|
||
<p className="text-xs leading-5 text-white/50">
|
||
真实导出严格使用本场景轨迹,不含音频。AI
|
||
生视频使用首帧与运镜描述作参考,不保证精确复现轨迹。
|
||
</p>
|
||
</fieldset>
|
||
{exportProgress !== null ? (
|
||
<div role="status" className="text-xs">
|
||
正在导出 {exportProgress.toFixed(1)} / {motion.duration} 秒{" "}
|
||
<button className={button} onClick={onCancel}>
|
||
取消导出
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
<input
|
||
ref={input}
|
||
type="file"
|
||
accept=".json,application/json"
|
||
className="hidden"
|
||
onChange={async (e) => {
|
||
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 : "轨迹导入失败");
|
||
}
|
||
}}
|
||
/>
|
||
</section>
|
||
);
|
||
}
|