ai-chatbot/components/canvas/director/DirectorMotionPanel.tsx

471 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}