"use client"; import { useRef, useState } from "react"; import { Camera, Plus, Upload, X, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { DirectorViewport, type DirectorViewportHandle, } from "./DirectorViewport"; import { readDirectorScene, directorRatios, type DirectorScene, type DirectorSnapshot, type DirectorObject, type DirectorVector, } from "@/lib/video-canvas/director-scene"; async function upload(file: File) { const form = new FormData(); form.append("file", file); form.append("purpose", "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 Error(result.error || "上传失败"); return String(url); } export function DirectorStudioDialog({ initialScene, onChange, onClose, onSend, }: { initialScene: unknown; onChange: (scene: DirectorScene) => void; onClose: () => void; onSend: (snapshots: DirectorSnapshot[]) => void; }) { const [scene, setScene] = useState(() => readDirectorScene(initialScene)); const current = useRef(scene); current.current = scene; const commit = (next: DirectorScene) => { current.current = next; setScene(next); onChange(next); }; const [selected, setSelected] = useState(scene.objects[0]?.id || ""); const [mode, setMode] = useState<"translate" | "rotate" | "scale">( "translate", ); const [cameraView, setCameraView] = useState(false); const [busy, setBusy] = useState(false); const viewport = useRef(null); const fileInput = useRef(null); const camera = scene.cameras.find((c) => c.id === scene.activeCameraId) || scene.cameras[0]; const object = scene.objects.find((o) => o.id === selected); const patchObject = (id: string, patch: Partial) => commit({ ...current.current, objects: current.current.objects.map((o) => o.id === id ? { ...o, ...patch } : o, ), }); const patchCamera = (patch: Partial) => commit({ ...current.current, cameras: current.current.cameras.map((c) => c.id === current.current.activeCameraId ? { ...c, ...patch } : 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 = "rounded-lg border border-white/10 px-3 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) => ( ))}
{ 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 Error("文件不是有效 GLB 模型"); const url = await upload(file); addObject("model", file.name, url); } catch (e) { toast.error(e instanceof Error ? e.message : "模型上传失败"); } finally { setBusy(false); } }} />
); }