feat(canvas): 导演台 - 增加彩色骨骼演员、姿势预设与单一构图回写
This commit is contained in:
@@ -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" }];
|
||||
|
||||
|
||||
154
web/src/components/canvas/director/director-sequencer.tsx
Normal file
154
web/src/components/canvas/director/director-sequencer.tsx
Normal 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`;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user