1032 lines
42 KiB
TypeScript
1032 lines
42 KiB
TypeScript
"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>
|
||
);
|
||
}
|