feat(canvas): 导演台 - 增加彩色骨骼演员、姿势预设与单一构图回写

This commit is contained in:
ddCat
2026-08-05 16:22:11 +08:00
parent cd326609fa
commit 61af2a652a
11 changed files with 1244 additions and 150 deletions

View File

@@ -1,18 +1,21 @@
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 { App, Button, ColorPicker, Input, InputNumber, Select, Switch } from "antd";
import { Box, BoxSelect, Camera, Circle, Cuboid, FileUp, Focus, Image as ImageIcon, LampDesk, Lightbulb, Plus, Redo2, Save, 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 type { AnimationClip } from "three";
import { DirectorViewport, type DirectorViewportHandle } from "@/components/canvas/director/director-viewport";
import { DirectorViewportDock } from "@/components/canvas/director/director-viewport-dock";
import { DirectorSequencer } from "@/components/canvas/director/director-sequencer";
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 { createDirectorActor, createDirectorBillboard, createDirectorCamera, createDirectorLight, createDirectorModel, createDirectorObject, DIRECTOR_ACTOR_COLORS, directorBoneLabel, directorPoseLabel, touchDirectorScene, upsertDirectorBoneKeyframe, 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";
import type { DirectorCamera, DirectorCameraMove, DirectorHumanoidBone, DirectorLight, DirectorObject, DirectorPose, DirectorQuat, DirectorRig, 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();
@@ -23,18 +26,25 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
const [history, setHistory] = useState<DirectorScene[]>([]);
const [future, setFuture] = useState<DirectorScene[]>([]);
const [saving, setSaving] = useState(false);
const [recording, setRecording] = 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 selectedBone = useDirectorWorkbenchStore((state) => state.selectedBone);
const autoKey = useDirectorWorkbenchStore((state) => state.autoKey);
const sequencerHeight = useDirectorWorkbenchStore((state) => state.sequencerHeight);
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 setSelectedBone = useDirectorWorkbenchStore((state) => state.setSelectedBone);
const setAutoKey = useDirectorWorkbenchStore((state) => state.setAutoKey);
const setSequencerHeight = useDirectorWorkbenchStore((state) => state.setSequencerHeight);
const resetWorkbench = useDirectorWorkbenchStore((state) => state.reset);
const assets = useAssetStore((state) => state.assets);
const addAsset = useAssetStore((state) => state.addAsset);
@@ -42,7 +52,9 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
useEffect(() => {
if (!open || !scene) return;
setDraft(structuredClone(scene));
const next = structuredClone(scene);
next.shots = next.shots.map((shot) => ({ ...shot, fps: shot.fps || 24 }));
setDraft(next);
setHistory([]);
setFuture([]);
resetWorkbench();
@@ -105,6 +117,13 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
setSelectedObjectId(object.id);
};
const addActor = () => {
const actorCount = draft?.objects.filter((item) => item.kind === "actor").length || 0;
const actor = createDirectorActor(`演员 ${actorCount + 1}`, [actorCount * 0.8, 0, 0], DIRECTOR_ACTOR_COLORS[actorCount % DIRECTOR_ACTOR_COLORS.length]);
commit((current) => ({ ...current, objects: [...current.objects, actor] }));
setSelectedObjectId(actor.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] }));
@@ -141,7 +160,7 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
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: "" };
const shot: DirectorShot = { id: nanoid(), name: `镜头 ${(draft?.shots.length || 0) + 1}`, cameraId: activeCamera.id, duration: 5, fps: 24, shotSize: "medium", cameraMove: "static", prompt: "" };
commit((current) => ({ ...current, shots: [...current.shots, shot], activeShotId: shot.id }));
setPlayhead(0);
};
@@ -156,6 +175,46 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
commit((current) => ({ ...current, cameras: current.cameras.map((item) => item.id === activeCamera.id ? { ...item, keyframes: upsertDirectorKeyframe(item.keyframes, playhead, item.transform) } : item) }));
};
const recordSelectedKeyframe = () => {
if (selectedObject && selectedBone) {
const rotation = selectedObject.boneOverrides?.[selectedBone as DirectorHumanoidBone] || [0, 0, 0, 1] as DirectorQuat;
updateObject(selectedObject.id, { boneTracks: upsertDirectorBoneKeyframe(selectedObject.boneTracks || [], selectedBone as DirectorHumanoidBone, playhead, rotation) });
return;
}
if (selectedObject) addObjectKeyframe();
else addCameraKeyframe();
};
const handleObjectTransform = useCallback((id: string, transform: DirectorTransform) => {
commit((current) => ({
...current,
objects: current.objects.map((item) => item.id === id ? { ...item, transform, keyframes: autoKey ? upsertDirectorKeyframe(item.keyframes, playhead, transform) : item.keyframes } : item),
}));
}, [autoKey, commit, playhead]);
const handleBoneTransform = useCallback((id: string, bone: string, rotation: DirectorQuat) => {
commit((current) => ({
...current,
objects: current.objects.map((item) => item.id === id ? {
...item,
boneOverrides: { ...item.boneOverrides, [bone]: rotation },
boneTracks: autoKey ? upsertDirectorBoneKeyframe(item.boneTracks || [], bone as DirectorHumanoidBone, playhead, rotation) : item.boneTracks,
} : item),
}));
}, [autoKey, commit, playhead]);
const handleActorRigReady = useCallback((id: string, rig: DirectorRig, animations: AnimationClip[]) => {
replaceWithoutHistory((current) => ({
...current,
objects: current.objects.map((item) => {
if (item.id !== id) return item;
const existing = item.motionClips || [];
const motionClips = existing.length ? existing : animations.map((clip) => ({ id: nanoid(), name: clip.name || "动作片段", sourceAnimation: clip.name, start: 0, duration: Math.max(0.1, clip.duration), playbackRate: 1, loop: true }));
return { ...item, rig, motionClips };
}),
}));
}, [replaceWithoutHistory]);
const applyCameraMove = () => {
if (!activeCamera || !activeShot) return;
const start = activeCamera.transform;
@@ -177,14 +236,12 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
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("镜头已回写画布");
await onApply({ scene: next, shot: activeShot, prompt, beauty });
message.success("导演台构图已回写画布");
} catch (error) {
message.error(error instanceof Error ? error.message : "导演台输出失败");
} finally {
@@ -192,6 +249,29 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
}
};
const exportClayVideo = async () => {
if (!draft || !activeShot || !viewportRef.current || recording) return;
setRecording(true);
const wasPlaying = playing;
const previousPlayhead = playhead;
setPlayhead(0);
setPlaying(true);
try {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const clayVideo = await viewportRef.current.recordVideo(activeShot.duration, activeShot.fps);
const next = touchDirectorScene(draft);
onChange(next);
await onApply({ scene: next, shot: activeShot, prompt: compileDirectorPrompt(next, activeShot), beauty: await viewportRef.current.capture("beauty"), clayVideo, clayVideoMimeType: clayVideo.type });
message.success("白膜视频已回写画布");
} catch (error) {
message.error(error instanceof Error ? error.message : "白膜视频导出失败");
} finally {
setPlaying(wasPlaying);
setPlayhead(previousPlayhead);
setRecording(false);
}
};
if (!open || !draft || !activeShot) return null;
return (
@@ -202,12 +282,9 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
<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} />
<Select size="small" value={renderMode} className="w-24" options={[{ label: "预览", value: "beauty" }, { label: "彩色白膜", value: "clay" }, { label: "骨骼", value: "pose" }, { label: "深度", value: "depth" }, { label: "法线", value: "normal" }]} onChange={setRenderMode} />
<Button size="small" icon={<Video className="size-3.5" />} loading={recording} onClick={() => void exportClayVideo()}>导出白膜</Button>
<Button size="small" type="primary" icon={<Save className="size-3.5" />} loading={saving} onClick={() => void applyToCanvas()}>应用到镜头</Button>
</div>
</header>
@@ -216,7 +293,7 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
<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)} />)}
{draft.objects.map((object) => <SceneRow key={object.id} active={selectedObjectId === object.id} icon={object.kind === "actor" || 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>
@@ -224,7 +301,7 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
<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={<UserRound />} onClick={addActor} />
<QuickAdd label="立方体" icon={<Box />} onClick={() => addPrimitive("box", "立方体")} />
<QuickAdd label="球体" icon={<Circle />} onClick={() => addPrimitive("sphere", "球体")} />
<QuickAdd label="圆柱" icon={<Cuboid />} onClick={() => addPrimitive("cylinder", "圆柱")} />
@@ -237,41 +314,61 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
</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 })} />
<DirectorViewport ref={viewportRef} scene={draft} selectedObjectId={selectedObjectId} selectedBone={selectedBone} transformMode={transformMode} renderMode={renderMode} playhead={playhead} onSelectObject={setSelectedObjectId} onSelectBone={setSelectedBone} onObjectTransform={handleObjectTransform} onBoneTransform={handleBoneTransform} onActorRigReady={handleActorRigReady} />
<div className="pointer-events-none absolute left-3 top-3 text-[var(--fs-tiny)] font-medium text-white/70">{activeShot.name} · {activeCamera?.name || "无摄影机"} · {activeShot.duration}s</div>
<DirectorViewportDock transformMode={transformMode} renderMode={renderMode} onTransformModeChange={setTransformMode} onRenderModeChange={setRenderMode} onAddActor={addActor} onAddBox={() => addPrimitive("box", "立方体")} onAddLight={addLight} onAddCamera={addCamera} onAlignCamera={alignCameraToView} />
</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} />}
{selectedObject ? <ObjectInspector object={selectedObject} playhead={playhead} selectedBone={selectedBone} onSelectBone={setSelectedBone} onUpdate={(patch) => updateObject(selectedObject.id, patch)} onAddKeyframe={recordSelectedKeyframe} 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} onExportClay={exportClayVideo} recording={recording} />}
</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-[var(--fs-label)] 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-[var(--fs-label)] 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>
<DirectorSequencer scene={draft} shot={activeShot} camera={activeCamera} objects={draft.objects} selectedObjectId={selectedObjectId} selectedBone={selectedBone} playhead={playhead} playing={playing} autoKey={autoKey} height={sequencerHeight} onPlayToggle={() => setPlaying(!playing)} onPlayheadChange={setPlayhead} onAutoKeyChange={setAutoKey} onHeightChange={setSequencerHeight} onSelectObject={setSelectedObjectId} onSelectBone={setSelectedBone} onRecordKeyframe={recordSelectedKeyframe} onAddShot={addShot} onSelectShot={(id) => { commit((current) => ({ ...current, activeShotId: id })); setPlayhead(0); }} />
</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-[var(--fs-tiny)] opacity-50">已记录 {object.keyframes.length} 个关键帧</div></Inspector>;
function ObjectInspector({ object, playhead, selectedBone, onSelectBone, onUpdate, onAddKeyframe, onDelete }: { object: DirectorObject; playhead: number; selectedBone: string | null; onSelectBone: (bone: string | null) => void; onUpdate: (patch: Partial<DirectorObject>) => void; onAddKeyframe: () => void; onDelete: () => void }) {
const motionClips = object.motionClips || [];
const activeMotionClip = motionClips.find((clip) => clip.id === object.activeMotionClipId);
const mappedBones = Object.keys(object.rig?.boneMap || {}) as DirectorHumanoidBone[];
const updateActiveMotion = (patch: Partial<NonNullable<DirectorObject["motionClips"]>[number]>) => activeMotionClip && onUpdate({ motionClips: motionClips.map((clip) => clip.id === activeMotionClip.id ? { ...clip, ...patch } : clip) });
const applyPose = (pose: DirectorPose) => onUpdate({ pose, activeMotionClipId: undefined, boneOverrides: {} });
return <Inspector title={object.name} onTitleChange={(name) => onUpdate({ name })} onDelete={onDelete}>
<TransformFields transform={object.transform} onChange={(transform) => onUpdate({ transform })} />
{object.kind === "actor"
? <Field label="角色颜色"><div className="director-actor-colors">{DIRECTOR_ACTOR_COLORS.map((color) => <button key={color} type="button" className={`director-actor-color ${object.color.toLowerCase() === color ? "is-active" : ""}`} style={{ background: color }} aria-label={`设置颜色 ${color}`} onClick={() => onUpdate({ color })} />)}<ColorPicker value={object.color} size="small" onChange={(_, color) => onUpdate({ color })} /></div></Field>
: <Field label="颜色"><ColorPicker value={object.color} onChange={(_, color) => onUpdate({ color })} /></Field>}
{object.kind === "actor" || motionClips.length ? <>
<section className="director-pose-section">
<div className="director-inspector-section-title"><span>姿势预设</span><span>{directorPoseLabel(object.pose || "stand")}</span></div>
<div className="director-pose-grid">{poseOptions.map((option) => <button key={option.value} type="button" className={`director-pose-button ${object.pose === option.value && !object.activeMotionClipId ? "is-active" : ""}`} title={option.label} onClick={() => applyPose(option.value)}>{option.label}</button>)}</div>
</section>
<div className="flex items-center justify-between border-y py-2 text-[var(--fs-label)]"><span>角色绑定</span><span className="opacity-55">{object.rig?.status === "ready" ? `${mappedBones.length} 根骨骼` : "等待模型"}</span></div>
{motionClips.length ? <><Field label="动作片段"><Select className="w-full" value={object.activeMotionClipId || ""} options={[{ label: "静态姿势", value: "" }, ...motionClips.map((clip) => ({ label: clip.name, value: clip.id }))]} onChange={(activeMotionClipId) => onUpdate({ activeMotionClipId: activeMotionClipId || undefined })} /></Field>{activeMotionClip ? <div className="grid grid-cols-2 gap-2"><Field label="播放速度"><InputNumber className="w-full" min={0.1} max={4} step={0.1} value={activeMotionClip.playbackRate} onChange={(playbackRate) => updateActiveMotion({ playbackRate: playbackRate || 1 })} /></Field><Field label="循环"><Switch checked={activeMotionClip.loop} onChange={(loop) => updateActiveMotion({ loop })} /></Field></div> : null}</> : <div className="text-[var(--fs-tiny)] opacity-50">模型加载后会显示可用动作 Clip</div>}
{mappedBones.length ? <Field label="骨骼控制"><Select className="w-full" allowClear value={selectedBone || undefined} options={mappedBones.map((bone) => ({ label: directorBoneLabel(bone), value: bone }))} onChange={(bone) => onSelectBone(bone || null)} /></Field> : null}
</> : 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}>{selectedBone ? `在 ${playhead.toFixed(1)}s 记录骨骼` : `在 ${playhead.toFixed(1)}s 记录关键帧`}</Button>
<div className="text-[var(--fs-tiny)] opacity-50">Transform {object.keyframes.length} 个 · 骨骼 {object.boneTracks?.reduce((sum, track) => sum + track.keyframes.length, 0) || 0} 个</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 ShotInspector({ shot, camera, cameras, onUpdateShot, onUpdateCamera, onAddCameraKeyframe, onApplyCameraMove, onAlignCameraToView, onExportClay, recording }: { shot: DirectorShot; camera: DirectorCamera | null; cameras: DirectorScene["cameras"]; onUpdateShot: (patch: Partial<DirectorShot>) => void; onUpdateCamera: (patch: Partial<DirectorCamera>) => void; onAddCameraKeyframe: () => void; onApplyCameraMove: () => void; onAlignCameraToView: () => void; onExportClay: () => void; recording: boolean }) {
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>
<div className="grid grid-cols-2 gap-2"><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.fps} options={[24, 25, 30].map((fps) => ({ label: `${fps} fps`, value: fps }))} onChange={(fps: 24 | 25 | 30) => onUpdateShot({ fps })} /></Field></div>
<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><Button block type="primary" ghost icon={<Video className="size-3.5" />} loading={recording} onClick={onExportClay}>导出白膜视频</Button></> : null}
</Inspector>;
}
function Inspector({ title, children, onTitleChange, onDelete }: { title: string; children: ReactNode; onTitleChange: (value: string) => void; onDelete?: () => void }) {
@@ -291,9 +388,13 @@ function PanelTitle({ title, action }: { title: string; action?: ReactNode }) {
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-[var(--fs-tiny)] 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 poseOptions: Array<{ label: string; value: DirectorPose }> = [
{ label: "站立", value: "stand" }, { label: "T 型", value: "t_pose" }, { label: "行走", value: "walk" }, { label: "跑步", value: "run" },
{ label: "坐姿", value: "sit" }, { label: "蹲下", value: "squat" }, { label: "单膝跪", value: "kneel_single" }, { label: "双膝跪", value: "kneel_double" },
{ label: "叉腰", value: "hands_hips" }, { label: "倚靠", value: "lean" }, { label: "鞠躬", value: "bow" }, { label: "思考", value: "think" },
{ label: "格斗", value: "fight" }, { label: "踢球", value: "kick" }, { label: "投掷", value: "throw" }, { label: "推进", value: "push" },
{ label: "招手", value: "wave" }, { label: "伸手", value: "reach" }, { label: "抱臂", value: "arms_crossed" }, { label: "看手机", value: "phone" },
];
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" }];

View File

@@ -0,0 +1,154 @@
import { ChevronDown, ChevronRight, KeyRound, Magnet, Pause, Play, Plus, Rows3, ZoomIn, ZoomOut } from "lucide-react";
import { useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { directorBoneLabel } from "@/lib/canvas/director/director-scene";
import type { DirectorCamera, DirectorObject, DirectorScene, DirectorShot } from "@/types/director";
type DirectorSequencerProps = {
scene: DirectorScene;
shot: DirectorShot;
camera: DirectorCamera | null;
objects: DirectorObject[];
selectedObjectId: string | null;
selectedBone: string | null;
playhead: number;
playing: boolean;
autoKey: boolean;
height: number;
onPlayToggle: () => void;
onPlayheadChange: (time: number) => void;
onAutoKeyChange: (value: boolean) => void;
onHeightChange: (height: number) => void;
onSelectObject: (id: string | null) => void;
onSelectBone: (bone: string | null) => void;
onRecordKeyframe: () => void;
onAddShot: () => void;
onSelectShot: (id: string) => void;
};
type TrackKey = { id: string; time: number; color?: string };
export function DirectorSequencer({ scene, shot, camera, objects, selectedObjectId, selectedBone, playhead, playing, autoKey, height, onPlayToggle, onPlayheadChange, onAutoKeyChange, onHeightChange, onSelectObject, onSelectBone, onRecordKeyframe, onAddShot, onSelectShot }: DirectorSequencerProps) {
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [snapEnabled, setSnapEnabled] = useState(true);
const [showDetails, setShowDetails] = useState(true);
const [timelineScale, setTimelineScale] = useState(1);
const duration = Math.max(0.5, shot.duration);
const fps = shot.fps || 24;
const rootRef = useRef<HTMLDivElement>(null);
const ticks = useMemo(() => Array.from({ length: Math.ceil(duration) + 1 }, (_, index) => index), [duration]);
const actorObjects = objects.filter((object) => object.kind === "actor" || object.primitive === "character");
const setTimeFromPointer = (event: React.PointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const rawTime = Math.max(0, Math.min(duration, ((event.clientX - rect.left) / Math.max(rect.width, 1)) * duration));
onPlayheadChange(snapEnabled ? Math.round(rawTime * fps) / fps : rawTime);
};
const startResize = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
const startY = event.clientY;
const startHeight = height;
const move = (moveEvent: PointerEvent) => onHeightChange(startHeight + startY - moveEvent.clientY);
const stop = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", stop);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", stop, { once: true });
};
const cameraKeys = camera?.keyframes.map((key) => ({ id: key.id, time: key.time, color: "#78a9ff" })) || [];
return (
<section ref={rootRef} className="director-sequencer shrink-0 border-t" style={{ height, minHeight: "var(--director-sequencer-min-height)", background: "var(--director-sequencer-surface)", borderColor: "var(--director-sequencer-border)" }}>
<div className="director-sequencer-resizer" onPointerDown={startResize} role="separator" aria-label="调整时间轴高度" />
<header className="flex h-10 shrink-0 items-center gap-2 border-b px-3" style={{ borderColor: "var(--director-sequencer-border)" }}>
<button type="button" className="director-sequencer-transport" onClick={onPlayToggle} aria-label={playing ? "暂停" : "播放"} title={playing ? "暂停" : "播放"}>{playing ? <Pause className="size-3.5" /> : <Play className="size-3.5" />}</button>
<span className="w-14 text-right text-[var(--fs-caption)] font-medium tabular-nums text-white/75">{formatTime(playhead)}</span>
<span className="text-[var(--fs-micro)] text-white/35">/ {formatTime(duration)} · {fps}fps</span>
<span className="mx-1 h-4 w-px bg-white/10" />
<select className="director-sequencer-shot-select" value={shot.id} aria-label="当前镜头" onChange={(event) => onSelectShot(event.target.value)}>
{scene.shots.map((item, index) => <option key={item.id} value={item.id}>{index + 1}. {item.name}</option>)}
</select>
<button type="button" className={`director-sequencer-tool ${autoKey ? "is-active" : ""}`} onClick={() => onAutoKeyChange(!autoKey)} aria-pressed={autoKey} title="自动关键帧"><KeyRound className="size-3.5" /><span>自动关键帧</span></button>
<button type="button" className={`director-sequencer-tool ${snapEnabled ? "is-active" : ""}`} title="吸附到帧" aria-pressed={snapEnabled} onClick={() => setSnapEnabled((value) => !value)}><Magnet className="size-3.5" /><span>吸附</span></button>
<button type="button" className="director-sequencer-tool" title="记录当前关键帧" onClick={onRecordKeyframe}><KeyRound className="size-3.5" /><span>记录</span></button>
<span className="ml-auto flex items-center gap-1">
<button type="button" className="director-sequencer-icon" title="缩小时间轴" aria-label="缩小时间轴" onClick={() => setTimelineScale((value) => Math.max(0.75, value - 0.25))}><ZoomOut className="size-3.5" /></button>
<button type="button" className="director-sequencer-icon" title="放大时间轴" aria-label="放大时间轴" onClick={() => setTimelineScale((value) => Math.min(2.5, value + 0.25))}><ZoomIn className="size-3.5" /></button>
<button type="button" className={`director-sequencer-icon ${showDetails ? "is-active" : ""}`} title="显示子轨道" aria-label="显示子轨道" aria-pressed={showDetails} onClick={() => setShowDetails((value) => !value)}><Rows3 className="size-3.5" /></button>
<button type="button" className="director-sequencer-icon" title="新增镜头" aria-label="新增镜头" onClick={onAddShot}><Plus className="size-3.5" /></button>
</span>
</header>
<div className="director-sequencer-body thin-scrollbar overflow-auto">
<div className="director-sequencer-grid" style={{ "--director-sequencer-duration": duration, "--director-sequencer-track-scale": timelineScale } as CSSProperties}>
<span className="director-sequencer-global-playhead"><i style={{ left: `${(playhead / duration) * 100}%` }} /></span>
<div className="director-sequencer-label director-sequencer-ruler-label">轨道</div>
<div className="director-sequencer-ruler" onPointerDown={setTimeFromPointer}>
{ticks.map((tick) => <span key={tick} className="director-sequencer-tick" style={{ left: `${(tick / duration) * 100}%` }}>{tick}s</span>)}
<span className="director-sequencer-playhead" style={{ left: `${(playhead / duration) * 100}%` }} />
</div>
<SequencerRow label="镜头总轨" icon="◈" selected={false} keys={[]} onClick={() => undefined}>
<TrackBar duration={duration} color="#7da2ff" label={`${shot.name} · ${formatTime(duration)}`} />
</SequencerRow>
<SequencerRow label="Camera Cut" icon="▣" selected={false} keys={cameraKeys} onClick={() => undefined}>
<TrackKeys duration={duration} keys={cameraKeys} />
</SequencerRow>
{camera ? <SequencerRow label={camera.name} icon="⌾" selected={!selectedObjectId} keys={cameraKeys} onClick={() => { onSelectObject(null); onSelectBone(null); }}>
<TrackBar duration={duration} color="#78a9ff" label="Transform · 焦距 · 景深" />
<TrackKeys duration={duration} keys={cameraKeys} />
</SequencerRow> : null}
{actorObjects.map((object) => {
const isExpanded = expanded[object.id] ?? object.id === selectedObjectId;
const activeClip = object.motionClips?.find((clip) => clip.id === object.activeMotionClipId);
const boneTrackKeys = object.boneTracks?.flatMap((track) => track.keyframes.map((key) => ({ id: `${track.bone}-${key.id}`, time: key.time, color: "#f0b36a" }))) || [];
const transformKeys = object.keyframes.map((key) => ({ id: key.id, time: key.time, color: "#61d2ad" }));
return <div key={object.id} className="contents">
<SequencerRow label={object.name} icon={isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />} selected={selectedObjectId === object.id && !selectedBone} keys={transformKeys} onClick={() => { onSelectObject(object.id); onSelectBone(null); setExpanded((current) => ({ ...current, [object.id]: !isExpanded })); }}>
<TrackKeys duration={duration} keys={[...transformKeys, ...boneTrackKeys]} />
</SequencerRow>
{isExpanded && showDetails ? <>
<SequencerRow label="动作片段" icon="▶" indent selected={selectedObjectId === object.id && !selectedBone} keys={[]} onClick={() => onSelectObject(object.id)}>
<TrackBar duration={duration} color="#61d2ad" label={activeClip ? `${activeClip.name}${activeClip.loop ? " · 循环" : ""}` : "姿势 / 动作"} start={activeClip?.start || 0} clipDuration={activeClip?.loop ? duration : activeClip?.duration || duration} />
</SequencerRow>
<SequencerRow label="Transform" icon="◇" indent selected={false} keys={transformKeys} onClick={() => onSelectObject(object.id)}>
<TrackKeys duration={duration} keys={transformKeys} />
</SequencerRow>
{object.boneTracks?.map((track) => {
const keys = track.keyframes.map((key) => ({ id: key.id, time: key.time, color: "#f0b36a" }));
return <SequencerRow key={track.bone} label={directorBoneLabel(track.bone)} icon="◌" indent selected={selectedBone === track.bone} keys={keys} onClick={() => { onSelectObject(object.id); onSelectBone(track.bone); }}><TrackKeys duration={duration} keys={keys} /></SequencerRow>;
})}
</> : null}
</div>;
})}
{objects.filter((object) => !actorObjects.some((actor) => actor.id === object.id)).map((object) => {
const keys = object.keyframes.map((key) => ({ id: key.id, time: key.time, color: "#b8c0ca" }));
return <SequencerRow key={object.id} label={object.name} icon="□" selected={selectedObjectId === object.id} keys={keys} onClick={() => { onSelectObject(object.id); onSelectBone(null); }}><TrackKeys duration={duration} keys={keys} /></SequencerRow>;
})}
</div>
</div>
</section>
);
}
function SequencerRow({ label, icon, selected, indent, children, onClick }: { label: string; icon: ReactNode; selected: boolean; indent?: boolean; keys?: TrackKey[]; children: ReactNode; onClick: () => void }) {
return <div className={`director-sequencer-row ${selected ? "is-selected" : ""}`}>
<button type="button" className={`director-sequencer-label ${indent ? "is-indent" : ""}`} onClick={onClick}><span className="director-sequencer-row-icon">{icon}</span><span className="min-w-0 truncate">{label}</span></button>
<div className="director-sequencer-track">{children}</div>
</div>;
}
function TrackKeys({ duration, keys }: { duration: number; keys: TrackKey[] }) {
return <div className="director-sequencer-track-content">{keys.map((key) => <span key={key.id} className="director-sequencer-key" style={{ left: `${(key.time / duration) * 100}%`, background: key.color || "#d7dee8" }} title={`${key.time.toFixed(2)}s`} />)}</div>;
}
function TrackBar({ duration, color, label, start = 0, clipDuration }: { duration: number; color: string; label: string; start?: number; clipDuration?: number }) {
return <div className="director-sequencer-track-content"><span className="director-sequencer-clip" style={{ left: `${(start / duration) * 100}%`, width: `${((clipDuration ?? duration) / duration) * 100}%`, background: `${color}33`, borderColor: `${color}88`, color }}><span className="truncate">{label}</span></span></div>;
}
function formatTime(time: number) {
return `${time.toFixed(2)}s`;
}

View File

@@ -0,0 +1,49 @@
import { Tooltip } from "antd";
import { Bone, Box, Camera, Crosshair, Lightbulb, Move3D, Palette, Rotate3D, Scaling, UserRound } from "lucide-react";
import type { ReactNode } from "react";
import type { DirectorRenderMode } from "@/types/director";
type DirectorViewportDockProps = {
transformMode: "translate" | "rotate" | "scale";
renderMode: DirectorRenderMode;
onTransformModeChange: (mode: DirectorViewportDockProps["transformMode"]) => void;
onRenderModeChange: (mode: DirectorRenderMode) => void;
onAddActor: () => void;
onAddBox: () => void;
onAddLight: () => void;
onAddCamera: () => void;
onAlignCamera: () => void;
};
export function DirectorViewportDock({ transformMode, renderMode, onTransformModeChange, onRenderModeChange, onAddActor, onAddBox, onAddLight, onAddCamera, onAlignCamera }: DirectorViewportDockProps) {
return (
<nav className="director-viewport-dock" aria-label="导演台视口工具">
<DockButton label="移动对象" active={transformMode === "translate"} onClick={() => onTransformModeChange("translate")}><Move3D /></DockButton>
<DockButton label="旋转对象" active={transformMode === "rotate"} onClick={() => onTransformModeChange("rotate")}><Rotate3D /></DockButton>
<DockButton label="缩放对象" active={transformMode === "scale"} onClick={() => onTransformModeChange("scale")}><Scaling /></DockButton>
<DockDivider />
<DockButton label="添加演员" onClick={onAddActor}><UserRound /></DockButton>
<DockButton label="添加立方体" onClick={onAddBox}><Box /></DockButton>
<DockButton label="添加灯光" onClick={onAddLight}><Lightbulb /></DockButton>
<DockButton label="添加摄影机" onClick={onAddCamera}><Camera /></DockButton>
<DockButton label="摄影机对齐当前视图" onClick={onAlignCamera}><Crosshair /></DockButton>
<DockDivider />
<DockButton label="构图预览" active={renderMode === "beauty"} onClick={() => onRenderModeChange("beauty")}><Camera /></DockButton>
<DockButton label="彩色白膜" active={renderMode === "clay"} onClick={() => onRenderModeChange("clay")}><Palette /></DockButton>
<DockButton label="骨骼视图" active={renderMode === "pose"} onClick={() => onRenderModeChange("pose")}><Bone /></DockButton>
</nav>
);
}
function DockButton({ label, active, children, onClick }: { label: string; active?: boolean; children: ReactNode; onClick: () => void }) {
return (
<Tooltip title={label} placement="top">
<button type="button" className={`director-viewport-dock-button ${active ? "is-active" : ""}`} aria-label={label} aria-pressed={active} onClick={onClick}>{children}</button>
</Tooltip>
);
}
function DockDivider() {
return <span className="director-viewport-dock-divider" aria-hidden />;
}

View File

@@ -1,34 +1,42 @@
import { Canvas, useThree } from "@react-three/fiber";
import { Canvas, useFrame, 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 { forwardRef, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { AnimationClip, AnimationMixer, Box3, Bone, Color, Group, LoopOnce, LoopRepeat, Mesh, MeshBasicMaterial, MeshDepthMaterial, MeshNormalMaterial, MeshStandardMaterial, Object3D, PerspectiveCamera, Quaternion, Scene, SkeletonHelper, Texture, TextureLoader, Vector3, WebGLRenderer } from "three";
import type { Material } from "three";
import { GLTFLoader, SkeletonUtils } from "three-stdlib";
import { interpolateDirectorTransform } from "@/lib/canvas/director/director-scene";
import { directorPoseBoneDeltas, interpolateDirectorBoneRotation, interpolateDirectorTransform } from "@/lib/canvas/director/director-scene";
import { resolveMediaUrl } from "@/services/file-storage";
import type { DirectorCamera, DirectorLight, DirectorObject, DirectorRenderMode, DirectorScene, DirectorTransform } from "@/types/director";
import type { DirectorCamera, DirectorHumanoidBone, DirectorLight, DirectorObject, DirectorQuat, DirectorRenderMode, DirectorRig, DirectorScene, DirectorTransform, DirectorVec3 } from "@/types/director";
export type DirectorViewportHandle = {
capture: (mode: DirectorRenderMode) => Promise<Blob>;
recordVideo: (duration: number, fps: number) => Promise<Blob>;
readCameraTransform: () => DirectorTransform | null;
};
type DirectorViewportProps = {
scene: DirectorScene;
selectedObjectId: string | null;
selectedBone: string | null;
transformMode: "translate" | "rotate" | "scale";
renderMode: DirectorRenderMode;
playhead: number;
onSelectObject: (id: string | null) => void;
onSelectBone: (bone: string | null) => void;
onObjectTransform: (id: string, transform: DirectorTransform) => void;
onBoneTransform: (id: string, bone: string, rotation: DirectorQuat) => void;
onActorRigReady: (id: string, rig: DirectorRig, animations: AnimationClip[]) => void;
};
type CaptureContext = { gl: WebGLRenderer; scene: Scene; camera: PerspectiveCamera };
type CaptureContext = { gl: WebGLRenderer; scene: Scene; camera: PerspectiveCamera; suspendDisplayMaterialOverride: () => () => void };
export const DirectorViewport = forwardRef<DirectorViewportHandle, DirectorViewportProps>(function DirectorViewport(props, ref) {
const captureContext = useRef<CaptureContext | null>(null);
const onCaptureContext = useCallback((context: CaptureContext) => { captureContext.current = context; }, []);
useImperativeHandle(ref, () => ({
capture: (mode) => captureFrame(captureContext.current, mode),
recordVideo: (duration, fps) => recordCanvas(captureContext.current, duration, fps),
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;
@@ -45,21 +53,30 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, DirectorViewp
onPointerMissed={() => props.onSelectObject(null)}
>
<Suspense fallback={null}>
<DirectorSceneContent {...props} onCaptureContext={(context) => (captureContext.current = context)} />
<DirectorSceneContent {...props} onCaptureContext={onCaptureContext} />
</Suspense>
</Canvas>
);
});
function DirectorSceneContent({ scene, selectedObjectId, transformMode, renderMode, playhead, onSelectObject, onObjectTransform, onCaptureContext }: DirectorViewportProps & { onCaptureContext: (context: CaptureContext) => void }) {
function DirectorSceneContent({ scene, selectedObjectId, selectedBone, transformMode, renderMode, playhead, onSelectObject, onSelectBone, onObjectTransform, onBoneTransform, onActorRigReady, onCaptureContext }: DirectorViewportProps & { onCaptureContext: (context: CaptureContext) => void }) {
const { gl, camera, scene: threeScene, invalidate } = useThree();
const [transforming, setTransforming] = useState(false);
const displayClayRestoreRef = useRef<(() => void) | null>(null);
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];
const suspendDisplayMaterialOverride = useCallback(() => {
const suspended = Boolean(displayClayRestoreRef.current);
displayClayRestoreRef.current?.();
displayClayRestoreRef.current = null;
return () => {
if (suspended) displayClayRestoreRef.current = applyClaySceneMaterials(threeScene);
};
}, [threeScene]);
useEffect(() => {
onCaptureContext({ gl, camera: camera as PerspectiveCamera, scene: threeScene });
}, [camera, gl, onCaptureContext, threeScene]);
onCaptureContext({ gl, camera: camera as PerspectiveCamera, scene: threeScene, suspendDisplayMaterialOverride });
}, [camera, gl, onCaptureContext, suspendDisplayMaterialOverride, threeScene]);
useEffect(() => {
threeScene.background = new Color(scene.background);
@@ -67,14 +84,17 @@ function DirectorSceneContent({ scene, selectedObjectId, transformMode, renderMo
}, [invalidate, scene.background, threeScene]);
useEffect(() => {
const material = renderMode === "depth" ? new MeshDepthMaterial() : renderMode === "normal" ? new MeshNormalMaterial() : null;
const material = renderMode === "depth" ? new MeshDepthMaterial() : renderMode === "normal" ? new MeshNormalMaterial() : renderMode === "pose" ? new MeshBasicMaterial({ color: "#ffffff", wireframe: true }) : null;
if (renderMode === "clay") displayClayRestoreRef.current = applyClaySceneMaterials(threeScene);
threeScene.overrideMaterial = material;
invalidate();
return () => {
if (threeScene.overrideMaterial === material) threeScene.overrideMaterial = null;
displayClayRestoreRef.current?.();
displayClayRestoreRef.current = null;
material?.dispose();
};
}, [invalidate, renderMode, threeScene]);
}, [invalidate, renderMode, scene.objects, threeScene]);
return (
<>
@@ -91,11 +111,15 @@ function DirectorSceneContent({ scene, selectedObjectId, transformMode, renderMo
key={object.id}
object={object}
selected={selectedObjectId === object.id}
selectedBone={selectedObjectId === object.id ? selectedBone : null}
transformMode={transformMode}
playhead={playhead}
onSelect={() => onSelectObject(object.id)}
onSelectBone={(bone) => { onSelectObject(object.id); onSelectBone(bone); }}
onTransforming={setTransforming}
onTransform={(transform) => onObjectTransform(object.id, transform)}
onBoneTransform={(bone, rotation) => onBoneTransform(object.id, bone, rotation)}
onActorRigReady={(rig, animations) => onActorRigReady(object.id, rig, animations)}
/>
))}
<OrbitControls makeDefault enabled={!transforming} target={activeCamera?.target || [0, 1, 0]} minDistance={0.6} maxDistance={80} />
@@ -121,7 +145,7 @@ function CameraSync({ camera, playhead }: { camera?: DirectorCamera; playhead: n
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 }) {
function DirectorObjectView({ object, selected, selectedBone, transformMode, playhead, onSelect, onSelectBone, onTransforming, onTransform, onBoneTransform, onActorRigReady }: { object: DirectorObject; selected: boolean; selectedBone: string | null; transformMode: DirectorViewportProps["transformMode"]; playhead: number; onSelect: () => void; onSelectBone: (bone: string | null) => void; onTransforming: (value: boolean) => void; onTransform: (transform: DirectorTransform) => void; onBoneTransform: (bone: string, rotation: DirectorQuat) => void; onActorRigReady: (rig: DirectorRig, animations: AnimationClip[]) => void }) {
const groupRef = useRef<Group>(null);
const transform = interpolateDirectorTransform(object.transform, object.keyframes, playhead);
const content = (
@@ -135,7 +159,7 @@ function DirectorObjectView({ object, selected, transformMode, playhead, onSelec
onSelect();
}}
>
<DirectorObjectVisual object={object} selected={selected} />
<DirectorObjectVisual object={object} selected={selected} selectedBone={selectedBone} playhead={playhead} onSelectBone={onSelectBone} onBoneTransform={onBoneTransform} onActorRigReady={onActorRigReady} />
</group>
);
if (!selected) return content;
@@ -156,8 +180,8 @@ function DirectorObjectView({ object, selected, transformMode, playhead, onSelec
);
}
function DirectorObjectVisual({ object, selected }: { object: DirectorObject; selected: boolean }) {
if (object.kind === "model" && object.url) return <DirectorModel object={object} selected={selected} />;
function DirectorObjectVisual({ object, selected, selectedBone, playhead, onSelectBone, onBoneTransform, onActorRigReady }: { object: DirectorObject; selected: boolean; selectedBone: string | null; playhead: number; onSelectBone: (bone: string | null) => void; onBoneTransform: (bone: string, rotation: DirectorQuat) => void; onActorRigReady: (rig: DirectorRig, animations: AnimationClip[]) => void }) {
if ((object.kind === "model" || object.kind === "actor") && object.url) return <DirectorModel object={object} selected={selected} selectedBone={selectedBone} playhead={playhead} onSelectBone={onSelectBone} onBoneTransform={onBoneTransform} onActorRigReady={onActorRigReady} />;
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} />;
@@ -188,35 +212,210 @@ function Limb({ position, rotation, color, length = 0.68 }: { position: [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 }) {
function DirectorModel({ object, selected, selectedBone, playhead, onSelectBone, onBoneTransform, onActorRigReady }: { object: DirectorObject; selected: boolean; selectedBone: string | null; playhead: number; onSelectBone: (bone: string | null) => void; onBoneTransform: (bone: string, rotation: DirectorQuat) => void; onActorRigReady: (rig: DirectorRig, animations: AnimationClip[]) => void }) {
const [model, setModel] = useState<Object3D | null>(null);
const [animations, setAnimations] = useState<AnimationClip[]>([]);
const [rig, setRig] = useState<DirectorRig | null>(null);
const [restRotations, setRestRotations] = useState<Partial<Record<DirectorHumanoidBone, DirectorQuat>>>({});
const mixerRef = useRef<AnimationMixer | null>(null);
const onActorRigReadyRef = useRef(onActorRigReady);
const invalidate = useThree((state) => state.invalidate);
const helper = useMemo(() => model ? new SkeletonHelper(model) : null, [model]);
const selectedBoneObject = selectedBone && rig?.boneMap[selectedBone as DirectorHumanoidBone] ? model?.getObjectByName(rig.boneMap[selectedBone as DirectorHumanoidBone]!) : null;
const motion = object.motionClips?.find((item) => item.id === object.activeMotionClipId);
const activeAnimation = motion ? animations.find((item) => item.name === motion.sourceAnimation) : undefined;
useEffect(() => { onActorRigReadyRef.current = onActorRigReady; }, [onActorRigReady]);
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");
const next = SkeletonUtils.clone(gltf.scene);
normalizeModel(next, object.castShadow, object.receiveShadow);
const nextRig = inferDirectorRig(next, gltf.animations.map((clip) => clip.name));
if (object.kind === "actor") applyActorReferenceMaterial(next, object.color);
mixerRef.current = new AnimationMixer(next);
setRig(nextRig);
setRestRotations(readRigRestRotations(next, nextRig));
setAnimations(gltf.animations);
setModel(next);
onActorRigReadyRef.current(nextRig, gltf.animations);
}, 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} />;
return () => {
active = false;
mixerRef.current?.stopAllAction();
mixerRef.current = null;
};
}, [object.kind, object.storageKey, object.url]);
useEffect(() => {
if (!model) return;
model.traverse((child) => {
const mesh = child as Mesh;
if (!mesh.isMesh) return;
mesh.castShadow = object.castShadow;
mesh.receiveShadow = object.receiveShadow;
});
invalidate();
}, [invalidate, model, object.castShadow, object.receiveShadow]);
useEffect(() => {
if (!model || object.kind !== "actor") return;
updateActorReferenceColor(model, object.color);
invalidate();
}, [invalidate, model, object.color, object.kind]);
useEffect(() => {
if (!model || !mixerRef.current) return;
const mixer = mixerRef.current;
mixer.stopAllAction();
if (!activeAnimation) return;
mixer.clipAction(activeAnimation).setLoop(motion?.loop ? LoopRepeat : LoopOnce, motion?.loop ? Infinity : 1).play();
return () => mixer.stopAllAction();
}, [activeAnimation, model, motion?.loop]);
useEffect(() => {
if (!model || !mixerRef.current) return;
if (activeAnimation && motion) {
const localTime = Math.max(0, playhead - motion.start) * motion.playbackRate;
const clipDuration = motion.duration || activeAnimation.duration;
mixerRef.current.setTime(motion.loop && clipDuration > 0 ? localTime % clipDuration : localTime);
}
applyDirectorBoneTracks(model, object, playhead, rig, restRotations, Boolean(activeAnimation && motion));
helper?.updateMatrixWorld(true);
invalidate();
}, [activeAnimation, helper, invalidate, model, motion, object.boneOverrides, object.boneTracks, object.pose, playhead, restRotations, rig]);
useFrame(() => {
if (selectedBoneObject && selected) selectedBoneObject.updateMatrixWorld(true);
});
if (!model) return <DirectorMannequin color={object.color} selected={selected} />;
return <group>
<primitive object={model} />
{selected && helper ? <primitive object={helper} /> : null}
{selected && rig ? Object.entries(rig.boneMap).filter(([, name]) => Boolean(name)).map(([bone, name]) => <BoneController key={bone} bone={model.getObjectByName(name!)} model={model} selected={selectedBone === bone} onSelect={() => onSelectBone(bone)} />) : null}
{selected && selectedBoneObject ? <TransformControls object={selectedBoneObject} mode="rotate" size={0.55} onMouseDown={(event) => { event.stopPropagation(); }} onMouseUp={() => onBoneTransform(selectedBone!, selectedBoneObject.quaternion.toArray() as DirectorQuat)} /> : null}
</group>;
}
function DirectorMannequin({ color, selected }: { color: string; selected: boolean }) {
const resolvedColor = selected ? new Color(color).lerp(new Color("#78a9ff"), 0.18).getStyle() : color;
const joints: DirectorVec3[] = [[0, 1.72, 0], [0, 1.48, 0], [-0.3, 1.4, 0], [0.3, 1.4, 0], [-0.32, 1.05, 0], [0.32, 1.05, 0], [-0.33, 0.72, 0], [0.33, 0.72, 0], [-0.13, 0.88, 0], [0.13, 0.88, 0], [-0.13, 0.46, 0], [0.13, 0.46, 0], [-0.13, 0.05, 0], [0.13, 0.05, 0]];
const bones: Array<[DirectorVec3, DirectorVec3]> = [[joints[0], joints[1]], [joints[1], joints[2]], [joints[1], joints[3]], [joints[2], joints[4]], [joints[4], joints[6]], [joints[3], joints[5]], [joints[5], joints[7]], [joints[1], [0, 0.92, 0]], [[0, 0.92, 0], joints[8]], [[0, 0.92, 0], joints[9]], [joints[8], joints[10]], [joints[10], joints[12]], [joints[9], joints[11]], [joints[11], joints[13]]];
return <group>
{bones.map(([from, to], index) => <LoadingBone key={`bone-${index}`} from={from} to={to} color={resolvedColor} />)}
{joints.map((position, index) => <mesh key={`joint-${index}`} position={position}><sphereGeometry args={[index === 0 ? 0.11 : 0.04, 12, 8]} /><meshBasicMaterial color={resolvedColor} transparent opacity={0.72} /></mesh>)}
</group>;
}
function LoadingBone({ from, to, color }: { from: DirectorVec3; to: DirectorVec3; color: string }) {
const start = useMemo(() => new Vector3(...from), [from]);
const end = useMemo(() => new Vector3(...to), [to]);
const direction = useMemo(() => end.clone().sub(start), [end, start]);
const midpoint = useMemo(() => start.clone().add(end).multiplyScalar(0.5), [end, start]);
const rotation = useMemo(() => new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), direction.clone().normalize()), [direction]);
return <mesh position={midpoint} quaternion={rotation}><cylinderGeometry args={[0.025, 0.025, direction.length(), 8]} /><meshBasicMaterial color={color} transparent opacity={0.62} /></mesh>;
}
function BoneController({ bone, model, selected, onSelect }: { bone: Object3D | undefined; model: Object3D; selected: boolean; onSelect: () => void }) {
const ref = useRef<Group>(null);
const world = useMemo(() => new Vector3(), []);
useFrame(() => {
if (!bone || !ref.current) return;
bone.getWorldPosition(world);
model.worldToLocal(world);
ref.current.position.copy(world);
});
if (!bone) return null;
return <group ref={ref} onPointerDown={(event) => { event.stopPropagation(); onSelect(); }}><mesh><sphereGeometry args={[selected ? 0.07 : 0.045, 12, 8]} /><meshBasicMaterial color={selected ? "#f0b36a" : "#78a9ff"} depthTest={false} transparent opacity={selected ? 1 : 0.75} /></mesh></group>;
}
function normalizeModel(root: Object3D, castShadow: boolean, receiveShadow: boolean) {
root.updateMatrixWorld(true);
const bounds = new Box3().setFromObject(root, true);
const size = bounds.getSize(new Vector3());
const maxSize = Math.max(size.x, size.y, size.z, 0.001);
root.scale.multiplyScalar(2 / maxSize);
root.updateMatrixWorld(true);
const centered = new Box3().setFromObject(root, true);
const center = centered.getCenter(new Vector3());
root.position.sub(center);
root.position.y -= centered.min.y - center.y;
root.traverse((child) => {
const mesh = child as Mesh;
if (!mesh.isMesh) return;
mesh.castShadow = castShadow;
mesh.receiveShadow = receiveShadow;
});
}
function applyActorReferenceMaterial(root: Object3D, color: string) {
const material = new MeshStandardMaterial({ color, roughness: 0.74, metalness: 0.02 });
root.traverse((child) => {
const mesh = child as Mesh;
if (!mesh.isMesh) return;
mesh.material = material;
mesh.userData.directorActor = true;
mesh.userData.directorActorMaterial = material;
});
}
function updateActorReferenceColor(root: Object3D, color: string) {
root.traverse((child) => {
const mesh = child as Mesh;
if (!mesh.isMesh || !mesh.userData.directorActor) return;
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
materials.forEach((material) => {
if (material instanceof MeshStandardMaterial) material.color.set(color);
});
});
}
function readRigRestRotations(root: Object3D, rig: DirectorRig) {
return Object.fromEntries(Object.entries(rig.boneMap).flatMap(([bone, name]) => {
const target = name ? root.getObjectByName(name) : null;
return target ? [[bone, target.quaternion.toArray() as DirectorQuat]] : [];
})) as Partial<Record<DirectorHumanoidBone, DirectorQuat>>;
}
function inferDirectorRig(root: Object3D, animationNames: string[]): DirectorRig {
const names = new Map<string, string>();
root.traverse((child) => { if (child instanceof Bone) names.set(normalizeBoneName(child.name), child.name); });
const patterns: Record<DirectorHumanoidBone, RegExp[]> = {
root: [/^root$/, /armature/], hips: [/hips|pelvis/, /mixamorig.*hip/], spine: [/spine1?$|lowerback/], chest: [/spine2|chest|upperback/], neck: [/neck/], head: [/head/],
leftShoulder: [/leftshoulder|shoulder_l|mixamorigleftshoulder/], leftUpperArm: [/leftupperarm|leftarm|upperarm_l|mixamorigleftarm/], leftLowerArm: [/leftforearm|leftlowerarm|forearm_l|mixamorigleftforearm/], leftHand: [/lefthand|hand_l|mixamoriglefthand/],
rightShoulder: [/rightshoulder|shoulder_r|mixamorigrightshoulder/], rightUpperArm: [/rightupperarm|rightarm|upperarm_r|mixamorigrightarm/], rightLowerArm: [/rightforearm|rightlowerarm|forearm_r|mixamorigrightforearm/], rightHand: [/righthand|hand_r|mixamorigrighthand/],
leftUpperLeg: [/leftupleg|leftthigh|thigh_l|mixamorigleftupleg/], leftLowerLeg: [/leftleg|leftcalf|calf_l|mixamorigleftleg/], leftFoot: [/leftfoot|foot_l|mixamorigleftfoot/], rightUpperLeg: [/rightupleg|rightthigh|thigh_r|mixamorigrightupleg/], rightLowerLeg: [/rightleg|rightcalf|calf_r|mixamorigrightleg/], rightFoot: [/rightfoot|foot_r|mixamorigrightfoot/],
};
const boneMap = Object.fromEntries(Object.entries(patterns).map(([bone, candidates]) => [bone, candidates.map((pattern) => [...names.entries()].find(([normalized]) => pattern.test(normalized))?.[1]).find(Boolean)]).filter(([, name]) => Boolean(name))) as DirectorRig["boneMap"];
return { status: Object.keys(boneMap).length >= 8 ? "ready" : "unmapped", boneMap, animationNames };
}
function normalizeBoneName(name: string) {
return name.toLowerCase().replace(/[^a-z0-9]/g, "");
}
function applyDirectorBoneTracks(model: Object3D, object: DirectorObject, playhead: number, rig: DirectorRig | null, restRotations: Partial<Record<DirectorHumanoidBone, DirectorQuat>>, hasActiveMotion: boolean) {
if (!rig) return;
const poseDeltas = hasActiveMotion ? {} : directorPoseBoneDeltas(object.pose || "stand");
Object.entries(rig.boneMap).forEach(([bone, name]) => {
const target = name ? model.getObjectByName(name) : null;
if (!target) return;
const humanoidBone = bone as DirectorHumanoidBone;
if (!hasActiveMotion) {
const rest = restRotations[humanoidBone];
if (rest) target.quaternion.copy(new Quaternion(...rest));
const delta = poseDeltas[humanoidBone];
if (delta) target.quaternion.multiply(new Quaternion(...delta));
}
const override = object.boneOverrides?.[bone as DirectorHumanoidBone];
const track = object.boneTracks?.find((item) => item.bone === bone);
const rotation = track ? interpolateDirectorBoneRotation(override || target.quaternion.toArray() as DirectorQuat, track.keyframes, playhead) : override;
if (rotation) target.quaternion.copy(new Quaternion(...rotation));
});
}
function DirectorBillboard({ object, selected }: { object: DirectorObject; selected: boolean }) {
@@ -246,42 +445,73 @@ function characterPose(pose: NonNullable<DirectorObject["pose"]>): Record<"leftA
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] };
if (pose === "fight" || pose === "throw" || pose === "push") 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 resumeDisplayMaterialOverride = context.suspendDisplayMaterialOverride();
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;
const override = mode === "depth" ? new MeshDepthMaterial() : mode === "normal" ? new MeshNormalMaterial() : mode === "pose" ? new MeshBasicMaterial({ color: "#ffffff", wireframe: true }) : null;
const restoreClayMaterials = mode === "clay" ? applyClaySceneMaterials(scene) : null;
try {
scene.overrideMaterial = override;
gl.render(scene, camera);
return await canvasToBlob(gl.domElement);
} finally {
scene.overrideMaterial = previous;
restoreClayMaterials?.();
override?.dispose();
resumeDisplayMaterialOverride();
gl.render(scene, camera);
}
}
async function recordCanvas(context: CaptureContext | null, duration: number, fps: number) {
if (!context) throw new Error("3D 视口尚未就绪");
if (!context.gl.domElement.captureStream || typeof MediaRecorder === "undefined") throw new Error("当前浏览器不支持视频录制,请导出帧序列");
const resumeDisplayMaterialOverride = context.suspendDisplayMaterialOverride();
const previousMaterial = context.scene.overrideMaterial;
const restoreClayMaterials = applyClaySceneMaterials(context.scene);
context.scene.overrideMaterial = null;
context.gl.render(context.scene, context.camera);
const stream = context.gl.domElement.captureStream(fps);
const mimeType = ["video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm"].find((type) => MediaRecorder.isTypeSupported(type)) || "";
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
const chunks: Blob[] = [];
const result = new Promise<Blob>((resolve, reject) => {
recorder.ondataavailable = (event) => { if (event.data.size) chunks.push(event.data); };
recorder.onerror = () => reject(new Error("白膜视频录制失败"));
recorder.onstop = () => resolve(new Blob(chunks, { type: recorder.mimeType || "video/webm" }));
});
recorder.start();
window.setTimeout(() => recorder.stop(), Math.max(250, duration * 1000 + 120));
try {
return await result;
} finally {
stream.getTracks().forEach((track) => track.stop());
restoreClayMaterials();
context.scene.overrideMaterial = previousMaterial;
resumeDisplayMaterialOverride();
context.gl.render(context.scene, context.camera);
}
}
function applyClaySceneMaterials(scene: Scene) {
const clayMaterial = new MeshStandardMaterial({ color: "#d6d9dd", roughness: 0.88, metalness: 0 });
const originals: Array<{ mesh: Mesh; material: Material | Material[] }> = [];
scene.traverse((child) => {
const mesh = child as Mesh;
if (!mesh.isMesh || mesh.userData.directorActor) return;
originals.push({ mesh, material: mesh.material });
mesh.material = clayMaterial;
});
return () => {
originals.forEach(({ mesh, material }) => { mesh.material = material; });
clayMaterial.dispose();
};
}
function canvasToBlob(canvas: HTMLCanvasElement) {

View File

@@ -1,4 +1,5 @@
import type { DirectorCamera, DirectorObject, DirectorScene, DirectorShot } from "@/types/director";
import { directorColorLabel, directorPoseLabel } from "@/lib/canvas/director/director-scene";
const shotSizeLabels: Record<DirectorShot["shotSize"], string> = {
extreme_wide: "大远景",
@@ -25,10 +26,12 @@ const cameraMoveLabels: Record<DirectorShot["cameraMove"], string> = {
export function compileDirectorPrompt(scene: DirectorScene, shot: DirectorShot) {
const camera = scene.cameras.find((item) => item.id === shot.cameraId) || scene.cameras[0];
const visibleObjects = scene.objects.filter((item) => item.visible);
const actors = visibleObjects.filter((item) => item.kind === "actor");
return [
shot.prompt.trim(),
`镜头设计:${shotSizeLabels[shot.shotSize]},${cameraMoveLabels[shot.cameraMove]},时长 ${formatNumber(shot.duration)} 秒。`,
camera ? cameraPrompt(camera) : "",
actors.length ? `角色颜色映射:${actors.map((actor) => `${directorColorLabel(actor.color)}人偶(${actor.color})代表${actor.name}`).join(";")}。生成视频时严格按颜色识别角色,不交换人物身份。` : "",
visibleObjects.length ? `空间调度:${visibleObjects.map(objectPrompt).join(";")}。` : "",
`灯光:${scene.lights.map((light) => `${light.name}${formatNumber(light.intensity)}强度${light.color}`).join(",")}。`,
"保持角色、道具、空间方向、光线方向和镜头轴线连续,遵循真实摄影机透视与物理遮挡。",
@@ -48,12 +51,9 @@ function cameraPrompt(camera: DirectorCamera) {
function objectPrompt(object: DirectorObject) {
const [x, y, z] = object.transform.position;
const pose = object.pose ? `,姿势${poseLabel(object.pose)}` : "";
return `${object.name}位于 (${formatNumber(x)}, ${formatNumber(y)}, ${formatNumber(z)})${pose}`;
}
function poseLabel(pose: NonNullable<DirectorObject["pose"]>) {
return { neutral: "自然", stand: "站立", walk: "行走", run: "奔跑", sit: "坐姿", action: "动作姿态" }[pose];
const pose = object.pose ? `,姿势${directorPoseLabel(object.pose)}` : "";
const color = object.kind === "actor" ? `,${directorColorLabel(object.color)}参考人偶` : "";
return `${object.name}${color}位于 (${formatNumber(x)}, ${formatNumber(y)}, ${formatNumber(z)})${pose}`;
}
function formatNumber(value: number) {

View File

@@ -1,6 +1,10 @@
import { nanoid } from "nanoid";
import { Color, Euler, Quaternion } from "three";
import type { DirectorCamera, DirectorKeyframe, DirectorLight, DirectorObject, DirectorScene, DirectorTransform, DirectorVec3 } from "@/types/director";
import type { DirectorBoneKeyframe, DirectorBoneTrack, DirectorCamera, DirectorHumanoidBone, DirectorKeyframe, DirectorLight, DirectorObject, DirectorPose, DirectorQuat, DirectorScene, DirectorTransform, DirectorVec3 } from "@/types/director";
export const DIRECTOR_DEFAULT_ACTOR_URL = "https://cdn.jsdelivr.net/gh/mrdoob/three.js@r185/examples/models/gltf/Soldier.glb";
export const DIRECTOR_ACTOR_COLORS = ["#f1f3f5", "#202329", "#2f7de1", "#d84949", "#dfae3f", "#34a276"] as const;
export const directorIdentityTransform = (position: DirectorVec3 = [0, 0, 0]): DirectorTransform => ({ position, rotation: [0, 0, 0], scale: [1, 1, 1] });
@@ -16,12 +20,12 @@ export function createDirectorScene(title = "未命名场景"): DirectorScene {
environmentIntensity: 0.7,
gridVisible: true,
objects: [
createDirectorObject("character", "演员", [0, 0.9, 0], "#9aa6b2"),
createDirectorActor("演员 1", [0, 0, 0]),
createDirectorObject("box", "前景道具", [1.8, 0.45, 0.8], "#8b7355"),
],
cameras: [camera],
lights: [createDirectorLight("directional", "主光", [4, 6, 4], 2.4), createDirectorLight("directional", "轮廓光", [-4, 3, -2], 1.1), createDirectorLight("ambient", "环境光", [0, 0, 0], 0.65)],
shots: [{ id: shotId, name: "镜头 1", cameraId: camera.id, duration: 5, shotSize: "medium", cameraMove: "static", prompt: "" }],
shots: [{ id: shotId, name: "镜头 1", cameraId: camera.id, duration: 5, fps: 24, shotSize: "medium", cameraMove: "static", prompt: "" }],
activeShotId: shotId,
createdAt: now,
updatedAt: now,
@@ -44,6 +48,21 @@ export function createDirectorObject(primitive: DirectorObject["primitive"] = "b
};
}
export function createDirectorActor(name = "演员", position: DirectorVec3 = [0, 0, 0], color = DIRECTOR_ACTOR_COLORS[0]): DirectorObject {
return {
...createDirectorObject("box", name, position, color),
kind: "actor",
primitive: undefined,
url: DIRECTOR_DEFAULT_ACTOR_URL,
mimeType: "model/gltf-binary",
pose: "stand",
rig: { status: "unmapped", boneMap: {}, animationNames: [] },
motionClips: [],
boneOverrides: {},
boneTracks: [],
};
}
export function createDirectorModel(input: Pick<DirectorObject, "name" | "storageKey" | "url" | "mimeType" | "assetId">): DirectorObject {
return { ...createDirectorObject("box", input.name, [0, 0, 0]), ...input, kind: "model", primitive: undefined };
}
@@ -70,19 +89,99 @@ export function upsertDirectorKeyframe(keyframes: DirectorKeyframe[], time: numb
return next.toSorted((a, b) => a.time - b.time);
}
export function upsertDirectorBoneKeyframe(tracks: DirectorBoneTrack[], bone: DirectorHumanoidBone, time: number, rotation: DirectorQuat) {
const track = tracks.find((item) => item.bone === bone);
const nextKeyframes = upsertBoneKeyframe(track?.keyframes || [], time, rotation);
return track ? tracks.map((item) => item.bone === bone ? { ...item, keyframes: nextKeyframes } : item) : [...tracks, { bone, keyframes: nextKeyframes }];
}
function upsertBoneKeyframe(keyframes: DirectorBoneKeyframe[], time: number, rotation: DirectorQuat) {
const current = keyframes.find((item) => Math.abs(item.time - time) < 0.001);
const next = current ? keyframes.map((item) => item.id === current.id ? { ...item, rotation } : item) : [...keyframes, { id: nanoid(), time, rotation }];
return next.toSorted((a, b) => a.time - b.time);
}
export function interpolateDirectorTransform(base: DirectorTransform, keyframes: DirectorKeyframe[], time: number): DirectorTransform {
if (!keyframes.length) return base;
const previous = [...keyframes].reverse().find((item) => item.time <= time) || keyframes[0];
const next = keyframes.find((item) => item.time >= time) || keyframes[keyframes.length - 1];
if (previous.id === next.id) return previous.transform;
const progress = Math.max(0, Math.min(1, (time - previous.time) / Math.max(next.time - previous.time, 0.001)));
const rotation = new Quaternion().setFromEuler(new Euler(...previous.transform.rotation)).slerp(new Quaternion().setFromEuler(new Euler(...next.transform.rotation)), progress);
return {
position: lerpVec3(previous.transform.position, next.transform.position, progress),
rotation: lerpVec3(previous.transform.rotation, next.transform.rotation, progress),
rotation: new Euler().setFromQuaternion(rotation).toArray().slice(0, 3) as DirectorVec3,
scale: lerpVec3(previous.transform.scale, next.transform.scale, progress),
};
}
export function interpolateDirectorBoneRotation(base: DirectorQuat, keyframes: DirectorBoneKeyframe[], time: number): DirectorQuat {
if (!keyframes.length) return base;
const previous = [...keyframes].reverse().find((item) => item.time <= time) || keyframes[0];
const next = keyframes.find((item) => item.time >= time) || keyframes[keyframes.length - 1];
if (previous.id === next.id) return previous.rotation;
const progress = Math.max(0, Math.min(1, (time - previous.time) / Math.max(next.time - previous.time, 0.001)));
return new Quaternion(...previous.rotation).slerp(new Quaternion(...next.rotation), progress).toArray() as DirectorQuat;
}
export function directorBoneLabel(bone: string) {
return ({ hips: "骨盆", spine: "脊柱", chest: "胸腔", neck: "颈部", head: "头部", leftShoulder: "左肩", leftUpperArm: "左上臂", leftLowerArm: "左前臂", leftHand: "左手", rightShoulder: "右肩", rightUpperArm: "右上臂", rightLowerArm: "右前臂", rightHand: "右手", leftUpperLeg: "左大腿", leftLowerLeg: "左小腿", leftFoot: "左脚", rightUpperLeg: "右大腿", rightLowerLeg: "右小腿", rightFoot: "右脚" } as Record<string, string>)[bone] || bone;
}
export function directorPoseLabel(pose: DirectorPose) {
return ({ neutral: "自然", stand: "站立", t_pose: "T 型", walk: "行走", run: "跑步", sit: "坐姿", squat: "蹲下", kneel_single: "单膝跪", kneel_double: "双膝跪", hands_hips: "叉腰", lean: "倚靠", bow: "鞠躬", think: "思考", fight: "格斗", kick: "踢球", throw: "投掷", push: "推进", wave: "招手", reach: "伸手", arms_crossed: "抱臂", phone: "看手机" } as Record<DirectorPose, string>)[pose];
}
export function directorPoseBoneDeltas(pose: DirectorPose): Partial<Record<DirectorHumanoidBone, DirectorQuat>> {
// Soldier 的左右上臂局部 Z 轴方向一致,正向旋转才会把两侧手臂从 T Pose 放下。
const armsDown = { leftUpperArm: poseQuaternion(0, 0, 1.28), rightUpperArm: poseQuaternion(0, 0, 1.28) };
const poses: Record<DirectorPose, Partial<Record<DirectorHumanoidBone, DirectorQuat>>> = {
neutral: armsDown,
stand: armsDown,
t_pose: {},
walk: { ...armsDown, leftUpperArm: poseQuaternion(0.36, 0, 1.2), rightUpperArm: poseQuaternion(-0.36, 0, 1.2), leftUpperLeg: poseQuaternion(-0.32, 0, 0), rightUpperLeg: poseQuaternion(0.32, 0, 0) },
run: { ...armsDown, leftUpperArm: poseQuaternion(0.75, 0, 1.05), rightUpperArm: poseQuaternion(-0.75, 0, 1.05), leftLowerArm: poseQuaternion(-0.7, 0, 0), rightLowerArm: poseQuaternion(-0.7, 0, 0), leftUpperLeg: poseQuaternion(-0.65, 0, 0), rightUpperLeg: poseQuaternion(0.55, 0, 0), rightLowerLeg: poseQuaternion(0.8, 0, 0) },
sit: { ...armsDown, leftUpperLeg: poseQuaternion(-1.35, 0, 0), rightUpperLeg: poseQuaternion(-1.35, 0, 0), leftLowerLeg: poseQuaternion(1.25, 0, 0), rightLowerLeg: poseQuaternion(1.25, 0, 0) },
squat: { ...armsDown, hips: poseQuaternion(0.25, 0, 0), leftUpperLeg: poseQuaternion(-0.75, 0, 0), rightUpperLeg: poseQuaternion(-0.75, 0, 0), leftLowerLeg: poseQuaternion(1.2, 0, 0), rightLowerLeg: poseQuaternion(1.2, 0, 0) },
kneel_single: { ...armsDown, leftUpperLeg: poseQuaternion(-0.95, 0, 0), leftLowerLeg: poseQuaternion(1.45, 0, 0), rightUpperLeg: poseQuaternion(-0.35, 0, 0), rightLowerLeg: poseQuaternion(0.75, 0, 0) },
kneel_double: { ...armsDown, leftUpperLeg: poseQuaternion(-0.55, 0, 0), rightUpperLeg: poseQuaternion(-0.55, 0, 0), leftLowerLeg: poseQuaternion(1.45, 0, 0), rightLowerLeg: poseQuaternion(1.45, 0, 0) },
hands_hips: { leftUpperArm: poseQuaternion(0, 0, 0.75), rightUpperArm: poseQuaternion(0, 0, 0.75), leftLowerArm: poseQuaternion(-0.1, 0.2, -1.5), rightLowerArm: poseQuaternion(-0.1, -0.2, 1.5) },
lean: { ...armsDown, hips: poseQuaternion(0, 0, 0.18), spine: poseQuaternion(0, 0, -0.12), head: poseQuaternion(0, 0, -0.08) },
bow: { ...armsDown, hips: poseQuaternion(0.5, 0, 0), spine: poseQuaternion(0.28, 0, 0), head: poseQuaternion(-0.18, 0, 0) },
think: { ...armsDown, rightUpperArm: poseQuaternion(-0.25, 0, 0.55), rightLowerArm: poseQuaternion(-1.35, 0, 0.3), head: poseQuaternion(0.05, -0.22, 0) },
fight: { leftUpperArm: poseQuaternion(-0.65, 0, 0.7), rightUpperArm: poseQuaternion(-0.55, 0, 0.65), leftLowerArm: poseQuaternion(-1.2, 0, 0), rightLowerArm: poseQuaternion(-1.25, 0, 0), chest: poseQuaternion(0, 0.2, 0), leftUpperLeg: poseQuaternion(-0.15, 0, 0), rightUpperLeg: poseQuaternion(0.2, 0, 0) },
kick: { ...armsDown, leftUpperArm: poseQuaternion(0.3, 0, 1.1), rightUpperArm: poseQuaternion(-0.3, 0, 1.1), rightUpperLeg: poseQuaternion(-1.1, 0, 0), rightLowerLeg: poseQuaternion(0.35, 0, 0) },
throw: { leftUpperArm: poseQuaternion(-0.35, 0.2, 0.35), rightUpperArm: poseQuaternion(-1.2, 0, 0.25), rightLowerArm: poseQuaternion(-1.05, 0, 0), chest: poseQuaternion(0, -0.3, 0) },
push: { leftUpperArm: poseQuaternion(-0.9, 0, 0.3), rightUpperArm: poseQuaternion(-0.9, 0, 0.3), leftLowerArm: poseQuaternion(-0.35, 0, 0), rightLowerArm: poseQuaternion(-0.35, 0, 0), chest: poseQuaternion(0.15, 0, 0) },
wave: { ...armsDown, rightUpperArm: poseQuaternion(0, 0, -0.35), rightLowerArm: poseQuaternion(0, 0, -1.45), rightHand: poseQuaternion(0, 0, -0.3) },
reach: { ...armsDown, rightUpperArm: poseQuaternion(-1.35, 0, -0.05), rightLowerArm: poseQuaternion(-0.1, 0, 0) },
arms_crossed: { leftUpperArm: poseQuaternion(-0.65, 0, 0.65), rightUpperArm: poseQuaternion(-0.65, 0, 0.65), leftLowerArm: poseQuaternion(-1.2, 0.15, -0.4), rightLowerArm: poseQuaternion(-1.2, -0.15, 0.4) },
phone: { ...armsDown, leftUpperArm: poseQuaternion(-0.45, 0, 0.95), rightUpperArm: poseQuaternion(-0.45, 0, 0.95), leftLowerArm: poseQuaternion(-1.1, 0, 0.15), rightLowerArm: poseQuaternion(-1.1, 0, -0.15), head: poseQuaternion(0.28, 0, 0) },
};
return poses[pose];
}
export function directorColorLabel(value: string) {
const hsl = { h: 0, s: 0, l: 0 };
new Color(value).getHSL(hsl);
if (hsl.l >= 0.86 && hsl.s <= 0.2) return "白色";
if (hsl.l <= 0.18) return "黑色";
if (hsl.s <= 0.16) return hsl.l >= 0.52 ? "浅灰色" : "深灰色";
const hue = hsl.h * 360;
if (hue < 18 || hue >= 345) return "红色";
if (hue < 48) return "橙色";
if (hue < 72) return "黄色";
if (hue < 165) return "绿色";
if (hue < 195) return "青色";
if (hue < 255) return "蓝色";
if (hue < 290) return "紫色";
return "粉色";
}
function poseQuaternion(x: number, y: number, z: number): DirectorQuat {
return new Quaternion().setFromEuler(new Euler(x, y, z)).toArray() as DirectorQuat;
}
function lerpVec3(from: DirectorVec3, to: DirectorVec3, progress: number): DirectorVec3 {
return from.map((value, index) => value + (to[index] - value) * progress) as DirectorVec3;
}

View File

@@ -2,11 +2,12 @@ import { useCallback, type Dispatch, type SetStateAction } from "react";
import { App } from "antd";
import { nanoid } from "nanoid";
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { imageMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
import { createDirectorScene } from "@/lib/canvas/director/director-scene";
import { uploadImage } from "@/services/image-storage";
import { uploadMediaFile } from "@/services/file-storage";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type Position } from "@/types/canvas";
import type { DirectorScene, DirectorSceneOutput } from "@/types/director";
@@ -94,50 +95,76 @@ export function useCanvasDirector({
const applyDirectorOutput = useCallback(async (output: DirectorSceneOutput) => {
const sourceNode = nodesRef.current.find((item) => item.id === directorNodeId);
if (!sourceNode) throw new Error("镜头节点不存在");
const uploads = await Promise.all([uploadImage(output.beauty), uploadImage(output.depth), uploadImage(output.normal)]);
const labels = ["导演台构图", "导演台深度", "导演台法线"];
const existingIds = [sourceNode.metadata?.directorPreviewNodeId, sourceNode.metadata?.directorDepthNodeId, sourceNode.metadata?.directorNormalNodeId];
const [image, videoUpload] = await Promise.all([
uploadImage(output.beauty),
output.clayVideo ? uploadMediaFile(output.clayVideo, "director-clay") : Promise.resolve(null),
]);
const previewId = sourceNode.metadata?.directorPreviewNodeId || `image-director-${Date.now()}`;
const previewSize = fitNodeSize(image.width, image.height);
const nextNodes = [...nodesRef.current];
const outputIds: string[] = [];
uploads.forEach((image, index) => {
const id = existingIds[index] || `image-director-${Date.now()}-${index}`;
const size = fitNodeSize(image.width, image.height);
const node: CanvasNodeData = {
id,
type: CanvasNodeType.Image,
title: `${sourceNode.title} · ${labels[index]}`,
position: { x: sourceNode.position.x - 3 * (size.width + 36) + index * (size.width + 36), y: sourceNode.position.y },
width: size.width,
height: size.height,
metadata: { ...imageMetadata(image), prompt: output.prompt, workflowKind: "reference_set", assetTags: [labels[index], `镜头:${sourceNode.title}`] },
const previewIndex = nextNodes.findIndex((item) => item.id === previewId);
const existingPreview = previewIndex >= 0 ? nextNodes[previewIndex] : null;
const previewNode: CanvasNodeData = {
...existingPreview,
id: previewId,
type: CanvasNodeType.Image,
title: `${sourceNode.title} · 导演台构图`,
position: existingPreview?.position || { x: sourceNode.position.x - previewSize.width - 36, y: sourceNode.position.y },
width: previewSize.width,
height: previewSize.height,
metadata: { ...existingPreview?.metadata, ...imageMetadata(image), prompt: output.prompt, workflowKind: "reference_set", assetTags: ["导演台构图", `镜头:${sourceNode.title}`] },
};
if (previewIndex >= 0) nextNodes[previewIndex] = previewNode;
else nextNodes.push(previewNode);
let clayVideoId = sourceNode.metadata?.directorClayVideoNodeId;
if (videoUpload) {
clayVideoId ||= `video-director-clay-${Date.now()}`;
const videoIndex = nextNodes.findIndex((item) => item.id === clayVideoId);
const existingVideo = videoIndex >= 0 ? nextNodes[videoIndex] : null;
const videoNode: CanvasNodeData = {
...existingVideo,
id: clayVideoId,
type: CanvasNodeType.Video,
title: `${sourceNode.title} · 白膜视频`,
position: existingVideo?.position || { x: sourceNode.position.x, y: sourceNode.position.y + sourceNode.height + 48 },
width: existingVideo?.width || 360,
height: existingVideo?.height || 220,
metadata: { ...existingVideo?.metadata, ...videoMetadata(videoUpload), prompt: output.prompt, workflowKind: "reference_video", assetTags: ["导演台白膜", `镜头:${sourceNode.title}`] },
};
const currentIndex = nextNodes.findIndex((item) => item.id === id);
if (currentIndex >= 0) nextNodes[currentIndex] = node;
else nextNodes.push(node);
outputIds.push(id);
});
if (videoIndex >= 0) nextNodes[videoIndex] = videoNode;
else nextNodes.push(videoNode);
}
const nextConnections = [...connectionsRef.current];
outputIds.forEach((id) => {
[previewId, videoUpload ? clayVideoId : null].filter((id): id is string => Boolean(id)).forEach((id) => {
if (!nextConnections.some((connection) => connection.fromNodeId === id && connection.toNodeId === sourceNode.id)) nextConnections.push({ id: nanoid(), fromNodeId: id, toNodeId: sourceNode.id });
});
const retiredReferenceIds = new Set([sourceNode.metadata?.directorDepthNodeId, sourceNode.metadata?.directorNormalNodeId].filter(Boolean));
const referenceAssetNodeIds = Array.from(new Set([
...(sourceNode.metadata?.referenceAssetNodeIds || []).filter((id) => !retiredReferenceIds.has(id)),
previewId,
...(clayVideoId ? [clayVideoId] : []),
]));
const directorMetadata: Partial<CanvasNodeMetadata> = {
directorSceneId: output.scene.id,
directorShotId: output.shot.id,
directorPreviewNodeId: outputIds[0],
directorDepthNodeId: outputIds[1],
directorNormalNodeId: outputIds[2],
directorPreviewNodeId: previewId,
directorDepthNodeId: undefined,
directorNormalNodeId: undefined,
directorClayVideoNodeId: clayVideoId,
composerContent: output.prompt,
prompt: output.prompt,
videoCameraMoveId: output.shot.cameraMove,
videoCameraMovePrompt: output.prompt,
referenceAssetNodeIds: Array.from(new Set([...(sourceNode.metadata?.referenceAssetNodeIds || []), ...outputIds])),
referenceAssetNodeIds,
};
const finalizedNodes = nextNodes.map((item) => item.id === sourceNode.id ? { ...item, metadata: { ...item.metadata, ...directorMetadata } } : item);
nodesRef.current = finalizedNodes;
connectionsRef.current = nextConnections;
setNodes(finalizedNodes);
setConnections(nextConnections);
saveDirectorScene({ ...output.scene, shots: output.scene.shots.map((shot) => shot.id === output.shot.id ? { ...shot, previewNodeId: outputIds[0], depthNodeId: outputIds[1], normalNodeId: outputIds[2] } : shot) });
saveDirectorScene({ ...output.scene, shots: output.scene.shots.map((shot) => shot.id === output.shot.id ? { ...shot, previewNodeId: previewId, depthNodeId: undefined, normalNodeId: undefined } : shot) });
}, [connectionsRef, directorNodeId, nodesRef, saveDirectorScene, setConnections, setNodes]);
return { applyDirectorOutput, createDirectorShot, openDirectorWorkbench, saveDirectorScene };

View File

@@ -4,29 +4,38 @@ import type { DirectorRenderMode } from "@/types/director";
type DirectorWorkbenchStore = {
selectedObjectId: string | null;
selectedBone: string | null;
selectedLightId: string | null;
transformMode: "translate" | "rotate" | "scale";
renderMode: DirectorRenderMode;
playhead: number;
playing: boolean;
autoKey: boolean;
sequencerHeight: number;
setSelectedObjectId: (id: string | null) => void;
setSelectedBone: (bone: string | null) => void;
setSelectedLightId: (id: string | null) => void;
setTransformMode: (mode: DirectorWorkbenchStore["transformMode"]) => void;
setRenderMode: (mode: DirectorRenderMode) => void;
setPlayhead: (time: number) => void;
setPlaying: (playing: boolean) => void;
setAutoKey: (autoKey: boolean) => void;
setSequencerHeight: (height: number) => void;
reset: () => void;
};
const initialState = { selectedObjectId: null, selectedLightId: null, transformMode: "translate" as const, renderMode: "beauty" as const, playhead: 0, playing: false };
const initialState = { selectedObjectId: null, selectedBone: null, selectedLightId: null, transformMode: "translate" as const, renderMode: "beauty" as const, playhead: 0, playing: false, autoKey: true, sequencerHeight: 300 };
export const useDirectorWorkbenchStore = create<DirectorWorkbenchStore>((set) => ({
...initialState,
setSelectedObjectId: (selectedObjectId) => set({ selectedObjectId, selectedLightId: null }),
setSelectedLightId: (selectedLightId) => set({ selectedLightId, selectedObjectId: null }),
setSelectedObjectId: (selectedObjectId) => set({ selectedObjectId, selectedBone: null, selectedLightId: null }),
setSelectedBone: (selectedBone) => set({ selectedBone }),
setSelectedLightId: (selectedLightId) => set({ selectedLightId, selectedObjectId: null, selectedBone: null }),
setTransformMode: (transformMode) => set({ transformMode }),
setRenderMode: (renderMode) => set({ renderMode }),
setPlayhead: (playhead) => set({ playhead }),
setPlaying: (playing) => set({ playing }),
setAutoKey: (autoKey) => set({ autoKey }),
setSequencerHeight: (sequencerHeight) => set({ sequencerHeight: Math.max(180, Math.min(620, sequencerHeight)) }),
reset: () => set(initialState),
}));

View File

@@ -341,6 +341,17 @@
--cn-spatial-elevated: var(--color-neutral-0);
--cn-spatial-toolbar-border: var(--color-neutral-200);
--director-sequencer-surface: var(--color-neutral-900);
--director-sequencer-surface-raised: var(--color-neutral-800);
--director-sequencer-border: rgba(255, 255, 255, 0.11);
--director-sequencer-muted: rgba(255, 255, 255, 0.48);
--director-sequencer-grid: rgba(255, 255, 255, 0.055);
--director-dock-surface: rgba(17, 19, 21, 0.92);
--director-dock-fg: rgba(255, 255, 255, 0.62);
--director-dock-fg-strong: rgba(255, 255, 255, 0.96);
--director-control-hover: rgba(255, 255, 255, 0.09);
--director-swatch-inset: rgba(0, 0, 0, 0.18);
/* 节点类型色(引用 Primitive 节点色板,对应 #97 决策1 色带方案)*/
--cn-type-image: var(--palette-node-image);
--cn-type-video: var(--palette-node-video);
@@ -457,6 +468,17 @@
--cn-spatial-elevated: var(--color-neutral-900);
--cn-spatial-toolbar-border: rgba(255, 255, 255, 0.1);
--director-sequencer-surface: #111315;
--director-sequencer-surface-raised: #191c1f;
--director-sequencer-border: rgba(255, 255, 255, 0.11);
--director-sequencer-muted: rgba(255, 255, 255, 0.48);
--director-sequencer-grid: rgba(255, 255, 255, 0.055);
--director-dock-surface: rgba(17, 19, 21, 0.92);
--director-dock-fg: rgba(255, 255, 255, 0.62);
--director-dock-fg-strong: rgba(255, 255, 255, 0.96);
--director-control-hover: rgba(255, 255, 255, 0.09);
--director-swatch-inset: rgba(0, 0, 0, 0.18);
/* --- Shadow dark 覆盖(暗色下黑影不可见,改用白色微影制造抬升感,审计 D4/P2-D2)---
组件 token(--node-shadow/--dock-shadow/--panel-shadow/--modal-shadow)均引用
var(--shadow-*),此处覆盖自动级联。--cn-spatial-shadow(内联 boxShadow 用)已有
@@ -527,6 +549,17 @@
--panel-shadow: var(--shadow-lg);
--panel-section-gap: var(--space-3); /* 12px */
/* Director workbench / Sequencer */
--director-sequencer-min-height: 180px;
--director-sequencer-label-width: 208px;
--director-sequencer-row-height: 30px;
--director-sequencer-base-width: 860px;
--director-sequencer-shot-select-width: 144px;
--director-dock-button-size: var(--space-10);
--director-dock-bottom: var(--space-3);
--director-pose-button-height: var(--space-8);
--director-color-swatch-size: var(--space-6);
/* Canvas 布局(画布页浮层定位,对应审计 P0-8.1/P1-2.1)*/
--canvas-sidebar-width: 232px; /* 项目侧栏宽度(短剧模式)*/
--canvas-panel-width: min(332px, calc(100vw - 24px)); /* 浮动面板默认宽度(活动任务等)*/
@@ -5137,6 +5170,357 @@ html:not(.dark) .creation-model-picker-menu .canvas-model-picker-option > span:f
}
/* 创作入口与空间导航在同一块画布上,避免视觉断层。 */
.director-sequencer {
position: relative;
color: rgba(255, 255, 255, 0.86);
font-size: var(--fs-caption);
}
.director-viewport-dock {
position: absolute;
z-index: var(--z-toolbar);
bottom: var(--director-dock-bottom);
left: 50%;
display: flex;
max-width: calc(100% - var(--space-6));
align-items: center;
gap: var(--space-1);
padding: var(--space-1-half);
overflow-x: auto;
border: var(--stroke-1) solid var(--director-sequencer-border);
border-radius: var(--r-lg);
background: var(--director-dock-surface);
box-shadow: var(--shadow-xl);
color: var(--director-dock-fg);
transform: translateX(-50%);
backdrop-filter: blur(var(--space-4));
}
.director-viewport-dock-button {
display: grid;
width: var(--director-dock-button-size);
height: var(--director-dock-button-size);
flex: 0 0 var(--director-dock-button-size);
place-items: center;
border-radius: var(--r-md);
transition: background var(--motion-dur-fast) var(--motion-ease-out), color var(--motion-dur-fast) var(--motion-ease-out), transform var(--motion-dur-fast) var(--motion-ease-out);
}
.director-viewport-dock-button > svg {
width: var(--space-5);
height: var(--space-5);
}
.director-viewport-dock-button:hover {
background: var(--director-control-hover);
color: var(--director-dock-fg-strong);
}
.director-viewport-dock-button:active {
transform: translateY(1px);
}
.director-viewport-dock-button.is-active {
background: var(--workspace-accent);
color: var(--color-neutral-0);
box-shadow: var(--shadow-glow-sm);
}
.director-viewport-dock-divider {
width: var(--stroke-1);
height: var(--space-6);
flex: 0 0 var(--stroke-1);
margin: 0 var(--space-half);
background: var(--director-sequencer-border);
}
.director-actor-colors {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-1-half);
}
.director-actor-color {
width: var(--director-color-swatch-size);
height: var(--director-color-swatch-size);
flex: 0 0 var(--director-color-swatch-size);
border: var(--stroke-1) solid var(--director-sequencer-border);
border-radius: var(--r-full);
box-shadow: inset 0 0 0 var(--stroke-1) var(--director-swatch-inset);
}
.director-actor-color.is-active {
outline: var(--stroke-2) solid var(--workspace-accent);
outline-offset: var(--space-half);
}
.director-pose-section {
display: grid;
gap: var(--space-2);
}
.director-inspector-section-title {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--cn-muted);
font-size: var(--fs-label);
}
.director-inspector-section-title > span:last-child {
color: var(--workspace-accent);
}
.director-pose-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-1);
}
.director-pose-button {
min-width: 0;
height: var(--director-pose-button-height);
padding: 0 var(--space-1);
overflow: hidden;
border: var(--stroke-1) solid var(--cn-stroke);
border-radius: var(--r-sm);
color: var(--cn-muted);
font-size: var(--fs-tiny);
text-overflow: ellipsis;
white-space: nowrap;
transition: border-color var(--motion-dur-fast) var(--motion-ease-out), background var(--motion-dur-fast) var(--motion-ease-out), color var(--motion-dur-fast) var(--motion-ease-out);
}
.director-pose-button:hover {
border-color: var(--workspace-accent);
color: var(--cn-text);
}
.director-pose-button.is-active {
border-color: var(--workspace-accent);
background: var(--workspace-accent-soft);
color: var(--workspace-accent);
}
@media (prefers-reduced-motion: reduce) {
.director-viewport-dock-button,
.director-pose-button {
transition: none;
}
}
.director-sequencer-resizer {
position: absolute;
z-index: 2;
inset: -4px 0 auto;
height: 8px;
cursor: ns-resize;
}
.director-sequencer-resizer::after {
content: "";
position: absolute;
top: 3px;
left: 50%;
width: 44px;
height: 2px;
border-radius: var(--r-full);
background: rgba(255, 255, 255, 0.28);
transform: translateX(-50%);
opacity: 0;
transition: opacity var(--motion-dur-fast) var(--motion-ease-out);
}
.director-sequencer:hover .director-sequencer-resizer::after,
.director-sequencer-resizer:focus-visible::after {
opacity: 1;
}
.director-sequencer-body {
position: relative;
height: calc(100% - 40px);
min-height: 0;
}
.director-sequencer-grid {
position: relative;
display: grid;
min-width: calc(var(--director-sequencer-base-width) * var(--director-sequencer-track-scale, 1));
grid-template-columns: var(--director-sequencer-label-width) minmax(640px, 1fr);
}
.director-sequencer-global-playhead {
position: absolute;
z-index: 1;
top: 28px;
bottom: 0;
right: 0;
left: var(--director-sequencer-label-width);
pointer-events: none;
}
.director-sequencer-global-playhead > i {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: #f0b36a;
}
.director-sequencer-label,
.director-sequencer-track {
min-width: 0;
height: var(--director-sequencer-row-height);
border-bottom: 1px solid var(--director-sequencer-border);
}
.director-sequencer-row {
display: contents;
}
.director-sequencer-label {
display: flex;
align-items: center;
gap: var(--space-2);
width: var(--director-sequencer-label-width);
padding: 0 var(--space-3);
color: rgba(255, 255, 255, 0.64);
text-align: left;
white-space: nowrap;
transition: background var(--motion-dur-fast) var(--motion-ease-out), color var(--motion-dur-fast) var(--motion-ease-out);
}
.director-sequencer-label:hover,
.director-sequencer-row.is-selected > .director-sequencer-label {
background: rgba(255, 255, 255, 0.075);
color: rgba(255, 255, 255, 0.96);
}
.director-sequencer-label.is-indent {
padding-left: var(--space-6);
color: rgba(255, 255, 255, 0.46);
}
.director-sequencer-ruler-label,
.director-sequencer-ruler {
height: 28px;
border-bottom-color: var(--director-sequencer-border);
background: var(--director-sequencer-surface-raised);
}
.director-sequencer-ruler-label {
padding-left: var(--space-3);
color: rgba(255, 255, 255, 0.42);
font-size: var(--fs-micro);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.director-sequencer-ruler {
position: relative;
cursor: ew-resize;
background-image: repeating-linear-gradient(to right, transparent 0, transparent calc(20% - 1px), var(--director-sequencer-grid) 20%);
}
.director-sequencer-tick {
position: absolute;
top: var(--space-2);
color: rgba(255, 255, 255, 0.38);
font-size: var(--fs-micro);
transform: translateX(-50%);
}
.director-sequencer-playhead {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: #f0b36a;
pointer-events: none;
}
.director-sequencer-track {
position: relative;
overflow: hidden;
background-image: repeating-linear-gradient(to right, transparent 0, transparent calc(20% - 1px), var(--director-sequencer-grid) 20%);
}
.director-sequencer-track-content {
position: absolute;
inset: 0;
}
.director-sequencer-key {
position: absolute;
top: 50%;
width: 8px;
height: 8px;
border-radius: var(--r-xs);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.38);
transform: translate(-50%, -50%) rotate(45deg);
}
.director-sequencer-clip {
position: absolute;
top: 5px;
bottom: 5px;
display: flex;
align-items: center;
min-width: 30px;
padding: 0 var(--space-2);
overflow: hidden;
border: 1px solid;
border-radius: var(--r-xs);
font-size: var(--fs-micro);
}
.director-sequencer-transport,
.director-sequencer-icon,
.director-sequencer-tool {
display: inline-flex;
align-items: center;
justify-content: center;
height: var(--space-8);
border-radius: var(--r-sm);
color: rgba(255, 255, 255, 0.62);
transition: background var(--motion-dur-fast) var(--motion-ease-out), color var(--motion-dur-fast) var(--motion-ease-out);
}
.director-sequencer-transport,
.director-sequencer-icon {
width: var(--space-8);
}
.director-sequencer-tool {
gap: var(--space-1);
padding: 0 var(--space-2);
font-size: var(--fs-micro);
}
.director-sequencer-shot-select {
width: var(--director-sequencer-shot-select-width);
height: var(--space-8);
padding: 0 var(--space-2);
overflow: hidden;
border: 1px solid var(--director-sequencer-border);
border-radius: var(--r-sm);
background: var(--director-sequencer-surface-raised);
color: rgba(255, 255, 255, 0.72);
font-size: var(--fs-micro);
text-overflow: ellipsis;
white-space: nowrap;
}
.director-sequencer-transport:hover,
.director-sequencer-icon:hover,
.director-sequencer-tool:hover,
.director-sequencer-icon.is-active,
.director-sequencer-tool.is-active {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
.app-workspace-shell.is-creation-workspace,
.app-workspace-shell.is-creation-workspace .app-workspace-sidebar,
.app-workspace-shell.is-creation-workspace .app-workspace-stage {

View File

@@ -224,6 +224,7 @@ export type CanvasNodeMetadata = {
directorPreviewNodeId?: string;
directorDepthNodeId?: string;
directorNormalNodeId?: string;
directorClayVideoNodeId?: string;
skillId?: string;
skillVersion?: number;
skillSnapshot?: CanvasSkillSnapshot;

View File

@@ -1,4 +1,5 @@
export type DirectorVec3 = [number, number, number];
export type DirectorQuat = [number, number, number, number];
export type DirectorTransform = {
position: DirectorVec3;
@@ -7,16 +8,47 @@ export type DirectorTransform = {
};
export type DirectorPrimitiveKind = "box" | "sphere" | "cylinder" | "plane" | "character";
export type DirectorObjectKind = "primitive" | "model" | "billboard";
export type DirectorPose = "neutral" | "stand" | "walk" | "run" | "sit" | "action";
export type DirectorObjectKind = "primitive" | "model" | "actor" | "billboard";
export type DirectorPose = "neutral" | "stand" | "t_pose" | "walk" | "run" | "sit" | "squat" | "kneel_single" | "kneel_double" | "hands_hips" | "lean" | "bow" | "think" | "fight" | "kick" | "throw" | "push" | "wave" | "reach" | "arms_crossed" | "phone";
export type DirectorCameraMove = "static" | "push_in" | "pull_out" | "pan_left" | "pan_right" | "tilt_up" | "tilt_down" | "orbit_left" | "orbit_right" | "handheld";
export type DirectorShotSize = "extreme_wide" | "wide" | "full" | "medium" | "close_up" | "extreme_close_up";
export type DirectorRenderMode = "beauty" | "depth" | "normal";
export type DirectorRenderMode = "beauty" | "clay" | "depth" | "normal" | "pose";
export type DirectorKeyframe = {
id: string;
time: number;
transform: DirectorTransform;
easing?: "step" | "linear" | "smooth";
};
export type DirectorHumanoidBone = "root" | "hips" | "spine" | "chest" | "neck" | "head" | "leftShoulder" | "leftUpperArm" | "leftLowerArm" | "leftHand" | "rightShoulder" | "rightUpperArm" | "rightLowerArm" | "rightHand" | "leftUpperLeg" | "leftLowerLeg" | "leftFoot" | "rightUpperLeg" | "rightLowerLeg" | "rightFoot";
export type DirectorBoneKeyframe = {
id: string;
time: number;
rotation: DirectorQuat;
easing?: "step" | "linear" | "smooth";
};
export type DirectorBoneTrack = {
bone: DirectorHumanoidBone;
keyframes: DirectorBoneKeyframe[];
};
export type DirectorRig = {
status: "unmapped" | "ready";
boneMap: Partial<Record<DirectorHumanoidBone, string>>;
animationNames: string[];
};
export type DirectorMotionClip = {
id: string;
name: string;
sourceAnimation: string;
start: number;
duration: number;
playbackRate: number;
loop: boolean;
};
export type DirectorObject = {
@@ -30,6 +62,11 @@ export type DirectorObject = {
castShadow: boolean;
receiveShadow: boolean;
pose?: DirectorPose;
rig?: DirectorRig;
motionClips?: DirectorMotionClip[];
activeMotionClipId?: string;
boneOverrides?: Partial<Record<DirectorHumanoidBone, DirectorQuat>>;
boneTracks?: DirectorBoneTrack[];
sourceNodeId?: string;
assetId?: string;
storageKey?: string;
@@ -69,6 +106,7 @@ export type DirectorShot = {
name: string;
cameraId: string;
duration: number;
fps: 24 | 25 | 30;
shotSize: DirectorShotSize;
cameraMove: DirectorCameraMove;
prompt: string;
@@ -98,6 +136,8 @@ export type DirectorSceneOutput = {
shot: DirectorShot;
prompt: string;
beauty: Blob;
depth: Blob;
normal: Blob;
depth?: Blob;
normal?: Blob;
clayVideo?: Blob;
clayVideoMimeType?: string;
};