feat(platform): 开源发布 - 初始化 Open AI Canvas

This commit is contained in:
ddCat committed 2026-07-22 14:29:10 +08:00
commit aea35896eb
365 files changed
+66410

No files matched your search

@@ -0,0 +1,71 @@
import type { ReactNode } from "react";
import { Box, Camera, Clapperboard, Lightbulb, LockKeyhole, Move3d } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasNodeData } from "@/types/canvas";
import type { DirectorScene } from "@/types/director";
export function CanvasDirectorNodePanel({ node, scene, previewUrl, onOpen, professional = true }: { node: CanvasNodeData; scene: DirectorScene | null; previewUrl?: string; onOpen: () => void; professional?: boolean }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const shot = scene?.shots?.find((item) => item.id === node.metadata?.directorShotId) || scene?.shots?.[0];
return (
<div className="flex h-full w-full cursor-move flex-col p-3 pt-7" style={{ color: theme.node.text }}>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="grid size-7 shrink-0 place-items-center rounded-md" style={{ background: theme.toolbar.itemHover }}><Clapperboard className="size-3.5" /></span>
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{node.metadata?.workflowTitle || node.title}</div>
<div className="truncate text-[10px]" style={{ color: theme.node.muted }}>{shot?.name || "未设置镜头"}</div>
</div>
</div>
<span className="shrink-0 text-[10px] font-semibold" style={{ color: theme.accent.primary }}>3D</span>
</div>
<button
type="button"
data-canvas-no-zoom
className="group relative min-h-0 flex-1 cursor-pointer overflow-hidden rounded-lg border text-left focus-visible:outline-none focus-visible:ring-2 disabled:cursor-default"
style={{ background: scene?.background || theme.canvas.background, borderColor: theme.node.stroke }}
title={professional ? "打开 3D 导演台" : "切换到专业模式后编辑导演台"}
disabled={!professional}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => { event.stopPropagation(); onOpen(); }}
>
{previewUrl ? <img src={previewUrl} alt={`${node.title} 场景缩略图`} className="h-full w-full object-cover" draggable={false} /> : <SceneSchematic scene={scene} />}
<span className={`absolute inset-x-0 bottom-0 flex h-10 items-center justify-center gap-1.5 text-xs font-semibold backdrop-blur-sm transition-opacity ${professional ? "opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100" : "opacity-100"}`} style={{ background: `${theme.toolbar.panel}dd`, color: theme.node.text }}>{professional ? <><Move3d className="size-3.5" />进入导演台</> : <><LockKeyhole className="size-3.5" />专业模式可编辑</>}</span>
</button>
<div className="mt-2 grid grid-cols-3 gap-1 text-[10px]" style={{ color: theme.node.muted }}>
<Stat icon={<Box className="size-3" />} value={scene?.objects.length || 0} label="对象" />
<Stat icon={<Camera className="size-3" />} value={scene?.cameras.length || 0} label="机位" />
<Stat icon={<Lightbulb className="size-3" />} value={scene?.lights.length || 0} label="灯光" />
</div>
</div>
);
}
function SceneSchematic({ scene }: { scene: DirectorScene | null }) {
return (
<div className="relative h-full w-full overflow-hidden">
<div className="absolute inset-x-0 bottom-0 h-[55%] origin-bottom -skew-y-6 border-t border-white/25 bg-black/15" />
<div className="absolute inset-x-[8%] bottom-[18%] h-px bg-white/25" />
<div className="absolute inset-x-[16%] bottom-[32%] h-px bg-white/15" />
{(scene?.objects || []).slice(0, 6).map((object, index) => {
const left = 48 + object.transform.position[0] * 12;
const bottom = 25 + object.transform.position[2] * 7 + Math.max(0, object.transform.position[1]) * 4;
const height = object.primitive === "character" ? 42 : 22 + Math.min(18, object.transform.scale[1] * 8);
return <span key={object.id} className={`absolute border border-white/45 shadow-sm ${object.primitive === "sphere" ? "rounded-full" : "rounded-sm"}`} style={{ left: `${Math.max(12, Math.min(82, left + index * 2))}%`, bottom: `${Math.max(18, Math.min(58, bottom))}%`, width: object.primitive === "character" ? 13 : 22, height, background: object.color, transform: "translateX(-50%)" }} />;
})}
<Camera className="absolute bottom-[14%] left-[12%] size-5 text-white/75" />
<span className="absolute left-[18%] top-[18%] size-16 rounded-full bg-white/10 blur-xl" />
<span className="absolute inset-x-3 top-3 text-[10px] font-medium text-white/70">{scene ? "场景预览" : "正在准备场景"}</span>
</div>
);
}
function Stat({ icon, value, label }: { icon: ReactNode; value: number; label: string }) {
return <span className="inline-flex min-w-0 items-center justify-center gap-1 rounded-md py-1" title={`${value} 个${label}`}>{icon}<b>{value}</b>{label}</span>;
}
@@ -0,0 +1,307 @@
import { App, Button, ColorPicker, Input, InputNumber, Select, Slider, Switch } from "antd";
import { Box, BoxSelect, Camera, Circle, Cuboid, FileUp, Focus, Image as ImageIcon, LampDesk, Lightbulb, Move3D, Pause, Play, Plus, Redo2, Rotate3D, Save, Scaling, Trash2, Undo2, UserRound, Video, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from "react";
import { nanoid } from "nanoid";
import { DirectorViewport, type DirectorViewportHandle } from "@/components/canvas/director/director-viewport";
import { canvasThemes } from "@/lib/canvas-theme";
import { compileDirectorPrompt } from "@/lib/canvas/director/director-prompt-compiler";
import { createDirectorBillboard, createDirectorCamera, createDirectorLight, createDirectorModel, createDirectorObject, touchDirectorScene, upsertDirectorKeyframe } from "@/lib/canvas/director/director-scene";
import { uploadMediaFile } from "@/services/file-storage";
import { useAssetStore, type ModelAsset } from "@/stores/use-asset-store";
import { useDirectorWorkbenchStore } from "@/stores/canvas/use-director-workbench-store";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasNodeData } from "@/types/canvas";
import type { DirectorCamera, DirectorCameraMove, DirectorLight, DirectorObject, DirectorScene, DirectorSceneOutput, DirectorShot, DirectorShotSize, DirectorTransform, DirectorVec3 } from "@/types/director";
export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onChange, onApply }: { open: boolean; scene: DirectorScene | null; imageNodes: CanvasNodeData[]; onClose: () => void; onChange: (scene: DirectorScene) => void; onApply: (output: DirectorSceneOutput) => Promise<void> }) {
const { message } = App.useApp();
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const viewportRef = useRef<DirectorViewportHandle>(null);
const modelInputRef = useRef<HTMLInputElement>(null);
const [draft, setDraft] = useState<DirectorScene | null>(null);
const [history, setHistory] = useState<DirectorScene[]>([]);
const [future, setFuture] = useState<DirectorScene[]>([]);
const [saving, setSaving] = useState(false);
const selectedObjectId = useDirectorWorkbenchStore((state) => state.selectedObjectId);
const selectedLightId = useDirectorWorkbenchStore((state) => state.selectedLightId);
const transformMode = useDirectorWorkbenchStore((state) => state.transformMode);
const renderMode = useDirectorWorkbenchStore((state) => state.renderMode);
const playhead = useDirectorWorkbenchStore((state) => state.playhead);
const playing = useDirectorWorkbenchStore((state) => state.playing);
const setSelectedObjectId = useDirectorWorkbenchStore((state) => state.setSelectedObjectId);
const setSelectedLightId = useDirectorWorkbenchStore((state) => state.setSelectedLightId);
const setTransformMode = useDirectorWorkbenchStore((state) => state.setTransformMode);
const setRenderMode = useDirectorWorkbenchStore((state) => state.setRenderMode);
const setPlayhead = useDirectorWorkbenchStore((state) => state.setPlayhead);
const setPlaying = useDirectorWorkbenchStore((state) => state.setPlaying);
const resetWorkbench = useDirectorWorkbenchStore((state) => state.reset);
const assets = useAssetStore((state) => state.assets);
const addAsset = useAssetStore((state) => state.addAsset);
const modelAssets = useMemo(() => assets.filter((asset): asset is ModelAsset => asset.kind === "model"), [assets]);
useEffect(() => {
if (!open || !scene) return;
setDraft(structuredClone(scene));
setHistory([]);
setFuture([]);
resetWorkbench();
}, [open, resetWorkbench, scene]);
const activeShot = draft?.shots?.find((item) => item.id === draft.activeShotId) || draft?.shots?.[0] || null;
const activeCamera = draft?.cameras?.find((item) => item.id === activeShot?.cameraId) || draft?.cameras?.[0] || null;
const selectedObject = draft?.objects?.find((item) => item.id === selectedObjectId) || null;
const selectedLight = draft?.lights?.find((item) => item.id === selectedLightId) || null;
useEffect(() => {
if (!playing || !activeShot) return;
let frame = 0;
let last = performance.now();
const tick = (now: number) => {
const delta = (now - last) / 1000;
last = now;
const next = useDirectorWorkbenchStore.getState().playhead + delta;
setPlayhead(next >= activeShot.duration ? 0 : next);
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [activeShot, playing, setPlayhead]);
const commit = useCallback((updater: (current: DirectorScene) => DirectorScene) => {
setDraft((current) => {
if (!current) return current;
const next = touchDirectorScene(updater(current));
setHistory((items) => [...items.slice(-49), structuredClone(current)]);
setFuture([]);
return next;
});
}, []);
const replaceWithoutHistory = useCallback((updater: (current: DirectorScene) => DirectorScene) => setDraft((current) => (current ? touchDirectorScene(updater(current)) : current)), []);
const undo = () => {
const previous = history.at(-1);
if (!previous || !draft) return;
setHistory((items) => items.slice(0, -1));
setFuture((items) => [structuredClone(draft), ...items].slice(0, 50));
setDraft(previous);
};
const redo = () => {
const next = future[0];
if (!next || !draft) return;
setFuture((items) => items.slice(1));
setHistory((items) => [...items, structuredClone(draft)].slice(-50));
setDraft(next);
};
const updateObject = (id: string, patch: Partial<DirectorObject>) => commit((current) => ({ ...current, objects: current.objects.map((item) => (item.id === id ? { ...item, ...patch } : item)) }));
const updateLight = (id: string, patch: Partial<DirectorLight>) => commit((current) => ({ ...current, lights: current.lights.map((item) => (item.id === id ? { ...item, ...patch } : item)) }));
const updateShot = (id: string, patch: Partial<DirectorShot>) => commit((current) => ({ ...current, shots: current.shots.map((item) => (item.id === id ? { ...item, ...patch } : item)) }));
const addPrimitive = (primitive: DirectorObject["primitive"], name: string) => {
const object = createDirectorObject(primitive, name);
commit((current) => ({ ...current, objects: [...current.objects, object] }));
setSelectedObjectId(object.id);
};
const addModelAsset = (asset: ModelAsset) => {
const object = createDirectorModel({ name: asset.title, assetId: asset.id, storageKey: asset.data.storageKey, url: asset.data.url, mimeType: asset.data.mimeType });
commit((current) => ({ ...current, objects: [...current.objects, object] }));
setSelectedObjectId(object.id);
};
const uploadModel = async (file?: File) => {
if (!file || !/\.(glb|gltf)$/i.test(file.name)) return;
const uploaded = await uploadMediaFile(file, "model");
const assetId = addAsset({ kind: "model", title: file.name.replace(/\.(glb|gltf)$/i, ""), coverUrl: "", tags: ["3D模型"], source: "导演台", data: { url: uploaded.url, storageKey: uploaded.storageKey, bytes: uploaded.bytes, mimeType: uploaded.mimeType, fileName: file.name }, metadata: { source: "director" } });
const asset = useAssetStore.getState().assets.find((item): item is ModelAsset => item.id === assetId && item.kind === "model");
if (asset) addModelAsset(asset);
message.success("3D 模型已加入场景和素材库");
};
const addBillboard = (node: CanvasNodeData) => {
if (!node.metadata?.content) return;
const object = createDirectorBillboard(node.title, node.metadata.content, node.metadata.storageKey, node.id);
commit((current) => ({ ...current, objects: [...current.objects, object] }));
setSelectedObjectId(object.id);
};
const addCamera = () => {
const camera = createDirectorCamera(`摄影机 ${draft?.cameras.length ? draft.cameras.length + 1 : 1}`);
commit((current) => ({ ...current, cameras: [...current.cameras, camera] }));
if (activeShot) updateShot(activeShot.id, { cameraId: camera.id });
};
const addLight = () => {
const light = createDirectorLight("point", `灯光 ${draft?.lights.length ? draft.lights.length + 1 : 1}`, [2, 3, 2], 1.5);
commit((current) => ({ ...current, lights: [...current.lights, light] }));
setSelectedLightId(light.id);
};
const addShot = () => {
if (!activeCamera) return;
const shot: DirectorShot = { id: nanoid(), name: `镜头 ${(draft?.shots.length || 0) + 1}`, cameraId: activeCamera.id, duration: 5, shotSize: "medium", cameraMove: "static", prompt: "" };
commit((current) => ({ ...current, shots: [...current.shots, shot], activeShotId: shot.id }));
setPlayhead(0);
};
const addObjectKeyframe = () => {
if (!selectedObject) return;
updateObject(selectedObject.id, { keyframes: upsertDirectorKeyframe(selectedObject.keyframes, playhead, selectedObject.transform) });
};
const addCameraKeyframe = () => {
if (!activeCamera) return;
commit((current) => ({ ...current, cameras: current.cameras.map((item) => item.id === activeCamera.id ? { ...item, keyframes: upsertDirectorKeyframe(item.keyframes, playhead, item.transform) } : item) }));
};
const applyCameraMove = () => {
if (!activeCamera || !activeShot) return;
const start = activeCamera.transform;
const end = cameraMoveTransform(start, activeShot.cameraMove);
commit((current) => ({ ...current, cameras: current.cameras.map((item) => item.id === activeCamera.id ? { ...item, keyframes: [{ id: nanoid(), time: 0, transform: start }, { id: nanoid(), time: activeShot.duration, transform: end }] } : item) }));
message.success("已生成相机运动关键帧");
};
const alignCameraToView = () => {
if (!activeCamera) return;
const transform = viewportRef.current?.readCameraTransform();
if (!transform) return;
commit((current) => ({ ...current, cameras: current.cameras.map((item) => item.id === activeCamera.id ? { ...item, transform } : item) }));
message.success("摄影机已对齐当前视图");
};
const applyToCanvas = async () => {
if (!draft || !activeShot || !viewportRef.current) return;
setSaving(true);
try {
const beauty = await viewportRef.current.capture("beauty");
const depth = await viewportRef.current.capture("depth");
const normal = await viewportRef.current.capture("normal");
const prompt = compileDirectorPrompt(draft, activeShot);
const next = touchDirectorScene(draft);
setDraft(next);
onChange(next);
await onApply({ scene: next, shot: activeShot, prompt, beauty, depth, normal });
message.success("镜头已回写画布");
} catch (error) {
message.error(error instanceof Error ? error.message : "导演台输出失败");
} finally {
setSaving(false);
}
};
if (!open || !draft || !activeShot) return null;
return (
<div data-canvas-no-zoom className="fixed inset-0 z-[500] flex min-h-0 flex-col overflow-hidden" style={{ background: theme.canvas.background, color: theme.node.text }}>
<header className="flex h-12 shrink-0 items-center gap-2 border-b px-2" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border }}>
<IconButton label="关闭导演台" onClick={onClose}><X className="size-4" /></IconButton>
<Input variant="borderless" value={draft.title} className="max-w-56 font-medium" onChange={(event) => replaceWithoutHistory((current) => ({ ...current, title: event.target.value }))} />
<span className="h-5 w-px" style={{ background: theme.toolbar.border }} />
<IconButton label="撤销" disabled={!history.length} onClick={undo}><Undo2 className="size-4" /></IconButton>
<IconButton label="重做" disabled={!future.length} onClick={redo}><Redo2 className="size-4" /></IconButton>
<span className="h-5 w-px" style={{ background: theme.toolbar.border }} />
<ToolButton label="移动" active={transformMode === "translate"} onClick={() => setTransformMode("translate")}><Move3D className="size-4" /></ToolButton>
<ToolButton label="旋转" active={transformMode === "rotate"} onClick={() => setTransformMode("rotate")}><Rotate3D className="size-4" /></ToolButton>
<ToolButton label="缩放" active={transformMode === "scale"} onClick={() => setTransformMode("scale")}><Scaling className="size-4" /></ToolButton>
<div className="ml-auto flex items-center gap-1">
<Select size="small" value={renderMode} className="w-24" options={[{ label: "预览", value: "beauty" }, { label: "深度", value: "depth" }, { label: "法线", value: "normal" }]} onChange={setRenderMode} />
<Button size="small" type="primary" icon={<Save className="size-3.5" />} loading={saving} onClick={() => void applyToCanvas()}>应用到镜头</Button>
</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-[220px_minmax(0,1fr)_292px] max-lg:grid-cols-[180px_minmax(0,1fr)]">
<aside className="thin-scrollbar min-h-0 overflow-y-auto border-r" style={{ background: theme.node.panel, borderColor: theme.toolbar.border }}>
<PanelTitle title="场景对象" action={<IconButton label="添加立方体" onClick={() => addPrimitive("box", "立方体")}><Plus className="size-3.5" /></IconButton>} />
<div className="px-2 pb-2">
{draft.objects.map((object) => <SceneRow key={object.id} active={selectedObjectId === object.id} icon={object.primitive === "character" ? <UserRound /> : object.kind === "model" ? <BoxSelect /> : object.kind === "billboard" ? <ImageIcon /> : <Cuboid />} label={object.name} onClick={() => setSelectedObjectId(object.id)} />)}
</div>
<PanelTitle title="摄影机" action={<IconButton label="添加摄影机" onClick={addCamera}><Plus className="size-3.5" /></IconButton>} />
<div className="px-2 pb-2">{draft.cameras.map((camera) => <SceneRow key={camera.id} active={activeShot.cameraId === camera.id && !selectedObjectId && !selectedLightId} icon={<Camera />} label={camera.name} onClick={() => { setSelectedObjectId(null); setSelectedLightId(null); updateShot(activeShot.id, { cameraId: camera.id }); }} />)}</div>
<PanelTitle title="灯光" action={<IconButton label="添加灯光" onClick={addLight}><Plus className="size-3.5" /></IconButton>} />
<div className="px-2 pb-2">{draft.lights.map((light) => <SceneRow key={light.id} active={selectedLightId === light.id} icon={<Lightbulb />} label={light.name} onClick={() => setSelectedLightId(light.id)} />)}</div>
<PanelTitle title="快速添加" />
<div className="grid grid-cols-2 gap-1.5 px-2 pb-3">
<QuickAdd label="演员" icon={<UserRound />} onClick={() => addPrimitive("character", "演员")} />
<QuickAdd label="立方体" icon={<Box />} onClick={() => addPrimitive("box", "立方体")} />
<QuickAdd label="球体" icon={<Circle />} onClick={() => addPrimitive("sphere", "球体")} />
<QuickAdd label="圆柱" icon={<Cuboid />} onClick={() => addPrimitive("cylinder", "圆柱")} />
<QuickAdd label="上传模型" icon={<FileUp />} onClick={() => modelInputRef.current?.click()} />
<QuickAdd label="添加灯光" icon={<LampDesk />} onClick={addLight} />
</div>
{modelAssets.length ? <><PanelTitle title="3D 素材" /><div className="px-2 pb-3">{modelAssets.map((asset) => <SceneRow key={asset.id} icon={<BoxSelect />} label={asset.title} onClick={() => addModelAsset(asset)} />)}</div></> : null}
{imageNodes.length ? <><PanelTitle title="画布图片立牌" /><div className="px-2 pb-3">{imageNodes.slice(0, 20).map((node) => <SceneRow key={node.id} icon={<ImageIcon />} label={node.title} onClick={() => addBillboard(node)} />)}</div></> : null}
<input ref={modelInputRef} type="file" accept=".glb,.gltf,model/gltf-binary,model/gltf+json" className="hidden" onChange={(event) => { void uploadModel(event.target.files?.[0]); event.currentTarget.value = ""; }} />
</aside>
<main className="relative min-h-0 overflow-hidden bg-neutral-900">
<DirectorViewport ref={viewportRef} scene={draft} selectedObjectId={selectedObjectId} transformMode={transformMode} renderMode={renderMode} playhead={playhead} onSelectObject={setSelectedObjectId} onObjectTransform={(id, transform) => updateObject(id, { transform })} />
<div className="pointer-events-none absolute left-3 top-3 text-[10px] font-medium text-white/70">{activeShot.name} · {activeCamera?.name || "无摄影机"} · {activeShot.duration}s</div>
</main>
<aside className="thin-scrollbar min-h-0 overflow-y-auto border-l max-lg:hidden" style={{ background: theme.node.panel, borderColor: theme.toolbar.border }}>
{selectedObject ? <ObjectInspector object={selectedObject} playhead={playhead} onUpdate={(patch) => updateObject(selectedObject.id, patch)} onAddKeyframe={addObjectKeyframe} onDelete={() => { commit((current) => ({ ...current, objects: current.objects.filter((item) => item.id !== selectedObject.id) })); setSelectedObjectId(null); }} /> : selectedLight ? <LightInspector light={selectedLight} onUpdate={(patch) => updateLight(selectedLight.id, patch)} onDelete={() => { commit((current) => ({ ...current, lights: current.lights.filter((item) => item.id !== selectedLight.id) })); setSelectedLightId(null); }} /> : <ShotInspector shot={activeShot} camera={activeCamera} cameras={draft.cameras} onUpdateShot={(patch) => updateShot(activeShot.id, patch)} onUpdateCamera={(patch) => activeCamera && commit((current) => ({ ...current, cameras: current.cameras.map((item) => item.id === activeCamera.id ? { ...item, ...patch } : item) }))} onAddCameraKeyframe={addCameraKeyframe} onApplyCameraMove={applyCameraMove} onAlignCameraToView={alignCameraToView} />}
</aside>
</div>
<footer className="shrink-0 border-t px-3 py-2" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border }}>
<div className="flex items-center gap-2">
<IconButton label={playing ? "暂停" : "播放"} onClick={() => setPlaying(!playing)}>{playing ? <Pause className="size-4" /> : <Play className="size-4" />}</IconButton>
<span className="w-12 text-right text-[11px] tabular-nums">{playhead.toFixed(1)}s</span>
<Slider className="min-w-0 flex-1" min={0} max={activeShot.duration} step={0.05} value={playhead} onChange={setPlayhead} />
<Button size="small" type="text" icon={<Focus className="size-3.5" />} onClick={selectedObject ? addObjectKeyframe : addCameraKeyframe}>记录关键帧</Button>
</div>
<div className="mt-1.5 flex gap-1 overflow-x-auto">
{draft.shots.map((shot, index) => <button key={shot.id} type="button" className="h-8 min-w-28 shrink-0 border-l-2 px-2 text-left text-[11px] transition" style={{ borderColor: draft.activeShotId === shot.id ? theme.node.activeStroke : theme.toolbar.border, background: draft.activeShotId === shot.id ? theme.toolbar.itemHover : "transparent" }} onClick={() => { commit((current) => ({ ...current, activeShotId: shot.id })); setPlayhead(0); }}><span className="block truncate">{index + 1}. {shot.name}</span><span className="block opacity-45">{shot.duration}s</span></button>)}
<button type="button" className="grid h-8 w-9 shrink-0 place-items-center" title="新增镜头" onClick={addShot}><Plus className="size-4" /></button>
</div>
</footer>
</div>
);
}
function ObjectInspector({ object, playhead, onUpdate, onAddKeyframe, onDelete }: { object: DirectorObject; playhead: number; onUpdate: (patch: Partial<DirectorObject>) => void; onAddKeyframe: () => void; onDelete: () => void }) {
return <Inspector title={object.name} onTitleChange={(name) => onUpdate({ name })} onDelete={onDelete}><TransformFields transform={object.transform} onChange={(transform) => onUpdate({ transform })} /><Field label="颜色"><ColorPicker value={object.color} onChange={(_, color) => onUpdate({ color })} /></Field>{object.primitive === "character" ? <Field label="姿势"><Select className="w-full" value={object.pose} options={poseOptions} onChange={(pose) => onUpdate({ pose })} /></Field> : null}<Field label="可见"><Switch checked={object.visible} onChange={(visible) => onUpdate({ visible })} /></Field><Field label="投射阴影"><Switch checked={object.castShadow} onChange={(castShadow) => onUpdate({ castShadow })} /></Field><Button block icon={<Focus className="size-3.5" />} onClick={onAddKeyframe}>在 {playhead.toFixed(1)}s 记录关键帧</Button><div className="text-[10px] opacity-50">已记录 {object.keyframes.length} 个关键帧</div></Inspector>;
}
function LightInspector({ light, onUpdate, onDelete }: { light: DirectorLight; onUpdate: (patch: Partial<DirectorLight>) => void; onDelete: () => void }) {
return <Inspector title={light.name} onTitleChange={(name) => onUpdate({ name })} onDelete={onDelete}><Field label="类型"><Select className="w-full" value={light.type} options={[{ label: "方向光", value: "directional" }, { label: "点光源", value: "point" }, { label: "聚光灯", value: "spot" }, { label: "环境光", value: "ambient" }]} onChange={(type) => onUpdate({ type })} /></Field><Vec3Field label="位置" value={light.transform.position} onChange={(position) => onUpdate({ transform: { ...light.transform, position } })} /><Field label="颜色"><ColorPicker value={light.color} onChange={(_, color) => onUpdate({ color })} /></Field><Field label="强度"><InputNumber className="w-full" min={0} max={20} step={0.1} value={light.intensity} onChange={(value) => onUpdate({ intensity: value || 0 })} /></Field><Field label="投射阴影"><Switch checked={light.castShadow} onChange={(castShadow) => onUpdate({ castShadow })} /></Field></Inspector>;
}
function ShotInspector({ shot, camera, cameras, onUpdateShot, onUpdateCamera, onAddCameraKeyframe, onApplyCameraMove, onAlignCameraToView }: { shot: DirectorShot; camera: DirectorCamera | null; cameras: DirectorScene["cameras"]; onUpdateShot: (patch: Partial<DirectorShot>) => void; onUpdateCamera: (patch: Partial<DirectorCamera>) => void; onAddCameraKeyframe: () => void; onApplyCameraMove: () => void; onAlignCameraToView: () => void }) {
return <Inspector title={shot.name} onTitleChange={(name) => onUpdateShot({ name })}><Field label="摄影机"><Select className="w-full" value={shot.cameraId} options={cameras.map((item) => ({ label: item.name, value: item.id }))} onChange={(cameraId) => onUpdateShot({ cameraId })} /></Field><Field label="景别"><Select className="w-full" value={shot.shotSize} options={shotSizeOptions} onChange={(shotSize: DirectorShotSize) => onUpdateShot({ shotSize })} /></Field><Field label="运镜"><Select className="w-full" value={shot.cameraMove} options={cameraMoveOptions} onChange={(cameraMove: DirectorCameraMove) => onUpdateShot({ cameraMove })} /></Field><Field label="时长"><InputNumber className="w-full" min={0.5} max={60} step={0.5} value={shot.duration} addonAfter="秒" onChange={(value) => onUpdateShot({ duration: value || 5 })} /></Field><Field label="镜头意图"><Input.TextArea autoSize={{ minRows: 3, maxRows: 7 }} value={shot.prompt} placeholder="人物表演、动作、叙事目标…" onChange={(event) => onUpdateShot({ prompt: event.target.value })} /></Field>{camera ? <><Vec3Field label="摄影机位置" value={camera.transform.position} onChange={(position) => onUpdateCamera({ transform: { ...camera.transform, position } })} /><Vec3Field label="焦点" value={camera.target} onChange={(target) => onUpdateCamera({ target })} /><Field label="焦距"><InputNumber className="w-full" min={12} max={200} value={camera.focalLength} addonAfter="mm" onChange={(focalLength) => onUpdateCamera({ focalLength: focalLength || 35, fov: focalLengthToFov(focalLength || 35) })} /></Field><div className="grid grid-cols-2 gap-2"><Field label="光圈"><InputNumber className="w-full" min={0.7} max={32} step={0.1} value={camera.aperture} addonBefore="f/" onChange={(aperture) => onUpdateCamera({ aperture: aperture || 2.8 })} /></Field><Field label="焦点距离"><InputNumber className="w-full" min={0.1} max={200} step={0.1} value={camera.focusDistance} addonAfter="m" onChange={(focusDistance) => onUpdateCamera({ focusDistance: focusDistance || 5 })} /></Field></div><Button block icon={<Camera className="size-3.5" />} onClick={onAlignCameraToView}>摄影机对齐当前视图</Button><Button block icon={<Video className="size-3.5" />} onClick={onApplyCameraMove}>按运镜生成轨迹</Button><Button block icon={<Focus className="size-3.5" />} onClick={onAddCameraKeyframe}>记录摄影机关键帧</Button></> : null}</Inspector>;
}
function Inspector({ title, children, onTitleChange, onDelete }: { title: string; children: ReactNode; onTitleChange: (value: string) => void; onDelete?: () => void }) {
return <div className="space-y-3 p-3"><div className="flex items-center gap-2"><Input variant="borderless" value={title} className="min-w-0 flex-1 px-0 font-medium" onChange={(event) => onTitleChange(event.target.value)} />{onDelete ? <IconButton label="删除" onClick={onDelete}><Trash2 className="size-4" /></IconButton> : null}</div>{children}</div>;
}
function TransformFields({ transform, onChange }: { transform: DirectorTransform; onChange: (transform: DirectorTransform) => void }) {
return <><Vec3Field label="位置" value={transform.position} onChange={(position) => onChange({ ...transform, position })} /><Vec3Field label="旋转" value={transform.rotation} step={0.05} onChange={(rotation) => onChange({ ...transform, rotation })} /><Vec3Field label="缩放" value={transform.scale} step={0.1} onChange={(scale) => onChange({ ...transform, scale })} /></>;
}
function Vec3Field({ label, value, step = 0.1, onChange }: { label: string; value: DirectorVec3; step?: number; onChange: (value: DirectorVec3) => void }) {
return <Field label={label}><div className="grid grid-cols-3 gap-1">{value.map((item, index) => <InputNumber key={index} className="w-full" size="small" step={step} value={Number(item.toFixed(2))} onChange={(next) => onChange(value.map((entry, itemIndex) => itemIndex === index ? next || 0 : entry) as DirectorVec3)} />)}</div></Field>;
}
function Field({ label, children }: { label: string; children: ReactNode }) { return <label className="block"><span className="mb-1 block text-[11px] opacity-55">{label}</span>{children}</label>; }
function PanelTitle({ title, action }: { title: string; action?: ReactNode }) { return <div className="flex h-9 items-center px-3 text-[10px] font-semibold uppercase opacity-55"><span className="flex-1">{title}</span>{action}</div>; }
function SceneRow({ active, icon, label, onClick }: { active?: boolean; icon: ReactElement; label: string; onClick: () => void }) { return <button type="button" className={`flex h-8 w-full items-center gap-2 px-2 text-left text-xs transition ${active ? "bg-black/10 dark:bg-white/10" : "hover:bg-black/5 dark:hover:bg-white/5"}`} onClick={onClick}><span className="[&>svg]:size-3.5">{icon}</span><span className="truncate">{label}</span></button>; }
function QuickAdd({ label, icon, onClick }: { label: string; icon: ReactElement; onClick: () => void }) { return <button type="button" className="flex h-8 items-center gap-1.5 border px-2 text-[10px] transition hover:bg-black/5 dark:hover:bg-white/5" onClick={onClick}><span className="[&>svg]:size-3.5">{icon}</span><span className="truncate">{label}</span></button>; }
function IconButton({ label, disabled, children, onClick }: { label: string; disabled?: boolean; children: ReactNode; onClick: () => void }) { return <button type="button" aria-label={label} title={label} disabled={disabled} className="grid size-8 shrink-0 place-items-center rounded-md transition hover:bg-black/5 disabled:opacity-30 dark:hover:bg-white/10" onClick={onClick}>{children}</button>; }
function ToolButton({ label, active, children, onClick }: { label: string; active: boolean; children: ReactNode; onClick: () => void }) { return <button type="button" aria-label={label} title={label} className={`grid size-8 place-items-center rounded-md transition ${active ? "bg-black text-white dark:bg-white dark:text-black" : "hover:bg-black/5 dark:hover:bg-white/10"}`} onClick={onClick}>{children}</button>; }
const poseOptions = [{ label: "自然", value: "neutral" }, { label: "站立", value: "stand" }, { label: "行走", value: "walk" }, { label: "奔跑", value: "run" }, { label: "坐姿", value: "sit" }, { label: "动作", value: "action" }];
const shotSizeOptions = [{ label: "大远景", value: "extreme_wide" }, { label: "远景", value: "wide" }, { label: "全身景", value: "full" }, { label: "中景", value: "medium" }, { label: "近景", value: "close_up" }, { label: "大特写", value: "extreme_close_up" }];
const cameraMoveOptions = [{ label: "固定", value: "static" }, { label: "推进", value: "push_in" }, { label: "拉远", value: "pull_out" }, { label: "左摇", value: "pan_left" }, { label: "右摇", value: "pan_right" }, { label: "上摇", value: "tilt_up" }, { label: "下摇", value: "tilt_down" }, { label: "左环绕", value: "orbit_left" }, { label: "右环绕", value: "orbit_right" }, { label: "手持", value: "handheld" }];
function cameraMoveTransform(transform: DirectorTransform, move: DirectorCameraMove): DirectorTransform {
const [x, y, z] = transform.position;
const offsets: Record<DirectorCameraMove, DirectorVec3> = { static: [0, 0, 0], push_in: [0, 0, -2], pull_out: [0, 0, 2], pan_left: [-2, 0, 0], pan_right: [2, 0, 0], tilt_up: [0, 1.5, 0], tilt_down: [0, -1.2, 0], orbit_left: [-2.5, 0, -1.5], orbit_right: [2.5, 0, -1.5], handheld: [0.18, 0.08, -0.15] };
const offset = offsets[move];
return { ...transform, position: [x + offset[0], y + offset[1], z + offset[2]] };
}
function focalLengthToFov(focalLength: number) { return (2 * Math.atan(36 / (2 * focalLength)) * 180) / Math.PI; }
@@ -0,0 +1,289 @@
import { Canvas, useThree } from "@react-three/fiber";
import { Grid, OrbitControls, TransformControls } from "@react-three/drei";
import { forwardRef, Suspense, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { Box3, Color, Group, Mesh, MeshDepthMaterial, MeshNormalMaterial, Object3D, PerspectiveCamera, Scene, Texture, TextureLoader, Vector3, WebGLRenderer } from "three";
import { GLTFLoader } from "three-stdlib";
import { interpolateDirectorTransform } from "@/lib/canvas/director/director-scene";
import { resolveMediaUrl } from "@/services/file-storage";
import type { DirectorCamera, DirectorLight, DirectorObject, DirectorRenderMode, DirectorScene, DirectorTransform } from "@/types/director";
export type DirectorViewportHandle = {
capture: (mode: DirectorRenderMode) => Promise<Blob>;
readCameraTransform: () => DirectorTransform | null;
};
type DirectorViewportProps = {
scene: DirectorScene;
selectedObjectId: string | null;
transformMode: "translate" | "rotate" | "scale";
renderMode: DirectorRenderMode;
playhead: number;
onSelectObject: (id: string | null) => void;
onObjectTransform: (id: string, transform: DirectorTransform) => void;
};
type CaptureContext = { gl: WebGLRenderer; scene: Scene; camera: PerspectiveCamera };
export const DirectorViewport = forwardRef<DirectorViewportHandle, DirectorViewportProps>(function DirectorViewport(props, ref) {
const captureContext = useRef<CaptureContext | null>(null);
useImperativeHandle(ref, () => ({
capture: (mode) => captureFrame(captureContext.current, mode),
readCameraTransform: () => {
const camera = captureContext.current?.camera;
return camera ? { position: camera.position.toArray() as DirectorTransform["position"], rotation: [camera.rotation.x, camera.rotation.y, camera.rotation.z], scale: [1, 1, 1] } : null;
},
}), []);
return (
<Canvas
shadows
frameloop="demand"
dpr={[1, 1.5]}
camera={{ position: [4.8, 2.7, 6.8], fov: 50, near: 0.05, far: 500 }}
gl={{ antialias: true, preserveDrawingBuffer: true, alpha: false }}
onPointerMissed={() => props.onSelectObject(null)}
>
<Suspense fallback={null}>
<DirectorSceneContent {...props} onCaptureContext={(context) => (captureContext.current = context)} />
</Suspense>
</Canvas>
);
});
function DirectorSceneContent({ scene, selectedObjectId, transformMode, renderMode, playhead, onSelectObject, onObjectTransform, onCaptureContext }: DirectorViewportProps & { onCaptureContext: (context: CaptureContext) => void }) {
const { gl, camera, scene: threeScene, invalidate } = useThree();
const [transforming, setTransforming] = useState(false);
const shot = scene.shots.find((item) => item.id === scene.activeShotId) || scene.shots[0];
const activeCamera = scene.cameras.find((item) => item.id === shot?.cameraId) || scene.cameras[0];
useEffect(() => {
onCaptureContext({ gl, camera: camera as PerspectiveCamera, scene: threeScene });
}, [camera, gl, onCaptureContext, threeScene]);
useEffect(() => {
threeScene.background = new Color(scene.background);
invalidate();
}, [invalidate, scene.background, threeScene]);
useEffect(() => {
const material = renderMode === "depth" ? new MeshDepthMaterial() : renderMode === "normal" ? new MeshNormalMaterial() : null;
threeScene.overrideMaterial = material;
invalidate();
return () => {
if (threeScene.overrideMaterial === material) threeScene.overrideMaterial = null;
material?.dispose();
};
}, [invalidate, renderMode, threeScene]);
return (
<>
<CameraSync camera={activeCamera} playhead={playhead} />
<ambientLight intensity={scene.environmentIntensity * 0.35} />
{scene.lights.map((light) => <DirectorLightView key={light.id} light={light} />)}
{scene.gridVisible ? <Grid position={[0, 0, 0]} infiniteGrid fadeDistance={40} fadeStrength={5} cellSize={0.5} sectionSize={5} cellColor="#8f99a3" sectionColor="#626d77" /> : null}
<mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow position={[0, -0.012, 0]}>
<planeGeometry args={[120, 120]} />
<meshStandardMaterial color="#aeb7bf" roughness={0.92} />
</mesh>
{scene.objects.filter((item) => item.visible).map((object) => (
<DirectorObjectView
key={object.id}
object={object}
selected={selectedObjectId === object.id}
transformMode={transformMode}
playhead={playhead}
onSelect={() => onSelectObject(object.id)}
onTransforming={setTransforming}
onTransform={(transform) => onObjectTransform(object.id, transform)}
/>
))}
<OrbitControls makeDefault enabled={!transforming} target={activeCamera?.target || [0, 1, 0]} minDistance={0.6} maxDistance={80} />
</>
);
}
function CameraSync({ camera, playhead }: { camera?: DirectorCamera; playhead: number }) {
const threeCamera = useThree((state) => state.camera as PerspectiveCamera);
const invalidate = useThree((state) => state.invalidate);
useEffect(() => {
if (!camera) return;
const transform = interpolateDirectorTransform(camera.transform, camera.keyframes, playhead);
threeCamera.position.set(...transform.position);
threeCamera.rotation.set(...transform.rotation);
threeCamera.fov = camera.fov;
threeCamera.near = camera.near;
threeCamera.far = camera.far;
threeCamera.lookAt(...camera.target);
threeCamera.updateProjectionMatrix();
invalidate();
}, [camera, invalidate, playhead, threeCamera]);
return null;
}
function DirectorObjectView({ object, selected, transformMode, playhead, onSelect, onTransforming, onTransform }: { object: DirectorObject; selected: boolean; transformMode: DirectorViewportProps["transformMode"]; playhead: number; onSelect: () => void; onTransforming: (value: boolean) => void; onTransform: (transform: DirectorTransform) => void }) {
const groupRef = useRef<Group>(null);
const transform = interpolateDirectorTransform(object.transform, object.keyframes, playhead);
const content = (
<group
ref={groupRef}
position={transform.position}
rotation={transform.rotation}
scale={transform.scale}
onPointerDown={(event) => {
event.stopPropagation();
onSelect();
}}
>
<DirectorObjectVisual object={object} selected={selected} />
</group>
);
if (!selected) return content;
return (
<TransformControls
mode={transformMode}
size={0.8}
onMouseDown={() => onTransforming(true)}
onMouseUp={() => {
onTransforming(false);
const target = groupRef.current;
if (!target) return;
onTransform({ position: target.position.toArray() as DirectorTransform["position"], rotation: [target.rotation.x, target.rotation.y, target.rotation.z], scale: target.scale.toArray() as DirectorTransform["scale"] });
}}
>
{content}
</TransformControls>
);
}
function DirectorObjectVisual({ object, selected }: { object: DirectorObject; selected: boolean }) {
if (object.kind === "model" && object.url) return <DirectorModel object={object} selected={selected} />;
if (object.kind === "billboard" && object.url) return <DirectorBillboard object={object} selected={selected} />;
if (object.primitive === "character") return <DirectorCharacter object={object} selected={selected} />;
const material = <meshStandardMaterial color={selected ? "#2f8cff" : object.color} roughness={0.68} metalness={0.05} />;
return (
<mesh castShadow={object.castShadow} receiveShadow={object.receiveShadow}>
{object.primitive === "sphere" ? <sphereGeometry args={[0.6, 32, 24]} /> : object.primitive === "cylinder" ? <cylinderGeometry args={[0.5, 0.5, 1.2, 32]} /> : object.primitive === "plane" ? <planeGeometry args={[1.6, 1]} /> : <boxGeometry args={[1, 1, 1]} />}
{material}
</mesh>
);
}
function DirectorCharacter({ object, selected }: { object: DirectorObject; selected: boolean }) {
const color = selected ? "#2f8cff" : object.color;
const pose = characterPose(object.pose || "stand");
return (
<group>
<mesh castShadow position={[0, 1.55, 0]}><sphereGeometry args={[0.24, 24, 18]} /><meshStandardMaterial color={color} roughness={0.76} /></mesh>
<mesh castShadow position={[0, 1.05, 0]}><capsuleGeometry args={[0.28, 0.65, 8, 16]} /><meshStandardMaterial color={color} roughness={0.76} /></mesh>
<Limb position={[-0.34, 1.16, 0]} rotation={pose.leftArm} color={color} />
<Limb position={[0.34, 1.16, 0]} rotation={pose.rightArm} color={color} />
<Limb position={[-0.17, 0.43, 0]} rotation={pose.leftLeg} color={color} length={0.82} />
<Limb position={[0.17, 0.43, 0]} rotation={pose.rightLeg} color={color} length={0.82} />
</group>
);
}
function Limb({ position, rotation, color, length = 0.68 }: { position: [number, number, number]; rotation: [number, number, number]; color: string; length?: number }) {
return <mesh castShadow position={position} rotation={rotation}><capsuleGeometry args={[0.09, length, 6, 12]} /><meshStandardMaterial color={color} roughness={0.76} /></mesh>;
}
function DirectorModel({ object, selected }: { object: DirectorObject; selected: boolean }) {
const [model, setModel] = useState<Object3D | null>(null);
useEffect(() => {
let active = true;
const loader = new GLTFLoader();
void resolveMediaUrl(object.storageKey, object.url).then((url) => loader.load(url, (gltf) => {
if (!active) return;
const next = gltf.scene.clone(true);
const bounds = new Box3().setFromObject(next);
const size = bounds.getSize(new Vector3());
const maxSize = Math.max(size.x, size.y, size.z, 0.001);
next.scale.multiplyScalar(2 / maxSize);
const centered = new Box3().setFromObject(next);
const center = centered.getCenter(new Vector3());
next.position.sub(center);
next.position.y -= centered.min.y - center.y;
next.traverse((child) => {
const mesh = child as Mesh;
if (!mesh.isMesh) return;
mesh.castShadow = object.castShadow;
mesh.receiveShadow = object.receiveShadow;
});
applyHumanoidPose(next, object.pose || "stand");
setModel(next);
}, undefined, () => active && setModel(null)));
return () => { active = false; };
}, [object.castShadow, object.pose, object.receiveShadow, object.storageKey, object.url]);
if (!model) return <mesh castShadow><boxGeometry args={[1, 1, 1]} /><meshStandardMaterial color={selected ? "#2f8cff" : "#e39145"} wireframe /></mesh>;
return <primitive object={model} />;
}
function DirectorBillboard({ object, selected }: { object: DirectorObject; selected: boolean }) {
const [texture, setTexture] = useState<Texture | null>(null);
useEffect(() => {
let active = true;
new TextureLoader().load(object.url!, (next) => active && setTexture(next), undefined, () => active && setTexture(null));
return () => { active = false; };
}, [object.url]);
return (
<mesh castShadow={object.castShadow}>
<planeGeometry args={[1.6, 1]} />
<meshBasicMaterial map={texture || undefined} color={texture ? "#ffffff" : selected ? "#2f8cff" : object.color} toneMapped={false} />
</mesh>
);
}
function DirectorLightView({ light }: { light: DirectorLight }) {
const position = light.transform.position;
if (light.type === "ambient") return <ambientLight color={light.color} intensity={light.intensity} />;
if (light.type === "point") return <pointLight position={position} color={light.color} intensity={light.intensity} castShadow={light.castShadow} />;
if (light.type === "spot") return <spotLight position={position} color={light.color} intensity={light.intensity} angle={light.angle} penumbra={light.penumbra} castShadow={light.castShadow} />;
return <directionalLight position={position} color={light.color} intensity={light.intensity} castShadow={light.castShadow} shadow-mapSize-width={1024} shadow-mapSize-height={1024} />;
}
function characterPose(pose: NonNullable<DirectorObject["pose"]>): Record<"leftArm" | "rightArm" | "leftLeg" | "rightLeg", [number, number, number]> {
if (pose === "walk") return { leftArm: [0.35, 0, 0.18], rightArm: [-0.35, 0, -0.18], leftLeg: [-0.24, 0, 0.08], rightLeg: [0.24, 0, -0.08] };
if (pose === "run") return { leftArm: [0.7, 0, 0.35], rightArm: [-0.7, 0, -0.35], leftLeg: [-0.55, 0, 0.12], rightLeg: [0.55, 0, -0.12] };
if (pose === "sit") return { leftArm: [0.15, 0, 0.05], rightArm: [0.15, 0, -0.05], leftLeg: [1.1, 0, 0], rightLeg: [1.1, 0, 0] };
if (pose === "action") return { leftArm: [1.1, 0.2, 0.5], rightArm: [-0.7, -0.2, -0.5], leftLeg: [-0.35, 0, 0.2], rightLeg: [0.45, 0, -0.15] };
return { leftArm: [0, 0, 0.08], rightArm: [0, 0, -0.08], leftLeg: [0, 0, 0], rightLeg: [0, 0, 0] };
}
function applyHumanoidPose(root: Object3D, pose: NonNullable<DirectorObject["pose"]>) {
const rotations = humanoidBoneRotations(pose);
root.traverse((child) => {
const name = child.name.toLowerCase().replace(/[^a-z0-9]/g, "");
const entry = rotations.find(([pattern]) => pattern.test(name));
if (entry) child.rotation.set(...entry[1]);
});
}
function humanoidBoneRotations(pose: NonNullable<DirectorObject["pose"]>): Array<[RegExp, [number, number, number]]> {
const values = characterPose(pose);
return [
[/left(upper)?arm|upperarmleft|mixamorigleftarm/, values.leftArm],
[/right(upper)?arm|upperarmright|mixamorigrightarm/, values.rightArm],
[/left(up)?leg|upperlegleft|mixamorigleftupleg/, values.leftLeg],
[/right(up)?leg|upperlegright|mixamorigrightupleg/, values.rightLeg],
[/spine|chest/, pose === "action" ? [0.08, 0.18, -0.08] : [0, 0, 0]],
];
}
async function captureFrame(context: CaptureContext | null, mode: DirectorRenderMode) {
if (!context) throw new Error("3D 视口尚未就绪");
const { gl, scene, camera } = context;
const previous = scene.overrideMaterial;
const override = mode === "depth" ? new MeshDepthMaterial() : mode === "normal" ? new MeshNormalMaterial() : null;
scene.overrideMaterial = override;
gl.render(scene, camera);
const blob = await canvasToBlob(gl.domElement);
scene.overrideMaterial = previous;
override?.dispose();
gl.render(scene, camera);
return blob;
}
function canvasToBlob(canvas: HTMLCanvasElement) {
return new Promise<Blob>((resolve, reject) => canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("3D 预览图导出失败"))), "image/png"));
}