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

1032 lines
42 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 {
commitDirectorScene,
resolveDirectorCamera,
cameraAngles,
rotateDirectorCamera,
directorBodies,
createDirectorCrowd,
} from "@/lib/video-canvas/director-extensions";
import { directorPoses, type DirectorPoseName } from "@/lib/video-canvas/director-poses";
import { useEffect, useRef, useState } from "react";
import { ArrowUp, Camera, Plus, Upload, X, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { DirectorViewport, type DirectorViewportHandle } from "./DirectorViewport";
import {
readDirectorScene,
directorRatios,
directorImageSize,
type DirectorScene,
type DirectorSnapshot,
type DirectorObject,
type DirectorVector,
} from "@/lib/video-canvas/director-scene";
import { DirectorObjectTimeline } from "./DirectorObjectTimeline";
import {
DirectorReconstructionPanel,
type ReconstructionTask,
} from "./DirectorReconstructionPanel";
import {
recordObjectFrames,
sampleDirectorObjects,
} from "@/lib/video-canvas/director-object-tracks";
import { DirectorMotionPanel } from "./DirectorMotionPanel";
import {
sampleDirectorMotion,
mergeDirectorCameraPose,
type DirectorMotion,
} from "@/lib/video-canvas/director-motion";
async function upload(file: File) {
const form = new FormData();
form.append("file", file);
form.append(
"purpose",
file.type.startsWith("video/") ? "canvas-video-process" : "director-studio",
);
const response = await fetch(
/\.glb$/i.test(file.name) ? "/api/canvas/director/upload-model" : "/api/files/upload",
{
method: "POST",
body: form,
signal: AbortSignal.timeout(120000),
},
);
const result = await response.json();
const url = result.url || result.data?.url;
if (!response.ok || !url) throw new Error(result.error || "上传失败");
return String(url);
}
export function DirectorStudioDialog({
initialScene,
onChange,
onClose,
onSend,
onSendMotion,
onSendVideo,
onReconstruct,
reconstructionTask,
}: {
onReconstruct?: (input: { imageUrl: string; prompt: string }) => Promise<unknown>;
reconstructionTask?: ReconstructionTask;
initialScene: unknown;
onChange: (scene: DirectorScene) => void;
onClose: () => 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 current = useRef(scene);
current.current = scene;
const commit = (input: DirectorScene) => {
const next = commitDirectorScene(current.current, input);
if (next.activeCameraId !== current.current.activeCameraId) setPreviewTime(null);
current.current = next;
setScene(next);
onChange(next);
};
const [objectSearch, setObjectSearch] = useState("");
const [scenePrompt, setScenePrompt] = useState("");
const [sceneReferenceUrl, setSceneReferenceUrl] = useState("");
const [scenePromptBusy, setScenePromptBusy] = useState(false);
const sceneReferenceInput = useRef<HTMLInputElement>(null);
const [selected, setSelected] = useState("");
const [rightTab, setRightTab] = useState<"properties" | "pose">("properties");
const [mode, setMode] = useState<"translate" | "rotate" | "scale">("translate");
const [cameraView, setCameraView] = useState(false);
const [busy, setBusy] = useState(false);
const viewport = useRef<DirectorViewportHandle>(null);
const timelineDrawer = useRef<HTMLDetailsElement>(null);
const reconstructionAnchor = useRef<HTMLDivElement>(null);
const [previewTime, setPreviewState] = useState<number | null>(null);
const [timelineTime, setTimelineTime] = useState(0);
const [externalSeek, setExternalSeek] = useState<{ time: number; editing?: boolean }>();
const [autoObjectFrame, setAutoObjectFrame] = useState(false);
const setPreviewTime = (time: number | null) => {
setPreviewState(time);
if (time !== null) setTimelineTime(time);
};
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,
keyframes: current.current.motion.keyframes.map((k) => ({
...k,
...resolveDirectorCamera(current.current, k),
id: k.id,
time: k.time,
})),
};
const pose = sampleDirectorMotion(motion, 0);
if (!pose || motion.keyframes.length < 2) throw new Error("至少需要两个关键帧");
const image = await viewport.current!.capture(pose, 0);
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 backgroundInput = useRef<HTMLInputElement>(null);
const fileInput = useRef<HTMLInputElement>(null);
const camera = resolveDirectorCamera(scene);
const angles = cameraAngles(camera);
const object = scene.objects.find((o) => o.id === selected);
const patchObject = (id: string, patch: Partial<DirectorObject>) => {
let next = {
...current.current,
objects: current.current.objects.map((o) => (o.id === id ? { ...o, ...patch } : o)),
};
try {
if (
autoObjectFrame &&
previewTime === null &&
["position", "rotation", "scale", "visible"].some((key) => Object.hasOwn(patch, key))
)
next = recordObjectFrames(next, [id], timelineTime, () => crypto.randomUUID());
commit(next);
} catch (error) {
toast.error((error as Error).message);
}
};
const patchCamera = (patch: Partial<typeof camera>) =>
commit({
...current.current,
cameras: current.current.cameras.map((c) =>
c.id === current.current.activeCameraId
? {
...mergeDirectorCameraPose(c, patch),
...(Object.hasOwn(patch, "followObjectId")
? { followObjectId: patch.followObjectId }
: {}),
}
: c,
),
});
const addObject = (kind: DirectorObject["kind"], name: string, url?: string) => {
const id = crypto.randomUUID();
commit({
...current.current,
objects: [
...current.current.objects,
{
id,
kind,
name,
url,
position: [current.current.objects.length * 1.2, 0.5, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
],
});
setSelected(id);
};
const screenshot = async () => {
if (busy) return;
setBusy(true);
try {
const image = await viewport.current!.capture();
const url = await upload(
new File([image.blob], `director-${Date.now()}.png`, {
type: "image/png",
}),
);
const active = current.current.cameras.find((c) => c.id === current.current.activeCameraId)!;
const snapshot = {
id: crypto.randomUUID(),
title: `${active.name}-截图${current.current.snapshots.length + 1}`,
url,
width: image.width,
height: image.height,
cameraId: active.id,
};
commit({
...current.current,
snapshots: [...current.current.snapshots, snapshot],
});
toast.success("真实机位截图已保存");
} catch (e) {
toast.error(e instanceof Error ? e.message : "截图失败");
} finally {
setBusy(false);
}
};
const field = "w-full rounded bg-white/10 px-2 py-1.5 text-sm outline-none";
const button =
"whitespace-nowrap rounded-lg border border-white/10 px-2 py-2 text-sm hover:bg-white/10 disabled:opacity-40";
return (
<div
role="dialog"
aria-label="3D导演台"
data-canvas-overlay="true"
className="fixed inset-0 z-[600] flex flex-col bg-[#141414] text-white"
onPointerDown={(e) => e.stopPropagation()}
>
<header className="flex h-11 shrink-0 items-center justify-between border-b border-white/10 px-4">
<strong className="text-[13px]">3D导演台</strong>
<div className="flex gap-2">
<button
className={button}
aria-pressed={!cameraView}
disabled={busy}
onClick={() => setCameraView(false)}
>
导演视角
</button>
<button
className={button}
aria-pressed={cameraView}
disabled={busy}
onClick={() => setCameraView(true)}
>
机位视角
</button>
</div>
<button
aria-label="关闭导演台"
className={button}
onClick={() => {
recording.current?.abort();
onClose();
}}
>
<X size={18} />
</button>
</header>
<div className="grid min-h-0 flex-1 grid-cols-[248px_minmax(0,1fr)_248px]">
<aside inert={busy} className="overflow-y-auto border-r border-white/10 bg-[#1c1c1c] p-3">
<div className="mb-3 text-sm text-white/70">场景</div>
<input
aria-label="搜索场景对象"
className={`${field} mb-3`}
placeholder="搜索场景对象"
value={objectSearch}
onChange={(e) => setObjectSearch(e.target.value)}
/>
<div className="space-y-1">
<button className={`${button} w-full text-left ${!selected?"bg-white/10":""}`} onClick={()=>setSelected("")} type="button">▤ 场景</button>
{scene.cameras.map((cameraItem) => (
<button className={`${button} w-full text-left ${scene.activeCameraId===cameraItem.id?"bg-white/10":""}`} key={cameraItem.id}
onClick={()=>{commit({...current.current,activeCameraId:cameraItem.id});setCameraView(true)}} type="button">◧ {cameraItem.name}</button>
))}
{scene.objects
.filter((o) => o.name.toLowerCase().includes(objectSearch.trim().toLowerCase()))
.map((o) => (
<button
className={`${button} w-full text-left ${selected === o.id ? "bg-white/15" : ""}`}
key={o.id}
onClick={() => setSelected(o.id)}
>
{o.name}
</button>
))}
</div>
<details className="mt-5 border-t border-white/10 pt-2">
<summary className="cursor-pointer text-xs text-white/55">添加与场景资源</summary>
<div className="mt-3 flex flex-col gap-2">
<button className={button} onClick={() => addObject("actor", "基础人偶")}>
添加人偶
</button>
<button className={button} onClick={() => addObject("box", "立方体")}>
添加立方体
</button>
<button className={button} onClick={() => addObject("sphere", "球体")}>
添加球体
</button>
<button className={button} disabled={busy} onClick={() => fileInput.current?.click()}>
<Upload size={14} className="mr-2 inline" />
上传 GLB 模型
</button>
<button
className={button}
disabled={busy}
onClick={() => backgroundInput.current?.click()}
>
上传 360° 全景背景
</button>
<label className="block text-xs">
全景背景历史
<select
aria-label="全景背景历史"
className={`${field} mt-1`}
value={scene.backgroundUrl || ""}
onChange={(e) =>
commit({ ...current.current, backgroundUrl: e.target.value || undefined })
}
>
<option value="">默认背景</option>
{scene.backgroundHistory?.map((url, index) => (
<option key={url} value={url}>
全景背景 {index + 1}
</option>
))}
</select>
</label>
<button
className={button}
disabled={!onReconstruct || busy}
onClick={() => {
if (timelineDrawer.current) timelineDrawer.current.open = true;
reconstructionAnchor.current?.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}}
>
AI 识图重建布景
</button>
</div>
</details>
<p className="mt-4 text-xs leading-5 text-white/45">
左键旋转视角,右键平移,滚轮缩放。选择对象后使用变换轴调整。
</p>
</aside>
<main
inert={busy}
className="relative flex min-h-0 items-center justify-center overflow-hidden bg-[#080808]"
>
<div
className="h-full max-h-full w-full"
style={{ aspectRatio: scene.ratio.replace(":", " / ") }}
>
<DirectorViewport
ref={viewport}
scene={scene}
selected={selected}
mode={mode}
cameraView={cameraView}
previewTime={previewTime}
locked={busy}
onTransform={patchObject}
onCamera={(position, target) => patchCamera({ position, target })}
/>
</div>
<div className="absolute bottom-4 left-[39%] flex -translate-x-1/2 gap-1 rounded-xl border border-white/10 bg-[#242424] p-2">
{(["translate", "rotate", "scale"] as const).map((m, i) => (
<button
key={m}
className={`${button} ${mode === m ? "bg-white/20" : ""}`}
onClick={() => setMode(m)}
>
{["移动", "旋转", "缩放"][i]}
</button>
))}
<button className={button} disabled={busy} onClick={screenshot}>
<Camera className="mr-1 inline" size={16} />
{busy ? "处理中..." : "截图"}
</button>
</div>
{onReconstruct ? (
<div className="absolute bottom-4 left-[65%] flex h-[42px] w-[min(360px,42%)] -translate-x-1/2 items-center gap-2 rounded-full border border-white/10 bg-[#242424] px-2 text-white shadow-lg">
<button aria-label="上传场景参考图" className="flex size-8 shrink-0 items-center justify-center rounded-full hover:bg-white/10" disabled={scenePromptBusy} onClick={()=>sceneReferenceInput.current?.click()} type="button"><Plus size={16}/></button>
<input ref={sceneReferenceInput} hidden type="file" accept="image/png,image/jpeg,image/webp" onChange={async event=>{
const file=event.target.files?.[0];event.target.value="";if(!file)return;
if(file.size>20*1024*1024){toast.error("参考图需小于 20 MiB");return}
setScenePromptBusy(true);try{setSceneReferenceUrl(await upload(file));toast.success("已添加场景参考图")}catch(cause){toast.error(cause instanceof Error?cause.message:"参考图上传失败")}finally{setScenePromptBusy(false)}
}}/>
<input aria-label="描述想搭建的场景" className="min-w-0 flex-1 bg-transparent text-[12px] text-white outline-none placeholder:text-white/35" placeholder={sceneReferenceUrl?"描述想搭建的场景 · 已添加参考图":"描述想搭建的场景"} value={scenePrompt} onChange={event=>setScenePrompt(event.target.value)}/>
<button aria-label="创建场景" className="flex size-8 shrink-0 items-center justify-center rounded-full bg-white/12 disabled:opacity-35" disabled={!sceneReferenceUrl||scenePromptBusy||busy} onClick={async()=>{
if(!sceneReferenceUrl)return;setScenePromptBusy(true);try{await onReconstruct({imageUrl:sceneReferenceUrl,prompt:scenePrompt});timelineDrawer.current!.open=true;toast.success("重建任务已提交")}catch(cause){toast.error(cause instanceof Error?cause.message:"重建提交失败")}finally{setScenePromptBusy(false)}
}} type="button"><ArrowUp size={16}/></button>
</div>
) : null}
</main>
<aside className="space-y-4 overflow-y-auto border-l border-white/10 bg-[#222] p-3">
{!object ? (
<div className="space-y-4 text-[12px]">
<div className="border-b border-white/10 pb-2 text-[14px] font-medium">3D场景</div>
<label className="flex items-center justify-between gap-2">天空颜色
<input aria-label="天空颜色" type="color" value={scene.skyColor || "#080808"} onChange={event=>commit({...current.current,skyColor:event.target.value})}/>
</label>
<label className="flex items-center justify-between gap-2">网格吸附
<input aria-label="显示场景网格" type="checkbox" checked={scene.gridVisible !== false} onChange={event=>commit({...current.current,gridVisible:event.target.checked})}/>
</label>
<label className="flex items-center justify-between gap-2">地面
<input aria-label="显示场景地面" type="checkbox" checked={scene.groundVisible !== false} onChange={event=>commit({...current.current,groundVisible:event.target.checked})}/>
</label>
</div>
) : (
<div className="space-y-3 text-[12px]">
<div className="border-b border-white/10 pb-2 text-[14px] font-medium">{object.kind==="actor"?"角色":"场景对象"}</div>
<div className="flex gap-2 border-b border-white/10 pb-2">
<button className={rightTab==="properties"?"rounded bg-white/10 px-3 py-1":"px-3 py-1 text-white/45"} onClick={()=>setRightTab("properties")} type="button">属性</button>
<button className={rightTab==="pose"?"rounded bg-white/10 px-3 py-1":"px-3 py-1 text-white/45"} onClick={()=>setRightTab("pose")} type="button">姿势</button>
</div>
{rightTab==="properties" ? <>
<label className="block text-white/55">名称<input aria-label="对象名称" className={`${field} mt-1`} value={object.name} onChange={event=>patchObject(object.id,{name:event.target.value})}/></label>
{(["position","rotation","scale"] as const).map(key=>(
<div key={key} className="text-white/55">
<span>{key==="position"?"位置":key==="rotation"?"旋转":"缩放"}</span>
<div className="mt-1 grid grid-cols-3 gap-1">
{object[key].map((value,index)=>(<label className="flex items-center rounded bg-[#353535] px-1" key={index}>
<span className="text-[10px] text-white/40">{"XYZ"[index]}</span>
<input aria-label={`${key}-${"XYZ"[index]}`} className="w-full min-w-0 bg-transparent px-1 py-1 text-[11px] text-white outline-none" type="number" step="0.1"
value={Number((key==="rotation"?value*180/Math.PI:value).toFixed(2))}
onChange={event=>{const next=Number(event.target.value);if(!Number.isFinite(next)||(key==="scale"&&next<=0))return;const values=[...object[key]] as DirectorVector;values[index]=key==="rotation"?next*Math.PI/180:next;patchObject(object.id,{[key]:values})}}/>
</label>))}
</div>
</div>
))}
</> : <select aria-label="角色姿势" className={field} value={object.pose||""} onChange={event=>patchObject(object.id,{pose:event.target.value as DirectorPoseName})}>
<option value="">默认姿势</option>{Object.keys(directorPoses).map(pose=><option key={pose} value={pose}>{pose}</option>)}
</select>}
</div>
)}
<details className="border-t border-white/10 pt-2"><summary className="cursor-pointer text-xs text-white/55">相机与高级属性</summary>
<fieldset disabled={busy || previewTime !== null} className="mt-3 space-y-4">
<label className="block text-sm">
机位
<select
aria-label="机位"
className={`${field} mt-2`}
value={scene.activeCameraId}
onChange={(e) => commit({ ...current.current, activeCameraId: e.target.value })}
>
{scene.cameras.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<div className="flex flex-wrap gap-1">
{[
["正面", [0, 2, 7]],
["侧面", [7, 2, 0]],
["俯视", [0, 8, 0.1]],
["正面特写", [0, 1.7, 2]],
["正面全景", [0, 2, 10]],
["侧面近景", [2, 1.5, 0]],
["背面", [0, 2, -7]],
["45°俯拍", [4, 6, 4]],
["低机位", [0, 0.3, 4]],
["左过肩", [-1, 1.8, -2]],
["右过肩", [1, 1.8, -2]],
["鸟瞰", [0, 14, 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" />
{String(name)}
</button>
))}
</div>
<label className="block text-sm">
跟随对象
<select
aria-label="相机跟随对象"
className={`${field} mt-2`}
value={camera.followObjectId || ""}
onChange={(e) =>
patchCamera({
followObjectId: e.target.value || undefined,
target: camera.target,
})
}
>
<option value="">不跟随</option>
{scene.objects.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
</label>
<div className="grid grid-cols-2 gap-2">
{(["yaw", "pitch"] as const).map((axis) => (
<label key={axis} className="text-xs">
{axis === "yaw" ? "水平旋转(度)" : "俯仰旋转(度)"}
<input
aria-label={`相机${axis}`}
className={field}
type="number"
step="1"
min={axis === "yaw" ? -180 : -89}
max={axis === "yaw" ? 180 : 89}
value={Number(angles[axis].toFixed(1))}
onChange={(e) => {
const n = Number(e.target.value);
if (Number.isFinite(n))
patchCamera({
target: rotateDirectorCamera(
camera,
axis === "yaw" ? n : angles.yaw,
axis === "pitch" ? n : angles.pitch,
),
followObjectId: undefined,
});
}}
/>
</label>
))}
</div>
<label className="block text-sm">
视角 FOV
<input
aria-label="视角 FOV"
className={`${field} mt-2`}
type="number"
min={15}
max={90}
value={Number(camera.fov.toFixed(2))}
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">
<input
aria-label="对象名称"
className={field}
value={object.name}
maxLength={80}
onChange={(e) => patchObject(object.id, { name: e.target.value })}
/>
<button
aria-label="删除所选场景对象"
onClick={() => {
commit({
...current.current,
objects: current.current.objects.filter((o) => o.id !== object.id),
});
setSelected("");
}}
>
<Trash2 size={14} />
</button>
</div>
<div className="flex flex-wrap gap-3 text-xs">
<label>
<input
type="checkbox"
checked={object.visible !== false}
onChange={(e) => patchObject(object.id, { visible: e.target.checked })}
/>{" "}
显示
</label>
<label>
<input
type="checkbox"
checked={object.locked === true}
onChange={(e) => patchObject(object.id, { locked: e.target.checked })}
/>{" "}
锁定变换轴
</label>
{object.kind !== "model" && (
<label>
颜色{" "}
<input
aria-label="对象颜色"
type="color"
value={object.color || (object.kind === "actor" ? "#687bb0" : "#c3664c")}
onChange={(e) => patchObject(object.id, { color: e.target.value })}
/>
</label>
)}
</div>
{object.kind === "actor" && (
<label className="block text-xs">
人偶体型
<select
aria-label="人偶体型"
className={`${field} mt-1`}
value={object.body || "标准"}
onChange={(e) =>
patchObject(object.id, {
body: e.target.value as keyof typeof directorBodies,
})
}
>
{Object.keys(directorBodies).map((name) => (
<option key={name}>{name}</option>
))}
</select>
</label>
)}
{object.kind === "actor" && (
<label className="block text-xs">
人偶姿势
<select
aria-label="人偶姿势"
className={`${field} mt-1`}
value={object.pose || "站立"}
onChange={(e) =>
patchObject(object.id, { pose: e.target.value as DirectorPoseName })
}
>
{Object.keys(directorPoses).map((name) => (
<option key={name}>{name}</option>
))}
</select>
</label>
)}
<button
className={button}
onClick={() => {
const copy = {
...object,
id: crypto.randomUUID(),
name: `${object.name} 副本`,
position: [
object.position[0] + 1,
...object.position.slice(1),
] as DirectorVector,
};
commit({ ...current.current, objects: [...current.current.objects, copy] });
setSelected(copy.id);
}}
>
复制对象
</button>
<button
className={button}
onClick={() => {
try {
const crowd = createDirectorCrowd(
object,
current.current.objects.length,
() => crypto.randomUUID(),
);
commit({
...current.current,
objects: [...current.current.objects, ...crowd],
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "阵列创建失败");
}
}}
>
创建 3×3 阵列
</button>
<button
className={button}
onClick={() =>
patchCamera({
target: [object.position[0], object.position[1] + 1, object.position[2]],
})
}
>
相机对准所选对象
</button>
<label className="block text-xs">
等比缩放
<input
aria-label="等比缩放"
className={`${field} mt-1`}
type="number"
step="0.1"
min="0.01"
value={Number(object.scale[0].toFixed(2))}
onChange={(e) => {
const n = Number(e.target.value);
if (n > 0 && Number.isFinite(n))
patchObject(object.id, {
scale: object.scale.map(
(v) => (v * n) / object.scale[0],
) as DirectorVector,
});
}}
/>
</label>
{(["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((key === "rotation" ? (v * 180) / Math.PI : 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] = key === "rotation" ? (n * Math.PI) / 180 : n;
patchObject(object.id, { [key]: values });
}}
/>
))}
</div>
</div>
))}
</section>
) : null}
</fieldset>
</details>
{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>
) : null}
<section className="space-y-2 border-t border-white/10 pt-3">
<div className="text-sm">机位截图({scene.snapshots.length})</div>
{scene.snapshots.map((s) => (
<div key={s.id} className="rounded-lg border border-white/10 p-2">
<img src={s.url} alt={s.title} className="w-full rounded" />
<div className="mt-1 truncate text-xs">
{s.title} · {s.width}×{s.height}
</div>
<button
className={`${button} mt-2 w-full`}
onClick={() => {
onSend([s]);
toast.success("截图已发送到画布");
}}
>
发送到画布
</button>
</div>
))}
</section>
</aside>
</div>
<details
ref={timelineDrawer}
className="max-h-[32vh] shrink-0 overflow-auto border-t border-white/15 bg-[#1c1c1c] px-4 pb-3"
>
<summary className="cursor-pointer py-2 text-sm">
相机时间轴与运镜 · {camera.name}(独立轨道)
</summary>
<DirectorMotionPanel
key={scene.activeCameraId}
compact
hasObjectMotion={Object.values(scene.objectTracks || {}).some(
(t) => !t.muted && t.keyframes.length >= 2,
)}
externalSeek={externalSeek}
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();
}}
/>
<DirectorObjectTimeline
scene={scene}
time={timelineTime}
selected={selected}
disabled={busy}
preview={previewTime !== null}
auto={autoObjectFrame}
onAuto={setAutoObjectFrame}
onChange={commit}
onSelect={setSelected}
onSeek={(time) => {
setExternalSeek({ time });
setPreviewTime(time);
}}
onEdit={() => {
commit({
...current.current,
objects: sampleDirectorObjects(current.current, timelineTime),
});
setPreviewTime(null);
setExternalSeek({ time: timelineTime, editing: true });
}}
/>
{onReconstruct ? (
<div ref={reconstructionAnchor}>
<DirectorReconstructionPanel
scene={scene}
onChange={commit}
onSubmit={onReconstruct}
task={reconstructionTask}
disabled={busy}
/>
</div>
) : null}
</details>
<input
ref={backgroundInput}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
if (file.size > 20 * 1024 * 1024) {
toast.error("全景背景需小于 20 MiB");
return;
}
setBusy(true);
try {
const bitmap = await createImageBitmap(file);
const ratio = bitmap.width / bitmap.height;
bitmap.close();
if (Math.abs(ratio - 2) > 0.02) throw new Error("请上传 2:1 比例的等距柱状全景图");
const url = await upload(file);
commit({
...current.current,
backgroundUrl: url,
backgroundHistory: [
...new Set([...(current.current.backgroundHistory || []), url]),
].slice(-20),
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "全景背景上传失败");
} finally {
setBusy(false);
}
}}
/>
<input
ref={fileInput}
className="hidden"
type="file"
accept=".glb,model/gltf-binary"
onChange={async (e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
if (!/\.glb$/i.test(file.name) || file.size > 25 * 1024 * 1024) {
toast.error("请上传 25 MiB 以内的 GLB 模型");
return;
}
setBusy(true);
try {
const header = new DataView(await file.slice(0, 12).arrayBuffer());
if (header.byteLength < 12 || header.getUint32(0, true) !== 0x46546c67)
throw new Error("文件不是有效 GLB 模型");
const url = await upload(file);
addObject("model", file.name, url);
} catch (e) {
toast.error(e instanceof Error ? e.message : "模型上传失败");
} finally {
setBusy(false);
}
}}
/>
</div>
);
}