feat(video-canvas): implement camera motion timeline and real MP4 export
This commit is contained in:
parent
38be09de9c
commit
d5593ac3f0
@ -4,6 +4,7 @@ const DirectorStudioDialog = dynamic(
|
|||||||
() => import("@/components/canvas/director/DirectorStudioDialog").then(module => module.DirectorStudioDialog),
|
() => import("@/components/canvas/director/DirectorStudioDialog").then(module => module.DirectorStudioDialog),
|
||||||
{ ssr: false },
|
{ ssr: false },
|
||||||
);
|
);
|
||||||
|
import { directorMotionPrompt, resizeDirectorMotion } from "@/lib/video-canvas/director-motion";
|
||||||
import type { DirectorSnapshot } from "@/lib/video-canvas/director-scene";
|
import type { DirectorSnapshot } from "@/lib/video-canvas/director-scene";
|
||||||
import { filterCanvasModels, getCanvasVideoModes, getCanvasVideoParameters, type CanvasModelKind } from "@/lib/video-canvas/model-matching";
|
import { filterCanvasModels, getCanvasVideoModes, getCanvasVideoParameters, type CanvasModelKind } from "@/lib/video-canvas/model-matching";
|
||||||
|
|
||||||
@ -11498,6 +11499,42 @@ export function LiblibVideoCanvasReplica({
|
|||||||
initialScene={directorConsoleNode.draftValues?.directorScene}
|
initialScene={directorConsoleNode.draftValues?.directorScene}
|
||||||
onChange={scene => setNodes(current => current.map(node => node.id === directorConsoleNode.id ? {...node, draftValues: {...node.draftValues, directorScene: scene}} : node))}
|
onChange={scene => setNodes(current => current.map(node => node.id === directorConsoleNode.id ? {...node, draftValues: {...node.draftValues, directorScene: scene}} : node))}
|
||||||
onSend={snapshots => sendDirectorSnapshotsToCanvas(directorConsoleNode, snapshots)}
|
onSend={snapshots => sendDirectorSnapshotsToCanvas(directorConsoleNode, snapshots)}
|
||||||
|
onSendMotion={(snapshot, motion) => {
|
||||||
|
const source = createMediaResourceNode({
|
||||||
|
mediaType: "image", mediaUrl: snapshot.url, title: "运镜首帧",
|
||||||
|
width: 350, height: Math.round(350 * snapshot.height / snapshot.width),
|
||||||
|
x: directorConsoleNode.x + 500, y: directorConsoleNode.y,
|
||||||
|
});
|
||||||
|
const video = createTargetModuleNode("video", source.x + 500, source.y, nodes.length + 2);
|
||||||
|
const duration = Math.round(motion.duration);
|
||||||
|
const prompt = directorMotionPrompt(resizeDirectorMotion(motion, duration));
|
||||||
|
video.title = "导演台运镜视频";
|
||||||
|
video.draftValues = {
|
||||||
|
...video.draftValues, directorMotion: motion,
|
||||||
|
sourceDirectorNodeId: directorConsoleNode.id, prompt,
|
||||||
|
params: {
|
||||||
|
mode: "first-frame", prompt, duration, ratio: "adaptive",
|
||||||
|
settings: { model: "auto", duration: `${duration}s`, resolution: "720P", ratio: "自适应", audio: "关闭" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
addNodes([source, video], [
|
||||||
|
{ id: makeId("edge"), source: directorConsoleNode.id, target: source.id },
|
||||||
|
{ id: makeId("edge"), source: source.id, target: video.id },
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
onSendVideo={video => {
|
||||||
|
const result = createMediaResourceNode({
|
||||||
|
mediaType: "video", mediaUrl: video.url, title: "导演台真实运镜",
|
||||||
|
width: 420, height: Math.round(420 * video.height / video.width),
|
||||||
|
x: directorConsoleNode.x + 500, y: directorConsoleNode.y,
|
||||||
|
});
|
||||||
|
result.draftValues = {
|
||||||
|
...result.draftValues, generated: true, durationSeconds: video.duration,
|
||||||
|
outputSizeLabel: `${video.width} × ${video.height}`,
|
||||||
|
sourceDirectorNodeId: directorConsoleNode.id,
|
||||||
|
};
|
||||||
|
addNodes([result], [{ id: makeId("edge"), source: directorConsoleNode.id, target: result.id }]);
|
||||||
|
}}
|
||||||
onClose={() => setDirectorConsoleNodeId(null)}
|
onClose={() => setDirectorConsoleNodeId(null)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
439
components/canvas/director/DirectorMotionPanel.tsx
Normal file
439
components/canvas/director/DirectorMotionPanel.tsx
Normal file
@ -0,0 +1,439 @@
|
|||||||
|
"use client";
|
||||||
|
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,
|
||||||
|
pose,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
onPose,
|
||||||
|
onPreview,
|
||||||
|
onExport,
|
||||||
|
onSend,
|
||||||
|
exportProgress,
|
||||||
|
onCancel,
|
||||||
|
onOverview,
|
||||||
|
}: {
|
||||||
|
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 [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,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
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="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}
|
||||||
|
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}
|
||||||
|
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>
|
||||||
|
<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}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Camera, Plus, Upload, X, Trash2 } from "lucide-react";
|
import { Camera, Plus, Upload, X, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@ -9,15 +9,25 @@ import {
|
|||||||
import {
|
import {
|
||||||
readDirectorScene,
|
readDirectorScene,
|
||||||
directorRatios,
|
directorRatios,
|
||||||
|
directorImageSize,
|
||||||
type DirectorScene,
|
type DirectorScene,
|
||||||
type DirectorSnapshot,
|
type DirectorSnapshot,
|
||||||
type DirectorObject,
|
type DirectorObject,
|
||||||
type DirectorVector,
|
type DirectorVector,
|
||||||
} from "@/lib/video-canvas/director-scene";
|
} from "@/lib/video-canvas/director-scene";
|
||||||
|
import { DirectorMotionPanel } from "./DirectorMotionPanel";
|
||||||
|
import {
|
||||||
|
sampleDirectorMotion,
|
||||||
|
mergeDirectorCameraPose,
|
||||||
|
type DirectorMotion,
|
||||||
|
} from "@/lib/video-canvas/director-motion";
|
||||||
async function upload(file: File) {
|
async function upload(file: File) {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file);
|
form.append("file", file);
|
||||||
form.append("purpose", "director-studio");
|
form.append(
|
||||||
|
"purpose",
|
||||||
|
file.type.startsWith("video/") ? "canvas-video-process" : "director-studio",
|
||||||
|
);
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
/\.glb$/i.test(file.name)
|
/\.glb$/i.test(file.name)
|
||||||
? "/api/canvas/director/upload-model"
|
? "/api/canvas/director/upload-model"
|
||||||
@ -38,11 +48,20 @@ export function DirectorStudioDialog({
|
|||||||
onChange,
|
onChange,
|
||||||
onClose,
|
onClose,
|
||||||
onSend,
|
onSend,
|
||||||
|
onSendMotion,
|
||||||
|
onSendVideo,
|
||||||
}: {
|
}: {
|
||||||
initialScene: unknown;
|
initialScene: unknown;
|
||||||
onChange: (scene: DirectorScene) => void;
|
onChange: (scene: DirectorScene) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSend: (snapshots: DirectorSnapshot[]) => void;
|
onSend: (snapshots: DirectorSnapshot[]) => void;
|
||||||
|
onSendMotion: (snapshot: DirectorSnapshot, motion: DirectorMotion) => void;
|
||||||
|
onSendVideo: (video: {
|
||||||
|
url: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
duration: number;
|
||||||
|
}) => void;
|
||||||
}) {
|
}) {
|
||||||
const [scene, setScene] = useState(() => readDirectorScene(initialScene));
|
const [scene, setScene] = useState(() => readDirectorScene(initialScene));
|
||||||
const current = useRef(scene);
|
const current = useRef(scene);
|
||||||
@ -59,6 +78,85 @@ export function DirectorStudioDialog({
|
|||||||
const [cameraView, setCameraView] = useState(false);
|
const [cameraView, setCameraView] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const viewport = useRef<DirectorViewportHandle>(null);
|
const viewport = useRef<DirectorViewportHandle>(null);
|
||||||
|
const [previewTime, setPreviewTime] = useState<number | null>(null);
|
||||||
|
const [exportProgress, setExportProgress] = useState<number | null>(null);
|
||||||
|
const recording = useRef<AbortController | null>(null);
|
||||||
|
useEffect(() => () => recording.current?.abort(), []);
|
||||||
|
const [exportedVideo, setExportedVideo] = useState<{
|
||||||
|
blob: Blob;
|
||||||
|
url: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
duration: number;
|
||||||
|
} | null>(null);
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (exportedVideo) URL.revokeObjectURL(exportedVideo.url);
|
||||||
|
},
|
||||||
|
[exportedVideo],
|
||||||
|
);
|
||||||
|
const exportMotion = async () => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setExportProgress(0);
|
||||||
|
const controller = new AbortController();
|
||||||
|
recording.current = controller;
|
||||||
|
try {
|
||||||
|
const motion = current.current.motion;
|
||||||
|
const dimensions = directorImageSize(current.current.ratio);
|
||||||
|
const blob = await viewport.current!.record(
|
||||||
|
controller.signal,
|
||||||
|
setExportProgress,
|
||||||
|
);
|
||||||
|
setExportedVideo({
|
||||||
|
blob,
|
||||||
|
url: URL.createObjectURL(blob),
|
||||||
|
...dimensions,
|
||||||
|
duration: motion.duration,
|
||||||
|
});
|
||||||
|
toast.success("真实运镜视频已导出,可下载或发送到画布");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "轨迹导出失败");
|
||||||
|
} finally {
|
||||||
|
recording.current = null;
|
||||||
|
setBusy(false);
|
||||||
|
setExportProgress(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const sendMotion = async () => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const motion = current.current.motion;
|
||||||
|
const pose = sampleDirectorMotion(motion, 0);
|
||||||
|
if (!pose || motion.keyframes.length < 2)
|
||||||
|
throw Error("至少需要两个关键帧");
|
||||||
|
const image = await viewport.current!.capture(pose);
|
||||||
|
const url = await upload(
|
||||||
|
new File([image.blob], `director-motion-${Date.now()}.png`, {
|
||||||
|
type: "image/png",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const snapshot = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title: "运镜首帧",
|
||||||
|
url,
|
||||||
|
width: image.width,
|
||||||
|
height: image.height,
|
||||||
|
cameraId: current.current.activeCameraId,
|
||||||
|
};
|
||||||
|
commit({
|
||||||
|
...current.current,
|
||||||
|
snapshots: [...current.current.snapshots, snapshot],
|
||||||
|
});
|
||||||
|
onSendMotion(snapshot, motion);
|
||||||
|
toast.success("首帧和运镜描述已发送,请在视频节点选择模型");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "发送失败");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
const fileInput = useRef<HTMLInputElement>(null);
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
const camera =
|
const camera =
|
||||||
scene.cameras.find((c) => c.id === scene.activeCameraId) ||
|
scene.cameras.find((c) => c.id === scene.activeCameraId) ||
|
||||||
@ -75,7 +173,9 @@ export function DirectorStudioDialog({
|
|||||||
commit({
|
commit({
|
||||||
...current.current,
|
...current.current,
|
||||||
cameras: current.current.cameras.map((c) =>
|
cameras: current.current.cameras.map((c) =>
|
||||||
c.id === current.current.activeCameraId ? { ...c, ...patch } : c,
|
c.id === current.current.activeCameraId
|
||||||
|
? mergeDirectorCameraPose(c, patch)
|
||||||
|
: c,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
const addObject = (
|
const addObject = (
|
||||||
@ -150,6 +250,7 @@ export function DirectorStudioDialog({
|
|||||||
<button
|
<button
|
||||||
className={button}
|
className={button}
|
||||||
aria-pressed={!cameraView}
|
aria-pressed={!cameraView}
|
||||||
|
disabled={busy}
|
||||||
onClick={() => setCameraView(false)}
|
onClick={() => setCameraView(false)}
|
||||||
>
|
>
|
||||||
导演视角
|
导演视角
|
||||||
@ -157,17 +258,28 @@ export function DirectorStudioDialog({
|
|||||||
<button
|
<button
|
||||||
className={button}
|
className={button}
|
||||||
aria-pressed={cameraView}
|
aria-pressed={cameraView}
|
||||||
|
disabled={busy}
|
||||||
onClick={() => setCameraView(true)}
|
onClick={() => setCameraView(true)}
|
||||||
>
|
>
|
||||||
机位视角
|
机位视角
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button aria-label="关闭导演台" className={button} onClick={onClose}>
|
<button
|
||||||
|
aria-label="关闭导演台"
|
||||||
|
className={button}
|
||||||
|
onClick={() => {
|
||||||
|
recording.current?.abort();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div className="grid min-h-0 flex-1 grid-cols-[210px_minmax(0,1fr)_260px]">
|
<div className="grid min-h-0 flex-1 grid-cols-[210px_minmax(0,1fr)_260px]">
|
||||||
<aside className="overflow-y-auto border-r border-white/10 p-4">
|
<aside
|
||||||
|
inert={busy}
|
||||||
|
className="overflow-y-auto border-r border-white/10 p-4"
|
||||||
|
>
|
||||||
<div className="mb-3 text-sm text-white/60">场景对象</div>
|
<div className="mb-3 text-sm text-white/60">场景对象</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{scene.objects.map((o) => (
|
{scene.objects.map((o) => (
|
||||||
@ -219,7 +331,10 @@ export function DirectorStudioDialog({
|
|||||||
左键旋转视角,右键平移,滚轮缩放。选择对象后使用变换轴调整。
|
左键旋转视角,右键平移,滚轮缩放。选择对象后使用变换轴调整。
|
||||||
</p>
|
</p>
|
||||||
</aside>
|
</aside>
|
||||||
<main className="relative flex min-h-0 items-center justify-center overflow-hidden bg-[#080808]">
|
<main
|
||||||
|
inert={busy}
|
||||||
|
className="relative flex min-h-0 items-center justify-center overflow-hidden bg-[#080808]"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="h-full max-h-full w-full"
|
className="h-full max-h-full w-full"
|
||||||
style={{ aspectRatio: scene.ratio.replace(":", " / ") }}
|
style={{ aspectRatio: scene.ratio.replace(":", " / ") }}
|
||||||
@ -230,6 +345,8 @@ export function DirectorStudioDialog({
|
|||||||
selected={selected}
|
selected={selected}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
cameraView={cameraView}
|
cameraView={cameraView}
|
||||||
|
previewTime={previewTime}
|
||||||
|
locked={busy}
|
||||||
onTransform={patchObject}
|
onTransform={patchObject}
|
||||||
onCamera={(position, target) => patchCamera({ position, target })}
|
onCamera={(position, target) => patchCamera({ position, target })}
|
||||||
/>
|
/>
|
||||||
@ -251,134 +368,226 @@ export function DirectorStudioDialog({
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<aside className="space-y-4 overflow-y-auto border-l border-white/10 p-4">
|
<aside className="space-y-4 overflow-y-auto border-l border-white/10 p-4">
|
||||||
<label className="block text-sm">
|
<fieldset
|
||||||
机位
|
disabled={busy || previewTime !== null}
|
||||||
<select
|
className="space-y-4"
|
||||||
aria-label="机位"
|
>
|
||||||
className={`${field} mt-2`}
|
<label className="block text-sm">
|
||||||
value={scene.activeCameraId}
|
机位
|
||||||
onChange={(e) =>
|
<select
|
||||||
commit({ ...current.current, activeCameraId: e.target.value })
|
aria-label="机位"
|
||||||
}
|
className={`${field} mt-2`}
|
||||||
>
|
value={scene.activeCameraId}
|
||||||
{scene.cameras.map((c) => (
|
onChange={(e) =>
|
||||||
<option key={c.id} value={c.id}>
|
commit({ ...current.current, activeCameraId: e.target.value })
|
||||||
{c.name}
|
}
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{[
|
|
||||||
["正面", [0, 2, 7]],
|
|
||||||
["侧面", [7, 2, 0]],
|
|
||||||
["俯视", [0, 8, 0.1]],
|
|
||||||
].map(([name, position]) => (
|
|
||||||
<button
|
|
||||||
className={button}
|
|
||||||
key={String(name)}
|
|
||||||
onClick={() => {
|
|
||||||
const id = crypto.randomUUID();
|
|
||||||
commit({
|
|
||||||
...current.current,
|
|
||||||
activeCameraId: id,
|
|
||||||
cameras: [
|
|
||||||
...current.current.cameras,
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
name: `${name}机位${current.current.cameras.length + 1}`,
|
|
||||||
position: position as DirectorVector,
|
|
||||||
target: [0, 1, 0],
|
|
||||||
fov: 50,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Plus size={12} className="inline" />
|
{scene.cameras.map((c) => (
|
||||||
{String(name)}
|
<option key={c.id} value={c.id}>
|
||||||
</button>
|
{c.name}
|
||||||
))}
|
</option>
|
||||||
</div>
|
))}
|
||||||
<label className="block text-sm">
|
</select>
|
||||||
视角 FOV
|
</label>
|
||||||
<input
|
<div className="flex flex-wrap gap-1">
|
||||||
aria-label="视角 FOV"
|
{[
|
||||||
className={`${field} mt-2`}
|
["正面", [0, 2, 7]],
|
||||||
type="number"
|
["侧面", [7, 2, 0]],
|
||||||
min={15}
|
["俯视", [0, 8, 0.1]],
|
||||||
max={90}
|
].map(([name, position]) => (
|
||||||
value={camera.fov}
|
|
||||||
onChange={(e) => {
|
|
||||||
const n = Number(e.target.value);
|
|
||||||
if (n >= 15 && n <= 90) patchCamera({ fov: n });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block text-sm">
|
|
||||||
截图比例
|
|
||||||
<select
|
|
||||||
aria-label="截图比例"
|
|
||||||
className={`${field} mt-2`}
|
|
||||||
value={scene.ratio}
|
|
||||||
onChange={(e) =>
|
|
||||||
commit({ ...current.current, ratio: e.target.value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{directorRatios.map((r) => (
|
|
||||||
<option key={r}>{r}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{object ? (
|
|
||||||
<section className="space-y-2 border-t border-white/10 pt-3">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span>{object.name}</span>
|
|
||||||
<button
|
<button
|
||||||
aria-label="删除所选场景对象"
|
className={button}
|
||||||
|
key={String(name)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
commit({
|
commit({
|
||||||
...current.current,
|
...current.current,
|
||||||
objects: current.current.objects.filter(
|
activeCameraId: id,
|
||||||
(o) => o.id !== object.id,
|
cameras: [
|
||||||
),
|
...current.current.cameras,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
name: `${name}机位${current.current.cameras.length + 1}`,
|
||||||
|
position: position as DirectorVector,
|
||||||
|
target: [0, 1, 0],
|
||||||
|
fov: 50,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
setSelected("");
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Plus size={12} className="inline" />
|
||||||
|
{String(name)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
{(["position", "rotation", "scale"] as const).map((key, j) => (
|
|
||||||
<div key={key}>
|
|
||||||
<div className="mb-1 text-xs text-white/50">
|
|
||||||
{["位置", "旋转(弧度)", "缩放"][j]}
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-1">
|
|
||||||
{object[key].map((v, i) => (
|
|
||||||
<input
|
|
||||||
key={i}
|
|
||||||
aria-label={`${object.name}-${key}-${"XYZ"[i]}`}
|
|
||||||
className={field}
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={Number(v.toFixed(2))}
|
|
||||||
onChange={(e) => {
|
|
||||||
const n = Number(e.target.value);
|
|
||||||
if (
|
|
||||||
!Number.isFinite(n) ||
|
|
||||||
(key === "scale" && n <= 0)
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
const values = [...object[key]] as DirectorVector;
|
|
||||||
values[i] = n;
|
|
||||||
patchObject(object.id, { [key]: values });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
<label className="block text-sm">
|
||||||
|
视角 FOV
|
||||||
|
<input
|
||||||
|
aria-label="视角 FOV"
|
||||||
|
className={`${field} mt-2`}
|
||||||
|
type="number"
|
||||||
|
min={15}
|
||||||
|
max={90}
|
||||||
|
value={camera.fov}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = Number(e.target.value);
|
||||||
|
if (n >= 15 && n <= 90) patchCamera({ fov: n });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
截图比例
|
||||||
|
<select
|
||||||
|
aria-label="截图比例"
|
||||||
|
className={`${field} mt-2`}
|
||||||
|
value={scene.ratio}
|
||||||
|
onChange={(e) =>
|
||||||
|
commit({ ...current.current, ratio: e.target.value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{directorRatios.map((r) => (
|
||||||
|
<option key={r}>{r}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{(["position", "target"] as const).map((key, j) => (
|
||||||
|
<label className="block text-xs" key={key}>
|
||||||
|
{j === 0 ? "相机位置" : "相机注视点"}
|
||||||
|
<div className="mt-1 grid grid-cols-3 gap-1">
|
||||||
|
{camera[key].map((n, i) => (
|
||||||
|
<input
|
||||||
|
key={i}
|
||||||
|
aria-label={`相机-${key}-${"XYZ"[i]}`}
|
||||||
|
className={field}
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={Number(n.toFixed(2))}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = Number(e.target.value);
|
||||||
|
if (!Number.isFinite(n) || Math.abs(n) > 10000) return;
|
||||||
|
const values = [...camera[key]] as DirectorVector;
|
||||||
|
values[i] = n;
|
||||||
|
patchCamera({ [key]: values });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{object ? (
|
||||||
|
<section className="space-y-2 border-t border-white/10 pt-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>{object.name}</span>
|
||||||
|
<button
|
||||||
|
aria-label="删除所选场景对象"
|
||||||
|
onClick={() => {
|
||||||
|
commit({
|
||||||
|
...current.current,
|
||||||
|
objects: current.current.objects.filter(
|
||||||
|
(o) => o.id !== object.id,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
setSelected("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{(["position", "rotation", "scale"] as const).map((key, j) => (
|
||||||
|
<div key={key}>
|
||||||
|
<div className="mb-1 text-xs text-white/50">
|
||||||
|
{["位置", "旋转(弧度)", "缩放"][j]}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-1">
|
||||||
|
{object[key].map((v, i) => (
|
||||||
|
<input
|
||||||
|
key={i}
|
||||||
|
aria-label={`${object.name}-${key}-${"XYZ"[i]}`}
|
||||||
|
className={field}
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={Number(v.toFixed(2))}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = Number(e.target.value);
|
||||||
|
if (
|
||||||
|
!Number.isFinite(n) ||
|
||||||
|
(key === "scale" && n <= 0)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const values = [...object[key]] as DirectorVector;
|
||||||
|
values[i] = n;
|
||||||
|
patchObject(object.id, { [key]: values });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</fieldset>
|
||||||
|
<DirectorMotionPanel
|
||||||
|
motion={scene.motion}
|
||||||
|
pose={camera}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(motion) => commit({ ...current.current, motion })}
|
||||||
|
onPose={patchCamera}
|
||||||
|
onPreview={setPreviewTime}
|
||||||
|
onExport={exportMotion}
|
||||||
|
onSend={sendMotion}
|
||||||
|
exportProgress={exportProgress}
|
||||||
|
onCancel={() => recording.current?.abort()}
|
||||||
|
onOverview={() => {
|
||||||
|
setCameraView(false);
|
||||||
|
viewport.current?.showPath();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{exportedVideo ? (
|
||||||
|
<section
|
||||||
|
className="space-y-2 border-t border-white/10 pt-3"
|
||||||
|
aria-label="运镜导出结果"
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
controls
|
||||||
|
src={exportedVideo.url}
|
||||||
|
className="w-full"
|
||||||
|
aria-label="真实运镜导出预览"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
className={button}
|
||||||
|
href={exportedVideo.url}
|
||||||
|
download={`director-motion.${exportedVideo.blob.type.includes("mp4") ? "mp4" : "webm"}`}
|
||||||
|
>
|
||||||
|
下载运镜视频
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
disabled={busy}
|
||||||
|
className={`${button} w-full`}
|
||||||
|
onClick={async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const ext = exportedVideo.blob.type.includes("mp4")
|
||||||
|
? "mp4"
|
||||||
|
: "webm";
|
||||||
|
const url = await upload(
|
||||||
|
new File(
|
||||||
|
[exportedVideo.blob],
|
||||||
|
`director-motion-${Date.now()}.${ext}`,
|
||||||
|
{ type: exportedVideo.blob.type.split(";")[0] },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
onSendVideo({ ...exportedVideo, url });
|
||||||
|
toast.success("运镜视频已发送到画布");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "上传失败");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
发送真实运镜视频到画布
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
<section className="space-y-2 border-t border-white/10 pt-3">
|
<section className="space-y-2 border-t border-white/10 pt-3">
|
||||||
|
|||||||
@ -16,14 +16,28 @@ import {
|
|||||||
type DirectorScene,
|
type DirectorScene,
|
||||||
type DirectorObject,
|
type DirectorObject,
|
||||||
} from "@/lib/video-canvas/director-scene";
|
} from "@/lib/video-canvas/director-scene";
|
||||||
|
import {
|
||||||
|
sampleDirectorMotion,
|
||||||
|
type DirectorPose,
|
||||||
|
} from "@/lib/video-canvas/director-motion";
|
||||||
|
import { recordDirectorVideo } from "@/lib/video-canvas/director-recording";
|
||||||
export type DirectorViewportHandle = {
|
export type DirectorViewportHandle = {
|
||||||
capture: () => Promise<{ blob: Blob; width: number; height: number }>;
|
showPath: () => void;
|
||||||
|
record: (
|
||||||
|
signal: AbortSignal,
|
||||||
|
onProgress: (time: number) => void,
|
||||||
|
) => Promise<Blob>;
|
||||||
|
capture: (
|
||||||
|
pose?: DirectorPose,
|
||||||
|
) => Promise<{ blob: Blob; width: number; height: number }>;
|
||||||
};
|
};
|
||||||
type Props = {
|
type Props = {
|
||||||
scene: DirectorScene;
|
scene: DirectorScene;
|
||||||
selected: string;
|
selected: string;
|
||||||
mode: "translate" | "rotate" | "scale";
|
mode: "translate" | "rotate" | "scale";
|
||||||
cameraView: boolean;
|
cameraView: boolean;
|
||||||
|
previewTime: number | null;
|
||||||
|
locked: boolean;
|
||||||
onTransform: (id: string, patch: Partial<DirectorObject>) => void;
|
onTransform: (id: string, patch: Partial<DirectorObject>) => void;
|
||||||
onCamera: (
|
onCamera: (
|
||||||
position: [number, number, number],
|
position: [number, number, number],
|
||||||
@ -32,7 +46,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
function dispose(root: THREE.Object3D) {
|
function dispose(root: THREE.Object3D) {
|
||||||
root.traverse((o) => {
|
root.traverse((o) => {
|
||||||
if (o instanceof THREE.Mesh) {
|
if (o instanceof THREE.Mesh || o instanceof THREE.Line) {
|
||||||
o.geometry.dispose();
|
o.geometry.dispose();
|
||||||
for (const m of Array.isArray(o.material) ? o.material : [o.material]) {
|
for (const m of Array.isArray(o.material) ? o.material : [o.material]) {
|
||||||
for (const value of Object.values(m))
|
for (const value of Object.values(m))
|
||||||
@ -55,6 +69,7 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
transform: TransformControls;
|
transform: TransformControls;
|
||||||
helper: THREE.Object3D;
|
helper: THREE.Object3D;
|
||||||
grid: THREE.GridHelper;
|
grid: THREE.GridHelper;
|
||||||
|
path: THREE.Group;
|
||||||
objects: THREE.Group;
|
objects: THREE.Group;
|
||||||
pending: number;
|
pending: number;
|
||||||
resize: () => void;
|
resize: () => void;
|
||||||
@ -100,6 +115,8 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
world.add(floor);
|
world.add(floor);
|
||||||
const grid = new THREE.GridHelper(30, 30, 0x596573, 0x929daa);
|
const grid = new THREE.GridHelper(30, 30, 0x596573, 0x929daa);
|
||||||
world.add(grid);
|
world.add(grid);
|
||||||
|
const path = new THREE.Group();
|
||||||
|
world.add(path);
|
||||||
const camera = new THREE.PerspectiveCamera(50, 1, 0.05, 500);
|
const camera = new THREE.PerspectiveCamera(50, 1, 0.05, 500);
|
||||||
camera.position.set(4, 3, 7);
|
camera.position.set(4, 3, 7);
|
||||||
const orbit = new OrbitControls(camera, renderer.domElement);
|
const orbit = new OrbitControls(camera, renderer.domElement);
|
||||||
@ -112,7 +129,10 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
const objects = new THREE.Group();
|
const objects = new THREE.Group();
|
||||||
world.add(objects);
|
world.add(objects);
|
||||||
transform.addEventListener("dragging-changed", (e) => {
|
transform.addEventListener("dragging-changed", (e) => {
|
||||||
orbit.enabled = !e.value && !latest.current.cameraView;
|
orbit.enabled =
|
||||||
|
!e.value &&
|
||||||
|
!latest.current.cameraView &&
|
||||||
|
latest.current.previewTime === null;
|
||||||
});
|
});
|
||||||
transform.addEventListener("mouseUp", () => {
|
transform.addEventListener("mouseUp", () => {
|
||||||
const o = transform.object;
|
const o = transform.object;
|
||||||
@ -123,12 +143,14 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
scale: o.scale.toArray(),
|
scale: o.scale.toArray(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
orbit.addEventListener("end", () =>
|
orbit.addEventListener("end", () => {
|
||||||
|
if (latest.current.previewTime !== null || latest.current.cameraView)
|
||||||
|
return;
|
||||||
latest.current.onCamera(
|
latest.current.onCamera(
|
||||||
camera.position.toArray(),
|
camera.position.toArray(),
|
||||||
orbit.target.toArray(),
|
orbit.target.toArray(),
|
||||||
),
|
);
|
||||||
);
|
});
|
||||||
|
|
||||||
const resize = () => {
|
const resize = () => {
|
||||||
if (!host.current) return;
|
if (!host.current) return;
|
||||||
@ -148,6 +170,7 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
transform,
|
transform,
|
||||||
helper,
|
helper,
|
||||||
grid,
|
grid,
|
||||||
|
path,
|
||||||
objects,
|
objects,
|
||||||
pending: 0,
|
pending: 0,
|
||||||
resize,
|
resize,
|
||||||
@ -157,7 +180,7 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
observer.observe(host.current);
|
observer.observe(host.current);
|
||||||
resize();
|
resize();
|
||||||
renderer.setAnimationLoop(() => {
|
renderer.setAnimationLoop(() => {
|
||||||
orbit.update();
|
if (orbit.enabled) orbit.update();
|
||||||
renderer.render(world, camera);
|
renderer.render(world, camera);
|
||||||
});
|
});
|
||||||
setReady(true);
|
setReady(true);
|
||||||
@ -193,7 +216,11 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
object.rotation.set(...item.rotation);
|
object.rotation.set(...item.rotation);
|
||||||
object.scale.fromArray(item.scale);
|
object.scale.fromArray(item.scale);
|
||||||
r.objects.add(object);
|
r.objects.add(object);
|
||||||
if (item.id === latest.current.selected && !latest.current.cameraView)
|
if (
|
||||||
|
item.id === latest.current.selected &&
|
||||||
|
!latest.current.cameraView &&
|
||||||
|
latest.current.previewTime === null
|
||||||
|
)
|
||||||
r.transform.attach(object);
|
r.transform.attach(object);
|
||||||
};
|
};
|
||||||
for (const item of props.scene.objects) {
|
for (const item of props.scene.objects) {
|
||||||
@ -274,18 +301,26 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const r = runtime.current;
|
const r = runtime.current;
|
||||||
if (!r) return;
|
if (!r) return;
|
||||||
|
if (props.locked) {
|
||||||
|
r.orbit.enabled = false;
|
||||||
|
r.transform.detach();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const camera =
|
const camera =
|
||||||
props.scene.cameras.find((c) => c.id === props.scene.activeCameraId) ||
|
props.scene.cameras.find((c) => c.id === props.scene.activeCameraId) ||
|
||||||
props.scene.cameras[0];
|
props.scene.cameras[0];
|
||||||
r.camera.position.fromArray(camera.position);
|
if (props.previewTime === null) {
|
||||||
r.camera.fov = camera.fov;
|
r.camera.position.fromArray(camera.position);
|
||||||
r.camera.updateProjectionMatrix();
|
r.camera.fov = camera.fov;
|
||||||
r.orbit.target.fromArray(camera.target);
|
r.camera.updateProjectionMatrix();
|
||||||
r.orbit.enabled = !props.cameraView;
|
r.orbit.target.fromArray(camera.target);
|
||||||
r.orbit.update();
|
r.orbit.update();
|
||||||
|
}
|
||||||
|
r.orbit.enabled = !props.cameraView && props.previewTime === null;
|
||||||
r.transform.setMode(props.mode);
|
r.transform.setMode(props.mode);
|
||||||
const selected = r.objects.getObjectByName(props.selected);
|
const selected = r.objects.getObjectByName(props.selected);
|
||||||
if (selected && !props.cameraView) r.transform.attach(selected);
|
if (selected && !props.cameraView && props.previewTime === null)
|
||||||
|
r.transform.attach(selected);
|
||||||
else r.transform.detach();
|
else r.transform.detach();
|
||||||
}, [
|
}, [
|
||||||
props.scene.cameras,
|
props.scene.cameras,
|
||||||
@ -293,6 +328,59 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
props.selected,
|
props.selected,
|
||||||
props.mode,
|
props.mode,
|
||||||
props.cameraView,
|
props.cameraView,
|
||||||
|
props.previewTime === null,
|
||||||
|
props.locked,
|
||||||
|
ready,
|
||||||
|
]);
|
||||||
|
useEffect(() => {
|
||||||
|
const r = runtime.current;
|
||||||
|
if (!r) return;
|
||||||
|
dispose(r.path);
|
||||||
|
r.path.clear();
|
||||||
|
const motion = props.scene.motion;
|
||||||
|
if (motion.keyframes.length < 2) return;
|
||||||
|
const points = Array.from(
|
||||||
|
{ length: 121 },
|
||||||
|
(_, i) =>
|
||||||
|
new THREE.Vector3(
|
||||||
|
...sampleDirectorMotion(motion, (motion.duration * i) / 120)!
|
||||||
|
.position,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
r.path.add(
|
||||||
|
new THREE.Line(
|
||||||
|
new THREE.BufferGeometry().setFromPoints(points),
|
||||||
|
new THREE.LineBasicMaterial({ color: 0xfbbf24 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const k of motion.keyframes) {
|
||||||
|
const dot = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.08, 10, 8),
|
||||||
|
new THREE.MeshBasicMaterial({ color: 0xfbbf24 }),
|
||||||
|
);
|
||||||
|
dot.position.fromArray(k.position);
|
||||||
|
r.path.add(dot);
|
||||||
|
}
|
||||||
|
}, [props.scene.motion, ready]);
|
||||||
|
useEffect(() => {
|
||||||
|
const r = runtime.current;
|
||||||
|
if (!r) return;
|
||||||
|
r.path.visible =
|
||||||
|
!props.locked && !props.cameraView && props.previewTime === null;
|
||||||
|
if (props.locked || props.previewTime === null) return;
|
||||||
|
const pose = sampleDirectorMotion(props.scene.motion, props.previewTime);
|
||||||
|
if (!pose) return;
|
||||||
|
r.camera.position.fromArray(pose.position);
|
||||||
|
r.orbit.target.fromArray(pose.target);
|
||||||
|
r.camera.fov = pose.fov;
|
||||||
|
r.camera.updateProjectionMatrix();
|
||||||
|
r.camera.lookAt(r.orbit.target);
|
||||||
|
r.transform.detach();
|
||||||
|
}, [
|
||||||
|
props.previewTime,
|
||||||
|
props.scene.motion,
|
||||||
|
props.cameraView,
|
||||||
|
props.locked,
|
||||||
ready,
|
ready,
|
||||||
]);
|
]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -301,7 +389,81 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
capture: async () => {
|
showPath: () => {
|
||||||
|
const r = runtime.current;
|
||||||
|
if (!r) return;
|
||||||
|
const points = latest.current.scene.motion.keyframes.map(
|
||||||
|
(k) => new THREE.Vector3(...k.position),
|
||||||
|
);
|
||||||
|
if (!points.length) return;
|
||||||
|
points.push(new THREE.Vector3(0, 1, 0));
|
||||||
|
const box = new THREE.Box3().setFromPoints(points),
|
||||||
|
center = box.getCenter(new THREE.Vector3());
|
||||||
|
const radius = Math.max(3, box.getSize(new THREE.Vector3()).length());
|
||||||
|
const position = center
|
||||||
|
.clone()
|
||||||
|
.add(
|
||||||
|
new THREE.Vector3(1, 0.8, 1)
|
||||||
|
.normalize()
|
||||||
|
.multiplyScalar(radius * 1.4),
|
||||||
|
);
|
||||||
|
latest.current.onCamera(position.toArray(), center.toArray());
|
||||||
|
},
|
||||||
|
record: async (signal, onProgress) => {
|
||||||
|
const r = runtime.current;
|
||||||
|
if (!r || !ready || r.pending || error)
|
||||||
|
throw Error(error || "3D 场景尚未准备好");
|
||||||
|
if (latest.current.scene.motion.keyframes.length < 2)
|
||||||
|
throw Error("至少需要两个关键帧");
|
||||||
|
const motion = latest.current.scene.motion;
|
||||||
|
const size = r.renderer.getSize(new THREE.Vector2()),
|
||||||
|
pixelRatio = r.renderer.getPixelRatio();
|
||||||
|
const position = r.camera.position.clone(),
|
||||||
|
target = r.orbit.target.clone(),
|
||||||
|
fov = r.camera.fov,
|
||||||
|
aspect = r.camera.aspect;
|
||||||
|
const visibility = [r.helper.visible, r.grid.visible, r.path.visible],
|
||||||
|
enabled = r.orbit.enabled;
|
||||||
|
const dimensions = directorImageSize(latest.current.scene.ratio);
|
||||||
|
r.helper.visible = false;
|
||||||
|
r.grid.visible = false;
|
||||||
|
r.path.visible = false;
|
||||||
|
r.orbit.enabled = false;
|
||||||
|
r.renderer.setPixelRatio(1);
|
||||||
|
r.renderer.setSize(dimensions.width, dimensions.height, false);
|
||||||
|
r.camera.aspect = dimensions.width / dimensions.height;
|
||||||
|
try {
|
||||||
|
return await recordDirectorVideo({
|
||||||
|
canvas: r.renderer.domElement,
|
||||||
|
duration: motion.duration,
|
||||||
|
signal,
|
||||||
|
onProgress,
|
||||||
|
render: (time) => {
|
||||||
|
const pose = sampleDirectorMotion(motion, time)!;
|
||||||
|
r.camera.position.fromArray(pose.position);
|
||||||
|
r.orbit.target.fromArray(pose.target);
|
||||||
|
r.camera.fov = pose.fov;
|
||||||
|
r.camera.updateProjectionMatrix();
|
||||||
|
r.camera.lookAt(r.orbit.target);
|
||||||
|
r.renderer.render(r.world, r.camera);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (runtime.current === r) {
|
||||||
|
r.renderer.setPixelRatio(pixelRatio);
|
||||||
|
r.renderer.setSize(size.x, size.y);
|
||||||
|
r.camera.position.copy(position);
|
||||||
|
r.orbit.target.copy(target);
|
||||||
|
r.camera.fov = fov;
|
||||||
|
r.camera.aspect = aspect;
|
||||||
|
r.camera.updateProjectionMatrix();
|
||||||
|
r.orbit.enabled = enabled;
|
||||||
|
r.orbit.update();
|
||||||
|
[r.helper.visible, r.grid.visible, r.path.visible] = visibility;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
capture: async (pose) => {
|
||||||
const r = runtime.current;
|
const r = runtime.current;
|
||||||
if (!r || !ready || r.pending || error)
|
if (!r || !ready || r.pending || error)
|
||||||
throw Error(error || "3D 场景尚未准备好");
|
throw Error(error || "3D 场景尚未准备好");
|
||||||
@ -311,6 +473,19 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
const previousSize = r.renderer.getSize(new THREE.Vector2());
|
const previousSize = r.renderer.getSize(new THREE.Vector2());
|
||||||
const pixelRatio = r.renderer.getPixelRatio();
|
const pixelRatio = r.renderer.getPixelRatio();
|
||||||
const aspect = r.camera.aspect;
|
const aspect = r.camera.aspect;
|
||||||
|
const position = r.camera.position.clone(),
|
||||||
|
target = r.orbit.target.clone(),
|
||||||
|
fov = r.camera.fov;
|
||||||
|
const visibility = [r.helper.visible, r.grid.visible, r.path.visible];
|
||||||
|
const enabled = r.orbit.enabled;
|
||||||
|
r.orbit.enabled = false;
|
||||||
|
if (pose) {
|
||||||
|
r.camera.position.fromArray(pose.position);
|
||||||
|
r.orbit.target.fromArray(pose.target);
|
||||||
|
r.camera.fov = pose.fov;
|
||||||
|
r.camera.lookAt(r.orbit.target);
|
||||||
|
}
|
||||||
|
r.path.visible = false;
|
||||||
r.helper.visible = false;
|
r.helper.visible = false;
|
||||||
r.grid.visible = false;
|
r.grid.visible = false;
|
||||||
r.renderer.setPixelRatio(1);
|
r.renderer.setPixelRatio(1);
|
||||||
@ -331,8 +506,13 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
r.renderer.setSize(previousSize.x, previousSize.y);
|
r.renderer.setSize(previousSize.x, previousSize.y);
|
||||||
r.camera.aspect = aspect;
|
r.camera.aspect = aspect;
|
||||||
r.camera.updateProjectionMatrix();
|
r.camera.updateProjectionMatrix();
|
||||||
r.grid.visible = true;
|
r.camera.position.copy(position);
|
||||||
r.helper.visible = true;
|
r.orbit.target.copy(target);
|
||||||
|
r.camera.fov = fov;
|
||||||
|
r.camera.updateProjectionMatrix();
|
||||||
|
r.orbit.update();
|
||||||
|
[r.helper.visible, r.grid.visible, r.path.visible] = visibility;
|
||||||
|
r.orbit.enabled = enabled;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
50
docs/video-canvas-director-motion-2026-09-17.md
Normal file
50
docs/video-canvas-director-motion-2026-09-17.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# 3D 导演台镜头轨迹修复与验收(2026-09-17)
|
||||||
|
|
||||||
|
此前导演台只保存静态机位,运镜素材库只向提示词追加文字。静态机位截图生成视频的验收不代表连续镜头轨迹可用。本次补齐时间轴、真实 3D 播放和视频导出,未部署生产环境。
|
||||||
|
|
||||||
|
## 完成的功能
|
||||||
|
|
||||||
|
- 场景增加 `motion` 数据:2–15 秒、最多 100 个关键帧、相机位置、注视点、FOV、线性/缓入缓出插值和循环设置。兼容没有轨迹的旧场景;轨迹随原有画布保存链路持久化,不需要数据库迁移。
|
||||||
|
- 支持记录、覆盖、编辑、删除关键帧及修改时间;重复时间和无效数据拦截。调整时长按比例缩放关键帧时间。
|
||||||
|
- 推进、拉远、90° 环绕、横移预设;黄色轨迹与关键帧标记、完整轨迹总览。
|
||||||
|
- 播放、暂停、循环、时间轴定位;预览时关闭手动相机操控。进入编辑时从当前预览机位继续,避免跳回旧机位。页面进入后台时暂停预览。
|
||||||
|
- 导入/导出带版本号的 JSON 轨迹文件;文件限制 256 KiB,坏文件不会覆盖现有轨迹。
|
||||||
|
- 按真实相机轨迹录制无声 MP4,隐藏网格、辅助轴和路径,支持取消;浏览器不支持 MP4 编码时明确提示更新浏览器。切到后台、关闭导演台、渲染错误或超时会停止录制并释放媒体轨道,不把部分文件当成功结果。
|
||||||
|
- 导出结果可预览、下载、上传为真实视频节点;保存实际时长,供剪辑使用。视频上传使用现有 `canvas-video-process` 用途参数。
|
||||||
|
- “发送首帧与运镜到视频节点”真实渲染 0 秒机位,建立导演台→首帧→视频节点连线,携带完整轨迹元数据及精简的自然语言运镜描述。模型、尺寸等继续由视频节点选择;不自动发起计费生成。
|
||||||
|
|
||||||
|
## 实测中修复的回归
|
||||||
|
|
||||||
|
- 使用整个关键帧覆盖机位会把关键帧 ID 写成相机 ID,导致后续 FOV 编辑失效。现在仅合并 position、target、fov,保留相机身份,并有专门回归测试。
|
||||||
|
- WebM 原始录制缺失 duration,影响剪辑;改为 MP4。实际产物为 H.264,包含可读取的时长。
|
||||||
|
- 预览/录制时 OrbitControls 仍可能约束相机距离;改为显式设置相机朝向,录制期间锁定交互,避免改变轨迹。
|
||||||
|
- 从预览切换到录制时,React 更新可能重新显示轨迹辅助线;加入录制锁,确保辅助线不进入导出画面。
|
||||||
|
|
||||||
|
## 验收证据
|
||||||
|
|
||||||
|
浏览器使用生产组件进行本地独立验收;测试页仅将画布保存与对象存储替换成本地适配器,不伪造 WebGL、时间轴或编码结果。本轮没有调用收费 AI 模型,也未重新验证生产数据库/对象存储的整条链路;原画布持久化和真实万相链路见 2026-09-16 验收记录。
|
||||||
|
|
||||||
|
| 检查 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 环绕、推进与时间轴 | 浏览器真实播放,循环后仍持续播放;暂停及定位到终点有效 |
|
||||||
|
| 轨迹总览 | 相机切换到轨迹包围盒总览视角 |
|
||||||
|
| 自定义 JSON 导入 | 3 秒、2 个关键帧,位置与 FOV 参数生效 |
|
||||||
|
| 坏文件导入 | 显示无效数据提示,保留原轨迹 |
|
||||||
|
| 暂停时选择场景对象 | 预览不跳回编辑机位;场景区域像素对比无变化 |
|
||||||
|
| 关键帧更新 | 第二帧 FOV 改为 65° 并记录,完整刷新后恢复 |
|
||||||
|
| 首帧传递 | 从 0 秒机位生成 1280×720 PNG,回调收到首帧及 2 帧轨迹 |
|
||||||
|
| 横屏 MP4 | 1280×720,H.264,150 帧,4.998 秒,无音轨 |
|
||||||
|
| 播放中切换导出 | 1280×720,90 帧,3.0005 秒;从轨迹起点完整导出,画面无辅助线 |
|
||||||
|
| 竖屏 MP4 | 720×1280,H.264,90 帧,3.002 秒,无音轨 |
|
||||||
|
| 逐帧检查 | 对比初始、中间、结尾帧,视角连续变化,非静态图片录制 |
|
||||||
|
| 取消导出 | 浏览器中取消后恢复按钮与编辑状态,未出现成功产物 |
|
||||||
|
| 录制生命周期 | 自动化验证正常结束、取消、后台事件及渲染失败均释放轨道与帧回调 |
|
||||||
|
| 代码验证 | TypeScript 源码检查通过;49 条定向回归测试通过 |
|
||||||
|
|
||||||
|
浏览器自动化的多标签切换未可靠触发页面可见性事件,因此后台处理依靠事件级回归验证,不将该次标签切换算作真实后台场景通过。下载链接已接入原生下载;本轮媒体文件通过组件上传回调留档,并用 FFprobe 核验。
|
||||||
|
|
||||||
|
本地 `.qa/director-motion/` 留存测试 MP4、逐帧截图与探测结果,不提交生成文件和本地测试适配器。
|
||||||
|
|
||||||
|
## AI 生成与真实轨迹的边界
|
||||||
|
|
||||||
|
真实 3D 导出使用配置的轨迹。当前阿里云首帧模型接入接收首帧与运镜文字,不接收可精确执行的相机轨迹参数,因此 AI 生成仍可能偏离预设,界面已明确说明。需要精确复现时使用“导出真实运镜视频”。AI 识图重建仍是独立的未接通能力,不属于本次运镜修复。
|
||||||
228
lib/video-canvas/director-motion.ts
Normal file
228
lib/video-canvas/director-motion.ts
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
import type { DirectorCamera, DirectorVector } from "./director-scene";
|
||||||
|
export type DirectorPose = Pick<DirectorCamera, "position" | "target" | "fov">;
|
||||||
|
export function mergeDirectorCameraPose(
|
||||||
|
camera: DirectorCamera,
|
||||||
|
pose: Partial<DirectorPose>,
|
||||||
|
): DirectorCamera {
|
||||||
|
return {
|
||||||
|
...camera,
|
||||||
|
position: pose.position ?? camera.position,
|
||||||
|
target: pose.target ?? camera.target,
|
||||||
|
fov: pose.fov ?? camera.fov,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type DirectorKeyframe = DirectorPose & { id: string; time: number };
|
||||||
|
export type DirectorMotion = {
|
||||||
|
duration: number;
|
||||||
|
easing: "linear" | "smooth";
|
||||||
|
loop: boolean;
|
||||||
|
keyframes: DirectorKeyframe[];
|
||||||
|
};
|
||||||
|
export const defaultDirectorMotion = (): DirectorMotion => ({
|
||||||
|
duration: 5,
|
||||||
|
easing: "smooth",
|
||||||
|
loop: false,
|
||||||
|
keyframes: [],
|
||||||
|
});
|
||||||
|
const vector = (v: unknown): v is DirectorVector =>
|
||||||
|
Array.isArray(v) &&
|
||||||
|
v.length === 3 &&
|
||||||
|
v.every(
|
||||||
|
(n) => typeof n === "number" && Number.isFinite(n) && Math.abs(n) <= 10000,
|
||||||
|
);
|
||||||
|
export function validDirectorPose(p: DirectorPose) {
|
||||||
|
return (
|
||||||
|
!!p &&
|
||||||
|
vector(p.position) &&
|
||||||
|
vector(p.target) &&
|
||||||
|
Number.isFinite(p.fov) &&
|
||||||
|
p.fov >= 15 &&
|
||||||
|
p.fov <= 90 &&
|
||||||
|
Math.hypot(...p.position.map((n, i) => n - p.target[i])) > 0.001
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function readDirectorMotion(input: unknown): DirectorMotion {
|
||||||
|
const v = (input || {}) as Partial<DirectorMotion>;
|
||||||
|
const duration = Number.isFinite(v.duration)
|
||||||
|
? Math.max(2, Math.min(15, Number(v.duration)))
|
||||||
|
: 5;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const times = new Set<number>();
|
||||||
|
const keyframes = (Array.isArray(v.keyframes) ? v.keyframes : [])
|
||||||
|
.filter((k) => {
|
||||||
|
if (
|
||||||
|
!k ||
|
||||||
|
typeof k.id !== "string" ||
|
||||||
|
seen.has(k.id) ||
|
||||||
|
!Number.isFinite(k.time) ||
|
||||||
|
k.time < 0 ||
|
||||||
|
k.time > duration ||
|
||||||
|
times.has(k.time) ||
|
||||||
|
!validDirectorPose(k)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
seen.add(k.id);
|
||||||
|
times.add(k.time);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.slice(0, 100)
|
||||||
|
.map((k) => ({
|
||||||
|
id: k.id,
|
||||||
|
time: k.time,
|
||||||
|
position: [...k.position] as DirectorVector,
|
||||||
|
target: [...k.target] as DirectorVector,
|
||||||
|
fov: k.fov,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.time - b.time);
|
||||||
|
return {
|
||||||
|
duration,
|
||||||
|
easing: v.easing === "linear" ? "linear" : "smooth",
|
||||||
|
loop: v.loop === true,
|
||||||
|
keyframes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const lerpVector = (
|
||||||
|
a: DirectorVector,
|
||||||
|
b: DirectorVector,
|
||||||
|
t: number,
|
||||||
|
): DirectorVector => a.map((n, i) => n + (b[i] - n) * t) as DirectorVector;
|
||||||
|
export function sampleDirectorMotion(
|
||||||
|
m: DirectorMotion,
|
||||||
|
time: number,
|
||||||
|
): DirectorPose | null {
|
||||||
|
const keys = m.keyframes;
|
||||||
|
if (!keys.length) return null;
|
||||||
|
const t = Number.isFinite(time) ? Math.max(0, Math.min(m.duration, time)) : 0;
|
||||||
|
const right = keys.findIndex((k) => k.time > t);
|
||||||
|
const a = right < 0 ? keys[keys.length - 1] : keys[Math.max(0, right - 1)];
|
||||||
|
const b = right < 0 ? a : keys[right];
|
||||||
|
let u =
|
||||||
|
a === b ? 0 : Math.max(0, Math.min(1, (t - a.time) / (b.time - a.time)));
|
||||||
|
if (m.easing === "smooth") u = u * u * (3 - 2 * u);
|
||||||
|
const pose = {
|
||||||
|
position: lerpVector(a.position, b.position, u),
|
||||||
|
target: lerpVector(a.target, b.target, u),
|
||||||
|
fov: a.fov + (b.fov - a.fov) * u,
|
||||||
|
};
|
||||||
|
// A path crossing its look-at target has no defined viewing direction.
|
||||||
|
if (!validDirectorPose(pose)) {
|
||||||
|
const direction = a.position.map((n, i) => n - a.target[i]);
|
||||||
|
const length = Math.hypot(...direction);
|
||||||
|
pose.target = pose.position.map(
|
||||||
|
(n, i) => n - (direction[i] / length) * 0.01,
|
||||||
|
) as DirectorVector;
|
||||||
|
}
|
||||||
|
return pose;
|
||||||
|
}
|
||||||
|
export function resizeDirectorMotion(
|
||||||
|
m: DirectorMotion,
|
||||||
|
duration: number,
|
||||||
|
): DirectorMotion {
|
||||||
|
const next = Math.max(2, Math.min(15, duration));
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
duration: next,
|
||||||
|
keyframes: m.keyframes.map((k) => ({
|
||||||
|
...k,
|
||||||
|
time: (k.time / m.duration) * next,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function presetDirectorMotion(
|
||||||
|
pose: DirectorPose,
|
||||||
|
kind: "push" | "pull" | "orbit" | "pan",
|
||||||
|
duration = 5,
|
||||||
|
): DirectorMotion {
|
||||||
|
const m = { ...defaultDirectorMotion(), duration };
|
||||||
|
const offset = pose.position.map(
|
||||||
|
(n, i) => n - pose.target[i],
|
||||||
|
) as DirectorVector;
|
||||||
|
const count = kind === "orbit" ? 9 : 2;
|
||||||
|
m.keyframes = Array.from({ length: count }, (_, i) => {
|
||||||
|
const u = i / (count - 1);
|
||||||
|
let position: DirectorVector = [...pose.position],
|
||||||
|
target: DirectorVector = [...pose.target];
|
||||||
|
if (kind === "push" || kind === "pull")
|
||||||
|
position = pose.target.map(
|
||||||
|
(n, j) => n + offset[j] * (1 + u * (kind === "push" ? -0.5 : 0.5)),
|
||||||
|
) as DirectorVector;
|
||||||
|
if (kind === "orbit") {
|
||||||
|
const angle = (u * Math.PI) / 2;
|
||||||
|
position = [
|
||||||
|
pose.target[0] +
|
||||||
|
offset[0] * Math.cos(angle) +
|
||||||
|
offset[2] * Math.sin(angle),
|
||||||
|
pose.position[1],
|
||||||
|
pose.target[2] -
|
||||||
|
offset[0] * Math.sin(angle) +
|
||||||
|
offset[2] * Math.cos(angle),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (kind === "pan") {
|
||||||
|
const length = Math.hypot(offset[0], offset[2]) || 1;
|
||||||
|
const shift: DirectorVector = [
|
||||||
|
(offset[2] / length) * 2 * u,
|
||||||
|
0,
|
||||||
|
(-offset[0] / length) * 2 * u,
|
||||||
|
];
|
||||||
|
position = position.map((n, j) => n + shift[j]) as DirectorVector;
|
||||||
|
target = target.map((n, j) => n + shift[j]) as DirectorVector;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: `${kind}-${i}`,
|
||||||
|
time: u * duration,
|
||||||
|
position,
|
||||||
|
target,
|
||||||
|
fov: pose.fov,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (kind === "orbit") m.easing = "linear";
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
export function directorMotionPrompt(m: DirectorMotion) {
|
||||||
|
if (m.keyframes.length < 2) return "";
|
||||||
|
// Keep the model prompt compact; the full trajectory remains in the canvas data.
|
||||||
|
const keys =
|
||||||
|
m.keyframes.length <= 9
|
||||||
|
? m.keyframes
|
||||||
|
: Array.from({ length: 9 }, (_, i) => ({
|
||||||
|
time: (m.duration * i) / 8,
|
||||||
|
...sampleDirectorMotion(m, (m.duration * i) / 8)!,
|
||||||
|
}));
|
||||||
|
const lines = keys.slice(1).map((b, i) => {
|
||||||
|
const a = keys[i],
|
||||||
|
av = a.position.map((n, j) => n - a.target[j]),
|
||||||
|
bv = b.position.map((n, j) => n - b.target[j]);
|
||||||
|
const ar = Math.hypot(...av),
|
||||||
|
br = Math.hypot(...bv),
|
||||||
|
angle =
|
||||||
|
(Math.acos(
|
||||||
|
Math.max(
|
||||||
|
-1,
|
||||||
|
Math.min(
|
||||||
|
1,
|
||||||
|
av.reduce((sum, n, j) => sum + n * bv[j], 0) / (ar * br),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
) *
|
||||||
|
180) /
|
||||||
|
Math.PI;
|
||||||
|
const descriptions: string[] = [];
|
||||||
|
if (angle > 2)
|
||||||
|
descriptions.push(`围绕注视主体转动约 ${Math.round(angle)} 度`);
|
||||||
|
if (br < ar * 0.98) descriptions.push("向主体推进");
|
||||||
|
else if (br > ar * 1.02) descriptions.push("远离主体拉远");
|
||||||
|
if (Math.hypot(...b.target.map((n, j) => n - a.target[j])) > 0.05)
|
||||||
|
descriptions.push("注视点随镜头平移");
|
||||||
|
if (Math.abs(b.position[1] - a.position[1]) > 0.05)
|
||||||
|
descriptions.push(
|
||||||
|
b.position[1] > a.position[1] ? "相机升高" : "相机降低",
|
||||||
|
);
|
||||||
|
if (Math.abs(b.fov - a.fov) > 0.5)
|
||||||
|
descriptions.push(
|
||||||
|
`视角从 ${a.fov.toFixed(0)}° 变至 ${b.fov.toFixed(0)}°`,
|
||||||
|
);
|
||||||
|
return `${a.time.toFixed(2)}–${b.time.toFixed(2)}秒:${descriptions.join(",") || "保持机位和构图"}。`;
|
||||||
|
});
|
||||||
|
return `单一连续镜头,不切镜,总时长 ${m.duration} 秒;镜头${m.easing === "smooth" ? "平滑缓入缓出" : "匀速"}运动。\n${lines.join("\n")}\n保持参考图中的主体和场景一致,镜头连续运动。`;
|
||||||
|
}
|
||||||
95
lib/video-canvas/director-recording.ts
Normal file
95
lib/video-canvas/director-recording.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
/** Record the rendered camera path, never an AI approximation. */
|
||||||
|
export async function recordDirectorVideo({
|
||||||
|
canvas,
|
||||||
|
duration,
|
||||||
|
render,
|
||||||
|
signal,
|
||||||
|
onProgress,
|
||||||
|
}: {
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
duration: number;
|
||||||
|
render: (time: number) => void;
|
||||||
|
signal: AbortSignal;
|
||||||
|
onProgress?: (time: number) => void;
|
||||||
|
}): Promise<Blob> {
|
||||||
|
const mimeType = ["video/mp4"].find(
|
||||||
|
(t) =>
|
||||||
|
typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(t),
|
||||||
|
);
|
||||||
|
if (!mimeType || !canvas.captureStream)
|
||||||
|
throw Error("当前浏览器不支持 MP4 轨迹导出,请使用最新版 Chrome 或 Edge");
|
||||||
|
if (signal.aborted || document.hidden)
|
||||||
|
throw Error("请保持当前页面可见后导出");
|
||||||
|
const stream = canvas.captureStream(30);
|
||||||
|
try {
|
||||||
|
return await new Promise<Blob>((resolve, reject) => {
|
||||||
|
const recorder = new MediaRecorder(stream, {
|
||||||
|
mimeType,
|
||||||
|
videoBitsPerSecond: 6_000_000,
|
||||||
|
});
|
||||||
|
const chunks: Blob[] = [];
|
||||||
|
let frame = 0,
|
||||||
|
started = 0,
|
||||||
|
failure: Error | undefined,
|
||||||
|
finished = false;
|
||||||
|
const cleanup = () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
signal.removeEventListener("abort", cancel);
|
||||||
|
document.removeEventListener("visibilitychange", visibility);
|
||||||
|
};
|
||||||
|
const stop = (error?: Error) => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
failure = error;
|
||||||
|
cleanup();
|
||||||
|
if (recorder.state !== "inactive") recorder.stop();
|
||||||
|
else reject(error || Error("视频录制未启动"));
|
||||||
|
};
|
||||||
|
const cancel = () => stop(Error("已取消轨迹视频导出"));
|
||||||
|
const visibility = () => {
|
||||||
|
if (document.hidden)
|
||||||
|
stop(Error("导出时切换了页面,请回到当前页面重新导出"));
|
||||||
|
};
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => stop(Error("轨迹导出超时,请重试")),
|
||||||
|
duration * 1000 + 15000,
|
||||||
|
);
|
||||||
|
recorder.ondataavailable = (e) => {
|
||||||
|
if (e.data.size) chunks.push(e.data);
|
||||||
|
};
|
||||||
|
recorder.onerror = () => stop(Error("浏览器视频编码失败"));
|
||||||
|
recorder.onstop = () => {
|
||||||
|
cleanup();
|
||||||
|
if (failure) reject(failure);
|
||||||
|
else if (!chunks.length) reject(Error("未生成有效视频"));
|
||||||
|
else resolve(new Blob(chunks, { type: recorder.mimeType }));
|
||||||
|
};
|
||||||
|
signal.addEventListener("abort", cancel, { once: true });
|
||||||
|
document.addEventListener("visibilitychange", visibility);
|
||||||
|
const tick = (now: number) => {
|
||||||
|
if (finished) return;
|
||||||
|
if (!started) started = now;
|
||||||
|
const t = Math.min(duration, (now - started) / 1000);
|
||||||
|
try {
|
||||||
|
render(t);
|
||||||
|
onProgress?.(t);
|
||||||
|
} catch (e) {
|
||||||
|
stop(e instanceof Error ? e : Error("轨迹渲染失败"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t >= duration) stop();
|
||||||
|
else frame = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
render(0);
|
||||||
|
recorder.start(100);
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
} catch (e) {
|
||||||
|
stop(e instanceof Error ? e : Error("视频编码失败"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,9 @@
|
|||||||
|
import {
|
||||||
|
defaultDirectorMotion,
|
||||||
|
readDirectorMotion,
|
||||||
|
validDirectorPose,
|
||||||
|
type DirectorMotion,
|
||||||
|
} from "./director-motion";
|
||||||
export type DirectorVector = [number, number, number];
|
export type DirectorVector = [number, number, number];
|
||||||
export type DirectorObject = {
|
export type DirectorObject = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -29,6 +35,7 @@ export type DirectorScene = {
|
|||||||
activeCameraId: string;
|
activeCameraId: string;
|
||||||
ratio: string;
|
ratio: string;
|
||||||
snapshots: DirectorSnapshot[];
|
snapshots: DirectorSnapshot[];
|
||||||
|
motion: DirectorMotion;
|
||||||
};
|
};
|
||||||
export const directorRatios = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"];
|
export const directorRatios = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"];
|
||||||
export function directorImageSize(ratio: string) {
|
export function directorImageSize(ratio: string) {
|
||||||
@ -63,6 +70,7 @@ export function defaultDirectorScene(): DirectorScene {
|
|||||||
activeCameraId: "camera-1",
|
activeCameraId: "camera-1",
|
||||||
ratio: "16:9",
|
ratio: "16:9",
|
||||||
snapshots: [],
|
snapshots: [],
|
||||||
|
motion: defaultDirectorMotion(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export function readDirectorScene(value: unknown): DirectorScene {
|
export function readDirectorScene(value: unknown): DirectorScene {
|
||||||
@ -83,7 +91,8 @@ export function readDirectorScene(value: unknown): DirectorScene {
|
|||||||
vector(c.target) &&
|
vector(c.target) &&
|
||||||
Number.isFinite(c.fov) &&
|
Number.isFinite(c.fov) &&
|
||||||
c.fov >= 15 &&
|
c.fov >= 15 &&
|
||||||
c.fov <= 90,
|
c.fov <= 90 &&
|
||||||
|
validDirectorPose(c),
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
const objects = Array.isArray(v.objects)
|
const objects = Array.isArray(v.objects)
|
||||||
@ -120,5 +129,6 @@ export function readDirectorScene(value: unknown): DirectorScene {
|
|||||||
: validCameras[0].id,
|
: validCameras[0].id,
|
||||||
ratio: directorRatios.includes(v.ratio) ? v.ratio : "16:9",
|
ratio: directorRatios.includes(v.ratio) ? v.ratio : "16:9",
|
||||||
snapshots,
|
snapshots,
|
||||||
|
motion: readDirectorMotion(v.motion),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -104,3 +104,221 @@ test("models stored on the active media CDN use the authenticated same-origin pr
|
|||||||
"/api/canvas/3d/model-proxy?url=" + encodeURIComponent(url),
|
"/api/canvas/3d/model-proxy?url=" + encodeURIComponent(url),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import {
|
||||||
|
defaultDirectorMotion,
|
||||||
|
readDirectorMotion,
|
||||||
|
sampleDirectorMotion,
|
||||||
|
resizeDirectorMotion,
|
||||||
|
presetDirectorMotion,
|
||||||
|
directorMotionPrompt,
|
||||||
|
validDirectorPose,
|
||||||
|
} from "@/lib/video-canvas/director-motion";
|
||||||
|
const camera = { position: [0, 2, 8], target: [0, 1, 0], fov: 50 } as const;
|
||||||
|
const pose = () =>
|
||||||
|
({
|
||||||
|
position: [...camera.position],
|
||||||
|
target: [...camera.target],
|
||||||
|
fov: 50,
|
||||||
|
}) as import("@/lib/video-canvas/director-motion").DirectorPose;
|
||||||
|
test("legacy scenes acquire an empty timeline without altering cameras", () => {
|
||||||
|
const { motion, ...legacy } = defaultDirectorScene();
|
||||||
|
expect(readDirectorScene(legacy).motion).toEqual(defaultDirectorMotion());
|
||||||
|
expect(readDirectorScene(legacy).cameras).toEqual(legacy.cameras);
|
||||||
|
});
|
||||||
|
test("motion survives serialization and duration changes preserve relative keyframe timing", () => {
|
||||||
|
const scene = defaultDirectorScene();
|
||||||
|
scene.motion = presetDirectorMotion(pose(), "orbit", 5);
|
||||||
|
scene.motion.loop = true;
|
||||||
|
expect(readDirectorScene(JSON.parse(JSON.stringify(scene)))).toEqual(scene);
|
||||||
|
const resized = resizeDirectorMotion(scene.motion, 10);
|
||||||
|
expect(resized.keyframes[4].time).toBe(5);
|
||||||
|
expect(resized.keyframes.at(-1)?.time).toBe(10);
|
||||||
|
expect(resized.keyframes[4].position).toEqual(
|
||||||
|
scene.motion.keyframes[4].position,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("timeline sampling clamps endpoints, holds outside keyframes and differentiates easing", () => {
|
||||||
|
const motion = presetDirectorMotion(pose(), "push", 5);
|
||||||
|
motion.easing = "linear";
|
||||||
|
expect(sampleDirectorMotion(motion, -20)?.position).toEqual(
|
||||||
|
motion.keyframes[0].position,
|
||||||
|
);
|
||||||
|
expect(sampleDirectorMotion(motion, 30)?.position).toEqual(
|
||||||
|
motion.keyframes[1].position,
|
||||||
|
);
|
||||||
|
expect(sampleDirectorMotion(motion, 2.5)?.position[2]).toBe(6);
|
||||||
|
const linear = sampleDirectorMotion(motion, 1)?.position[2]!;
|
||||||
|
motion.easing = "smooth";
|
||||||
|
expect(sampleDirectorMotion(motion, 1)?.position[2]).toBeGreaterThan(linear);
|
||||||
|
motion.keyframes[0].time = 1;
|
||||||
|
motion.keyframes[1].time = 4;
|
||||||
|
expect(sampleDirectorMotion(motion, 0)?.position).toEqual(
|
||||||
|
motion.keyframes[0].position,
|
||||||
|
);
|
||||||
|
expect(sampleDirectorMotion(motion, 5)?.position).toEqual(
|
||||||
|
motion.keyframes[1].position,
|
||||||
|
);
|
||||||
|
expect(sampleDirectorMotion(defaultDirectorMotion(), 0)).toBeNull();
|
||||||
|
});
|
||||||
|
test("invalid, repeated, out of range or coincident keyframes are rejected", () => {
|
||||||
|
const m = presetDirectorMotion(pose(), "pull");
|
||||||
|
const first = m.keyframes[0];
|
||||||
|
m.keyframes.push(
|
||||||
|
{ ...first, id: "duplicate-time" },
|
||||||
|
{ ...first, id: "nan", time: NaN },
|
||||||
|
{ ...first, id: "out", time: 16 },
|
||||||
|
{ ...first, id: "broken", time: 2, position: [Infinity, 0, 1] },
|
||||||
|
{ ...first, id: "coincident", time: 3, position: [...first.target] },
|
||||||
|
);
|
||||||
|
const read = readDirectorMotion(m);
|
||||||
|
expect(read.keyframes).toHaveLength(2);
|
||||||
|
expect(read.keyframes[0].id).toBe(first.id);
|
||||||
|
});
|
||||||
|
test("all motion presets remain finite and differ from their initial camera", () => {
|
||||||
|
for (const kind of ["push", "pull", "orbit", "pan"] as const) {
|
||||||
|
const m = presetDirectorMotion(pose(), kind, 5);
|
||||||
|
for (let t = 0; t <= 5; t += 0.025)
|
||||||
|
expect(validDirectorPose(sampleDirectorMotion(m, t)!)).toBe(true);
|
||||||
|
expect(sampleDirectorMotion(m, 5)?.position).not.toEqual(
|
||||||
|
sampleDirectorMotion(m, 0)?.position,
|
||||||
|
);
|
||||||
|
expect(directorMotionPrompt(m)).toContain("5 秒");
|
||||||
|
expect(directorMotionPrompt(m)).toContain("5.00秒");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("look-at remains defined when opposing keyframes cross their target", () => {
|
||||||
|
const m = presetDirectorMotion(pose(), "push");
|
||||||
|
m.easing = "linear";
|
||||||
|
m.keyframes[0].position = [-1, 0, 0];
|
||||||
|
m.keyframes[0].target = [0, 0, 0];
|
||||||
|
m.keyframes[1].position = [1, 0, 0];
|
||||||
|
m.keyframes[1].target = [0, 0, 0];
|
||||||
|
expect(validDirectorPose(sampleDirectorMotion(m, 2.5)!)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
import { recordDirectorVideo } from "@/lib/video-canvas/director-recording";
|
||||||
|
test("recording stops tracks and rejects partial exports on cancel or background switch", async () => {
|
||||||
|
for (const outcome of [
|
||||||
|
"complete",
|
||||||
|
"cancel",
|
||||||
|
"hidden",
|
||||||
|
"render-error",
|
||||||
|
] as const) {
|
||||||
|
const saved = new Map<string, PropertyDescriptor | undefined>();
|
||||||
|
const install = (key: string, value: unknown) => {
|
||||||
|
saved.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||||
|
Object.defineProperty(globalThis, key, {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const doc = new EventTarget() as EventTarget & { hidden: boolean };
|
||||||
|
doc.hidden = false;
|
||||||
|
const callbacks = new Map<number, FrameRequestCallback>();
|
||||||
|
let id = 0,
|
||||||
|
stopped = 0;
|
||||||
|
class Recorder {
|
||||||
|
static isTypeSupported(type: string) {
|
||||||
|
return type === "video/mp4";
|
||||||
|
}
|
||||||
|
state = "inactive";
|
||||||
|
mimeType = "video/mp4";
|
||||||
|
onstop?: () => void;
|
||||||
|
ondataavailable?: (e: { data: Blob }) => void;
|
||||||
|
start() {
|
||||||
|
this.state = "recording";
|
||||||
|
}
|
||||||
|
stop() {
|
||||||
|
this.state = "inactive";
|
||||||
|
this.ondataavailable?.({ data: new Blob(["recorded"]) });
|
||||||
|
queueMicrotask(() => this.onstop?.());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
install("document", doc);
|
||||||
|
install("MediaRecorder", Recorder);
|
||||||
|
install("requestAnimationFrame", (fn: FrameRequestCallback) => {
|
||||||
|
callbacks.set(++id, fn);
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
install("cancelAnimationFrame", (n: number) => callbacks.delete(n));
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const rendered: number[] = [];
|
||||||
|
const canvas = {
|
||||||
|
captureStream: () => ({ getTracks: () => [{ stop: () => stopped++ }] }),
|
||||||
|
} as unknown as HTMLCanvasElement;
|
||||||
|
const result = recordDirectorVideo({
|
||||||
|
canvas,
|
||||||
|
duration: 2,
|
||||||
|
signal: controller.signal,
|
||||||
|
render: (t) => {
|
||||||
|
if (outcome === "render-error" && t > 0) throw Error("render fault");
|
||||||
|
rendered.push(t);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const step = (time: number) => {
|
||||||
|
const pending = [...callbacks.values()];
|
||||||
|
callbacks.clear();
|
||||||
|
pending.forEach((fn) => fn(time));
|
||||||
|
};
|
||||||
|
if (outcome === "complete") {
|
||||||
|
step(1000);
|
||||||
|
step(3000);
|
||||||
|
expect((await result).type).toBe("video/mp4");
|
||||||
|
expect(rendered.at(-1)).toBe(2);
|
||||||
|
} else {
|
||||||
|
const rejected = expect(result).rejects.toThrow();
|
||||||
|
if (outcome === "cancel") controller.abort();
|
||||||
|
else if (outcome === "hidden") {
|
||||||
|
doc.hidden = true;
|
||||||
|
doc.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
} else {
|
||||||
|
step(1000);
|
||||||
|
step(3000);
|
||||||
|
}
|
||||||
|
await rejected;
|
||||||
|
}
|
||||||
|
expect(stopped).toBe(1);
|
||||||
|
expect(callbacks.size).toBe(0);
|
||||||
|
} finally {
|
||||||
|
for (const [key, descriptor] of saved) {
|
||||||
|
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||||
|
else Reflect.deleteProperty(globalThis, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("AI motion prompt describes the movement and stays bounded for large timelines", () => {
|
||||||
|
expect(directorMotionPrompt(presetDirectorMotion(pose(), "push"))).toContain(
|
||||||
|
"推进",
|
||||||
|
);
|
||||||
|
expect(directorMotionPrompt(presetDirectorMotion(pose(), "pull"))).toContain(
|
||||||
|
"拉远",
|
||||||
|
);
|
||||||
|
expect(directorMotionPrompt(presetDirectorMotion(pose(), "orbit"))).toContain(
|
||||||
|
"围绕",
|
||||||
|
);
|
||||||
|
expect(directorMotionPrompt(presetDirectorMotion(pose(), "pan"))).toContain(
|
||||||
|
"平移",
|
||||||
|
);
|
||||||
|
const m = presetDirectorMotion(pose(), "orbit", 15);
|
||||||
|
m.keyframes = Array.from({ length: 100 }, (_, i) => ({
|
||||||
|
id: String(i),
|
||||||
|
time: (i / 99) * 15,
|
||||||
|
...sampleDirectorMotion(m, (i / 99) * 15)!,
|
||||||
|
}));
|
||||||
|
expect(directorMotionPrompt(m).length).toBeLessThan(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
import { mergeDirectorCameraPose } from "@/lib/video-canvas/director-motion";
|
||||||
|
test("editing a keyframe cannot replace the active camera identity", () => {
|
||||||
|
const c = defaultDirectorScene().cameras[0];
|
||||||
|
const key = presetDirectorMotion(pose(), "push").keyframes[1];
|
||||||
|
const edited = mergeDirectorCameraPose(c, key);
|
||||||
|
expect(edited.id).toBe(c.id);
|
||||||
|
expect(edited.name).toBe(c.name);
|
||||||
|
expect(edited.position).toEqual(key.position);
|
||||||
|
expect(mergeDirectorCameraPose(edited, { fov: 65 }).fov).toBe(65);
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user