"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; 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(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(null); const timelineDrawer = useRef(null); const reconstructionAnchor = useRef(null); const [previewTime, setPreviewState] = useState(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(null); const recording = useRef(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(null); const fileInput = useRef(null); const camera = resolveDirectorCamera(scene); const angles = cameraAngles(camera); const object = scene.objects.find((o) => o.id === selected); const patchObject = (id: string, patch: Partial) => { 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) => 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 (
e.stopPropagation()} >
3D导演台
patchCamera({ position, target })} />
{(["translate", "rotate", "scale"] as const).map((m, i) => ( ))}
{onReconstruct ? (
{ 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)} }}/> setScenePrompt(event.target.value)}/>
) : null}
相机时间轴与运镜 · {camera.name}(独立轨道) !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(); }} /> { setExternalSeek({ time }); setPreviewTime(time); }} onEdit={() => { commit({ ...current.current, objects: sampleDirectorObjects(current.current, timelineTime), }); setPreviewTime(null); setExternalSeek({ time: timelineTime, editing: true }); }} /> {onReconstruct ? (
) : null}
{ 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); } }} /> { 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); } }} />
); }