feat(canvas): 导演台 - 完善骨骼控制、对象管理与面板交互
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { App, Button, ColorPicker, Input, InputNumber, Select, Switch } from "antd";
|
||||
import { App, Button, ColorPicker, Dropdown, Input, InputNumber, Select, Slider, Switch } from "antd";
|
||||
import type { MenuProps } 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 { Euler, Quaternion } from "three";
|
||||
import type { AnimationClip } from "three";
|
||||
|
||||
import { DirectorViewport, type DirectorViewportHandle } from "@/components/canvas/director/director-viewport";
|
||||
@@ -17,7 +19,7 @@ import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
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> }) {
|
||||
export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onChange, onApply, onDeleteImageNode }: { open: boolean; scene: DirectorScene | null; imageNodes: CanvasNodeData[]; onClose: () => void; onChange: (scene: DirectorScene) => void; onApply: (output: DirectorSceneOutput) => Promise<void>; onDeleteImageNode: (nodeId: string) => void }) {
|
||||
const { message } = App.useApp();
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const viewportRef = useRef<DirectorViewportHandle>(null);
|
||||
@@ -36,6 +38,7 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
|
||||
const selectedBone = useDirectorWorkbenchStore((state) => state.selectedBone);
|
||||
const autoKey = useDirectorWorkbenchStore((state) => state.autoKey);
|
||||
const sequencerHeight = useDirectorWorkbenchStore((state) => state.sequencerHeight);
|
||||
const sequencerVisible = useDirectorWorkbenchStore((state) => state.sequencerVisible);
|
||||
const setSelectedObjectId = useDirectorWorkbenchStore((state) => state.setSelectedObjectId);
|
||||
const setSelectedLightId = useDirectorWorkbenchStore((state) => state.setSelectedLightId);
|
||||
const setTransformMode = useDirectorWorkbenchStore((state) => state.setTransformMode);
|
||||
@@ -45,6 +48,7 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
|
||||
const setSelectedBone = useDirectorWorkbenchStore((state) => state.setSelectedBone);
|
||||
const setAutoKey = useDirectorWorkbenchStore((state) => state.setAutoKey);
|
||||
const setSequencerHeight = useDirectorWorkbenchStore((state) => state.setSequencerHeight);
|
||||
const setSequencerVisible = useDirectorWorkbenchStore((state) => state.setSequencerVisible);
|
||||
const resetWorkbench = useDirectorWorkbenchStore((state) => state.reset);
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const addAsset = useAssetStore((state) => state.addAsset);
|
||||
@@ -110,6 +114,30 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
|
||||
const updateObject = (id: string, patch: Partial<DirectorObject>) => commit((current) => ({ ...current, objects: current.objects.map((item) => (item.id === id ? { ...item, ...patch } : item)) }));
|
||||
const updateLight = (id: string, patch: Partial<DirectorLight>) => commit((current) => ({ ...current, lights: current.lights.map((item) => (item.id === id ? { ...item, ...patch } : item)) }));
|
||||
const updateShot = (id: string, patch: Partial<DirectorShot>) => commit((current) => ({ ...current, shots: current.shots.map((item) => (item.id === id ? { ...item, ...patch } : item)) }));
|
||||
const removeObject = (id: string) => {
|
||||
commit((current) => ({ ...current, objects: current.objects.filter((item) => item.id !== id) }));
|
||||
if (selectedObjectId === id) {
|
||||
setSelectedObjectId(null);
|
||||
setSelectedBone(null);
|
||||
}
|
||||
};
|
||||
const removeLight = (id: string) => {
|
||||
commit((current) => ({ ...current, lights: current.lights.filter((item) => item.id !== id) }));
|
||||
if (selectedLightId === id) setSelectedLightId(null);
|
||||
};
|
||||
const removeCamera = (id: string) => {
|
||||
if (!draft || draft.cameras.length <= 1) {
|
||||
message.warning("至少保留一台摄影机");
|
||||
return;
|
||||
}
|
||||
const fallback = draft.cameras.find((item) => item.id !== id);
|
||||
if (!fallback) return;
|
||||
commit((current) => ({
|
||||
...current,
|
||||
cameras: current.cameras.filter((item) => item.id !== id),
|
||||
shots: current.shots.map((shot) => shot.cameraId === id ? { ...shot, cameraId: fallback.id } : shot),
|
||||
}));
|
||||
};
|
||||
|
||||
const addPrimitive = (primitive: DirectorObject["primitive"], name: string) => {
|
||||
const object = createDirectorObject(primitive, name);
|
||||
@@ -152,12 +180,29 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
|
||||
if (activeShot) updateShot(activeShot.id, { cameraId: camera.id });
|
||||
};
|
||||
|
||||
const addLight = () => {
|
||||
const light = createDirectorLight("point", `灯光 ${draft?.lights.length ? draft.lights.length + 1 : 1}`, [2, 3, 2], 1.5);
|
||||
const addLight = (type: DirectorLight["type"] = "point", label = "灯光", position: DirectorVec3 = [2, 3, 2], intensity = 1.5) => {
|
||||
const light = createDirectorLight(type, `${label} ${draft?.lights.length ? draft.lights.length + 1 : 1}`, position, intensity);
|
||||
commit((current) => ({ ...current, lights: [...current.lights, light] }));
|
||||
setSelectedLightId(light.id);
|
||||
};
|
||||
|
||||
const addCameraMenuItems: MenuProps["items"] = [
|
||||
{ key: "camera", icon: <Camera className="size-3.5" />, label: "添加摄影机", onClick: addCamera },
|
||||
];
|
||||
const addLightMenuItems: MenuProps["items"] = [
|
||||
{ key: "directional", icon: <Lightbulb className="size-3.5" />, label: "方向光", onClick: () => addLight("directional", "方向光", [4, 6, 4], 2.4) },
|
||||
{ key: "point", icon: <Lightbulb className="size-3.5" />, label: "点光源", onClick: () => addLight("point", "点光源") },
|
||||
{ key: "spot", icon: <Lightbulb className="size-3.5" />, label: "聚光灯", onClick: () => addLight("spot", "聚光灯", [2, 4, 2], 2) },
|
||||
{ key: "ambient", icon: <LampDesk className="size-3.5" />, label: "环境光", onClick: () => addLight("ambient", "环境光", [0, 0, 0], 0.65) },
|
||||
];
|
||||
const addObjectMenuItems: MenuProps["items"] = [
|
||||
{ key: "actor", icon: <UserRound className="size-3.5" />, label: "演员", onClick: addActor },
|
||||
{ key: "box", icon: <Box className="size-3.5" />, label: "立方体", onClick: () => addPrimitive("box", "立方体") },
|
||||
{ key: "sphere", icon: <Circle className="size-3.5" />, label: "球体", onClick: () => addPrimitive("sphere", "球体") },
|
||||
{ key: "cylinder", icon: <Cuboid className="size-3.5" />, label: "圆柱", onClick: () => addPrimitive("cylinder", "圆柱") },
|
||||
{ key: "model", icon: <FileUp className="size-3.5" />, label: "上传模型", onClick: () => modelInputRef.current?.click() },
|
||||
];
|
||||
|
||||
const addShot = () => {
|
||||
if (!activeCamera) return;
|
||||
const shot: DirectorShot = { id: nanoid(), name: `镜头 ${(draft?.shots.length || 0) + 1}`, cameraId: activeCamera.id, duration: 5, fps: 24, shotSize: "medium", cameraMove: "static", prompt: "" };
|
||||
@@ -291,14 +336,14 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[220px_minmax(0,1fr)_292px] max-lg:grid-cols-[180px_minmax(0,1fr)]">
|
||||
<aside className="thin-scrollbar min-h-0 overflow-y-auto border-r" style={{ background: theme.node.panel, borderColor: theme.toolbar.border }}>
|
||||
<PanelTitle title="场景对象" action={<IconButton label="添加立方体" onClick={() => addPrimitive("box", "立方体")}><Plus className="size-3.5" /></IconButton>} />
|
||||
<PanelTitle title="场景对象" action={<AddMenuButton label="添加场景对象" items={addObjectMenuItems} />} />
|
||||
<div className="px-2 pb-2">
|
||||
{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)} />)}
|
||||
{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)} onDelete={() => removeObject(object.id)} />)}
|
||||
</div>
|
||||
<PanelTitle title="摄影机" action={<IconButton label="添加摄影机" onClick={addCamera}><Plus className="size-3.5" /></IconButton>} />
|
||||
<div className="px-2 pb-2">{draft.cameras.map((camera) => <SceneRow key={camera.id} active={activeShot.cameraId === camera.id && !selectedObjectId && !selectedLightId} icon={<Camera />} label={camera.name} onClick={() => { setSelectedObjectId(null); setSelectedLightId(null); updateShot(activeShot.id, { cameraId: camera.id }); }} />)}</div>
|
||||
<PanelTitle title="灯光" action={<IconButton label="添加灯光" onClick={addLight}><Plus className="size-3.5" /></IconButton>} />
|
||||
<div className="px-2 pb-2">{draft.lights.map((light) => <SceneRow key={light.id} active={selectedLightId === light.id} icon={<Lightbulb />} label={light.name} onClick={() => setSelectedLightId(light.id)} />)}</div>
|
||||
<PanelTitle title="摄影机" action={<AddMenuButton label="添加摄影机" items={addCameraMenuItems} />} />
|
||||
<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 }); }} onDelete={() => removeCamera(camera.id)} />)}</div>
|
||||
<PanelTitle title="灯光" action={<AddMenuButton label="添加灯光" items={addLightMenuItems} />} />
|
||||
<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)} onDelete={() => removeLight(light.id)} />)}</div>
|
||||
<PanelTitle title="快速添加" />
|
||||
<div className="grid grid-cols-2 gap-1.5 px-2 pb-3">
|
||||
<QuickAdd label="演员" icon={<UserRound />} onClick={addActor} />
|
||||
@@ -309,7 +354,7 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
|
||||
<QuickAdd label="添加灯光" icon={<LampDesk />} onClick={addLight} />
|
||||
</div>
|
||||
{modelAssets.length ? <><PanelTitle title="3D 素材" /><div className="px-2 pb-3">{modelAssets.map((asset) => <SceneRow key={asset.id} icon={<BoxSelect />} label={asset.title} onClick={() => addModelAsset(asset)} />)}</div></> : null}
|
||||
{imageNodes.length ? <><PanelTitle title="画布图片立牌" /><div className="px-2 pb-3">{imageNodes.slice(0, 20).map((node) => <SceneRow key={node.id} icon={<ImageIcon />} label={node.title} onClick={() => addBillboard(node)} />)}</div></> : null}
|
||||
{imageNodes.length ? <><PanelTitle title="画布图片立牌" /><div className="px-2 pb-3">{imageNodes.slice(0, 20).map((node) => <SceneRow key={node.id} icon={<ImageIcon />} label={node.title} onClick={() => addBillboard(node)} onDelete={() => onDeleteImageNode(node.id)} />)}</div></> : null}
|
||||
<input ref={modelInputRef} type="file" accept=".glb,.gltf,model/gltf-binary,model/gltf+json" className="hidden" onChange={(event) => { void uploadModel(event.target.files?.[0]); event.currentTarget.value = ""; }} />
|
||||
</aside>
|
||||
|
||||
@@ -320,20 +365,28 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
|
||||
</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} 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} />}
|
||||
{selectedObject ? <ObjectInspector object={selectedObject} playhead={playhead} selectedBone={selectedBone} autoKey={autoKey} onSelectBone={setSelectedBone} onUpdate={(patch) => updateObject(selectedObject.id, patch)} onAddKeyframe={recordSelectedKeyframe} onDelete={() => removeObject(selectedObject.id)} /> : selectedLight ? <LightInspector light={selectedLight} onUpdate={(patch) => updateLight(selectedLight.id, patch)} onDelete={() => removeLight(selectedLight.id)} /> : <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>
|
||||
|
||||
<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); }} />
|
||||
<DirectorSequencer scene={draft} shot={activeShot} camera={activeCamera} objects={draft.objects} selectedObjectId={selectedObjectId} selectedBone={selectedBone} playhead={playhead} playing={playing} autoKey={autoKey} height={sequencerHeight} visible={sequencerVisible} onPlayToggle={() => setPlaying(!playing)} onPlayheadChange={setPlayhead} onAutoKeyChange={setAutoKey} onHeightChange={setSequencerHeight} onVisibilityChange={setSequencerVisible} onSelectObject={setSelectedObjectId} onSelectBone={setSelectedBone} onRecordKeyframe={recordSelectedKeyframe} onAddShot={addShot} onSelectShot={(id) => { commit((current) => ({ ...current, activeShotId: id })); setPlayhead(0); }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 }) {
|
||||
function ObjectInspector({ object, playhead, selectedBone, autoKey, onSelectBone, onUpdate, onAddKeyframe, onDelete }: { object: DirectorObject; playhead: number; selectedBone: string | null; autoKey: boolean; 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 selectedBoneId = selectedBone as DirectorHumanoidBone | null;
|
||||
const selectedBoneRotation = selectedBoneId ? object.boneOverrides?.[selectedBoneId] || [0, 0, 0, 1] as DirectorQuat : null;
|
||||
const updateActiveMotion = (patch: Partial<NonNullable<DirectorObject["motionClips"]>[number]>) => activeMotionClip && onUpdate({ motionClips: motionClips.map((clip) => clip.id === activeMotionClip.id ? { ...clip, ...patch } : clip) });
|
||||
const updateSelectedBoneRotation = (rotation: DirectorQuat) => {
|
||||
if (!selectedBoneId) return;
|
||||
const patch: Partial<DirectorObject> = { boneOverrides: { ...object.boneOverrides, [selectedBoneId]: rotation } };
|
||||
if (autoKey) patch.boneTracks = upsertDirectorBoneKeyframe(object.boneTracks || [], selectedBoneId, playhead, rotation);
|
||||
onUpdate(patch);
|
||||
};
|
||||
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 })} />
|
||||
@@ -348,6 +401,7 @@ function ObjectInspector({ object, playhead, selectedBone, onSelectBone, onUpdat
|
||||
<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}
|
||||
{selectedBoneId && selectedBoneRotation ? <BoneRotationFields rotation={selectedBoneRotation} onChange={updateSelectedBoneRotation} /> : null}
|
||||
</> : null}
|
||||
<Field label="可见"><Switch checked={object.visible} onChange={(visible) => onUpdate({ visible })} /></Field>
|
||||
<Field label="投射阴影"><Switch checked={object.castShadow} onChange={(castShadow) => onUpdate({ castShadow })} /></Field>
|
||||
@@ -379,13 +433,61 @@ function TransformFields({ transform, onChange }: { transform: DirectorTransform
|
||||
return <><Vec3Field label="位置" value={transform.position} onChange={(position) => onChange({ ...transform, position })} /><Vec3Field label="旋转" value={transform.rotation} step={0.05} onChange={(rotation) => onChange({ ...transform, rotation })} /><Vec3Field label="缩放" value={transform.scale} step={0.1} onChange={(scale) => onChange({ ...transform, scale })} /></>;
|
||||
}
|
||||
|
||||
function BoneRotationFields({ rotation, onChange }: { rotation: DirectorQuat; onChange: (rotation: DirectorQuat) => void }) {
|
||||
const initialDegrees = useMemo(() => {
|
||||
const euler = new Euler().setFromQuaternion(new Quaternion(...rotation), "XYZ");
|
||||
return [euler.x, euler.y, euler.z].map((value) => Number(((value * 180) / Math.PI).toFixed(1))) as DirectorVec3;
|
||||
}, [rotation]);
|
||||
const [degrees, setDegrees] = useState<DirectorVec3>(initialDegrees);
|
||||
const lastEmittedRotation = useRef<DirectorQuat | null>(null);
|
||||
useEffect(() => {
|
||||
if (lastEmittedRotation.current && sameDirectorQuaternion(rotation, lastEmittedRotation.current)) {
|
||||
lastEmittedRotation.current = null;
|
||||
return;
|
||||
}
|
||||
setDegrees(initialDegrees);
|
||||
}, [initialDegrees, rotation]);
|
||||
const updateAxis = (index: number, value: number) => {
|
||||
const next = degrees.map((entry, entryIndex) => entryIndex === index ? value : entry) as DirectorVec3;
|
||||
const radians = next.map((entry) => (entry * Math.PI) / 180) as DirectorVec3;
|
||||
const nextRotation = new Quaternion().setFromEuler(new Euler(radians[0], radians[1], radians[2], "XYZ")).toArray() as DirectorQuat;
|
||||
setDegrees(next);
|
||||
lastEmittedRotation.current = nextRotation;
|
||||
onChange(nextRotation);
|
||||
};
|
||||
return <Field label="骨骼旋转(局部角度 °)"><div className="space-y-1.5">
|
||||
{degrees.map((value, index) => <div key={index} className="grid grid-cols-[18px_minmax(0,1fr)_48px] items-center gap-2">
|
||||
<span className="text-[var(--fs-tiny)] font-medium opacity-65">{["X", "Y", "Z"][index]}</span>
|
||||
<Slider className="m-0" min={-180} max={180} step={1} value={value} onChange={(next) => updateAxis(index, Array.isArray(next) ? next[0] ?? 0 : next)} />
|
||||
<span className="text-right text-[var(--fs-tiny)] tabular-nums opacity-65">{value.toFixed(1)}°</span>
|
||||
</div>)}
|
||||
</div></Field>;
|
||||
}
|
||||
|
||||
function sameDirectorQuaternion(left: DirectorQuat, right: DirectorQuat) {
|
||||
const directDistance = left.reduce((sum, value, index) => sum + Math.abs(value - right[index]), 0);
|
||||
const inverseDistance = left.reduce((sum, value, index) => sum + Math.abs(value + right[index]), 0);
|
||||
return Math.min(directDistance, inverseDistance) < 0.0001;
|
||||
}
|
||||
|
||||
function Vec3Field({ label, value, step = 0.1, onChange }: { label: string; value: DirectorVec3; step?: number; onChange: (value: DirectorVec3) => void }) {
|
||||
return <Field label={label}><div className="grid grid-cols-3 gap-1">{value.map((item, index) => <InputNumber key={index} className="w-full" size="small" step={step} value={Number(item.toFixed(2))} onChange={(next) => onChange(value.map((entry, itemIndex) => itemIndex === index ? next || 0 : entry) as DirectorVec3)} />)}</div></Field>;
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) { return <label className="block"><span className="mb-1 block text-[var(--fs-label)] opacity-55">{label}</span>{children}</label>; }
|
||||
function PanelTitle({ title, action }: { title: string; action?: ReactNode }) { return <div className="flex h-9 items-center px-3 text-[var(--fs-tiny)] font-semibold uppercase opacity-55"><span className="flex-1">{title}</span>{action}</div>; }
|
||||
function SceneRow({ active, icon, label, onClick }: { active?: boolean; icon: ReactElement; label: string; onClick: () => void }) { return <button type="button" className={`flex h-8 w-full items-center gap-2 px-2 text-left text-xs transition ${active ? "bg-black/10 dark:bg-white/10" : "hover:bg-black/5 dark:hover:bg-white/5"}`} onClick={onClick}><span className="[&>svg]:size-3.5">{icon}</span><span className="truncate">{label}</span></button>; }
|
||||
function SceneRow({ active, icon, label, onClick, onDelete }: { active?: boolean; icon: ReactElement; label: string; onClick: () => void; onDelete?: () => void }) {
|
||||
return <div className={`flex h-8 w-full items-center gap-1 px-1 text-left text-xs transition ${active ? "bg-black/10 dark:bg-white/10" : "hover:bg-black/5 dark:hover:bg-white/5"}`}>
|
||||
<button type="button" className="flex min-w-0 flex-1 items-center gap-2 px-1 text-left" onClick={onClick}>
|
||||
<span className="[&>svg]:size-3.5">{icon}</span>
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
{onDelete ? <button type="button" aria-label={`删除${label}`} title={`删除${label}`} className="grid size-6 shrink-0 place-items-center rounded opacity-60 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10" onClick={(event) => { event.stopPropagation(); onDelete(); }}><Trash2 className="size-3.5" /></button> : null}
|
||||
</div>;
|
||||
}
|
||||
function AddMenuButton({ label, items }: { label: string; items: MenuProps["items"] }) {
|
||||
return <Dropdown trigger={["click"]} placement="bottomRight" menu={{ items }}><button type="button" aria-label={label} title={label} className="grid size-8 shrink-0 place-items-center rounded-md transition hover:bg-black/5 dark:hover:bg-white/10"><Plus className="size-3.5" /></button></Dropdown>;
|
||||
}
|
||||
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>; }
|
||||
const poseOptions: Array<{ label: string; value: DirectorPose }> = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronDown, ChevronRight, KeyRound, Magnet, Pause, Play, Plus, Rows3, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, ChevronUp, 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";
|
||||
@@ -15,10 +15,12 @@ type DirectorSequencerProps = {
|
||||
playing: boolean;
|
||||
autoKey: boolean;
|
||||
height: number;
|
||||
visible: boolean;
|
||||
onPlayToggle: () => void;
|
||||
onPlayheadChange: (time: number) => void;
|
||||
onAutoKeyChange: (value: boolean) => void;
|
||||
onHeightChange: (height: number) => void;
|
||||
onVisibilityChange: (visible: boolean) => void;
|
||||
onSelectObject: (id: string | null) => void;
|
||||
onSelectBone: (bone: string | null) => void;
|
||||
onRecordKeyframe: () => void;
|
||||
@@ -28,7 +30,7 @@ type DirectorSequencerProps = {
|
||||
|
||||
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) {
|
||||
export function DirectorSequencer({ scene, shot, camera, objects, selectedObjectId, selectedBone, playhead, playing, autoKey, height, visible, onPlayToggle, onPlayheadChange, onAutoKeyChange, onHeightChange, onVisibilityChange, onSelectObject, onSelectBone, onRecordKeyframe, onAddShot, onSelectShot }: DirectorSequencerProps) {
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const [snapEnabled, setSnapEnabled] = useState(true);
|
||||
const [showDetails, setShowDetails] = useState(true);
|
||||
@@ -60,6 +62,14 @@ export function DirectorSequencer({ scene, shot, camera, objects, selectedObject
|
||||
|
||||
const cameraKeys = camera?.keyframes.map((key) => ({ id: key.id, time: key.time, color: "#78a9ff" })) || [];
|
||||
|
||||
if (!visible) {
|
||||
return <section className="director-sequencer shrink-0 border-t" style={{ height: "var(--space-8)", minHeight: 0, background: "var(--director-sequencer-surface)", borderColor: "var(--director-sequencer-border)" }}>
|
||||
<div className="flex h-full items-center justify-end px-3">
|
||||
<button type="button" className="director-sequencer-tool" title="显示时间轴" aria-label="显示时间轴" onClick={() => onVisibilityChange(true)}><ChevronUp className="size-3.5" /><span>显示时间轴</span></button>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
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="调整时间轴高度" />
|
||||
@@ -79,6 +89,7 @@ export function DirectorSequencer({ scene, shot, camera, objects, selectedObject
|
||||
<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>
|
||||
<button type="button" className="director-sequencer-icon" title="隐藏时间轴" aria-label="隐藏时间轴" onClick={() => onVisibilityChange(false)}><ChevronDown className="size-3.5" /></button>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import { Canvas, useFrame, useThree, type ThreeEvent } from "@react-three/fiber";
|
||||
import { Grid, OrbitControls, TransformControls } from "@react-three/drei";
|
||||
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 { AnimationClip, AnimationMixer, Box3, Bone, Camera, Color, Group, LoopOnce, LoopRepeat, Mesh, MeshBasicMaterial, MeshDepthMaterial, MeshNormalMaterial, MeshStandardMaterial, Object3D, OrthographicCamera, PerspectiveCamera, Quaternion, Scene, SkeletonHelper, Texture, TextureLoader, Vector3, WebGLRenderer } from "three";
|
||||
import type { Material } from "three";
|
||||
import { GLTFLoader, SkeletonUtils } from "three-stdlib";
|
||||
|
||||
@@ -205,6 +205,21 @@ function DirectorModel({ object, selected, selectedBone, playhead, onSelectBone,
|
||||
const motion = object.motionClips?.find((item) => item.id === object.activeMotionClipId);
|
||||
const activeAnimation = motion ? animations.find((item) => item.name === motion.sourceAnimation) : undefined;
|
||||
const modelUrl = object.kind === "actor" || object.primitive === "character" ? DIRECTOR_DEFAULT_ACTOR_URL : object.url;
|
||||
const handleModelPointerDown = useCallback((event: ThreeEvent<PointerEvent>) => {
|
||||
if (!selected || !rig) return;
|
||||
let nearest: { bone: string; distance: number } | null = null;
|
||||
Object.entries(rig.boneMap).forEach(([bone, name]) => {
|
||||
if (!name) return;
|
||||
const target = model?.getObjectByName(name);
|
||||
if (!target) return;
|
||||
const distance = event.ray.distanceSqToPoint(target.getWorldPosition(new Vector3()));
|
||||
if (distance <= 0.12 ** 2 && (!nearest || distance < nearest.distance)) nearest = { bone, distance };
|
||||
});
|
||||
if (!nearest) return;
|
||||
// 模型表面可能先于关节控制球被射线命中,用最近骨骼保证点击仍可选中。
|
||||
event.stopPropagation();
|
||||
onSelectBone(nearest.bone);
|
||||
}, [model, onSelectBone, rig, selected]);
|
||||
|
||||
useEffect(() => { onActorRigReadyRef.current = onActorRigReady; }, [onActorRigReady]);
|
||||
|
||||
@@ -276,10 +291,14 @@ function DirectorModel({ object, selected, selectedBone, playhead, onSelectBone,
|
||||
});
|
||||
|
||||
if (!model) return <DirectorMannequin color={object.color} selected={selected} />;
|
||||
return <group>
|
||||
return <group onPointerDown={handleModelPointerDown}>
|
||||
<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 && rig ? Object.entries(rig.boneMap).filter(([, name]) => Boolean(name)).map(([bone, name]) => {
|
||||
const fingerGroup = directorFingerGroup(bone);
|
||||
const selectedFingerGroup = directorFingerGroup(selectedBone);
|
||||
return <BoneController key={bone} bone={model.getObjectByName(name!)} selected={selectedBone === bone} dimmed={Boolean(fingerGroup && selectedFingerGroup && fingerGroup !== selectedFingerGroup)} onSelect={() => onSelectBone(bone)} />;
|
||||
}) : null}
|
||||
{selected && selectedBoneObject ? <TransformControls object={selectedBoneObject} mode="rotate" size={0.55} onMouseUp={() => onBoneTransform(selectedBone!, selectedBoneObject.quaternion.toArray() as DirectorQuat)} /> : null}
|
||||
</group>;
|
||||
}
|
||||
@@ -303,17 +322,55 @@ function LoadingBone({ from, to, color }: { from: DirectorVec3; to: DirectorVec3
|
||||
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 }) {
|
||||
function BoneController({ bone, selected, dimmed, onSelect }: { bone: Object3D | undefined; selected: boolean; dimmed: boolean; onSelect: () => void }) {
|
||||
const ref = useRef<Group>(null);
|
||||
const visibleRef = useRef<Mesh>(null);
|
||||
const hitRef = useRef<Mesh>(null);
|
||||
const world = useMemo(() => new Vector3(), []);
|
||||
const local = useMemo(() => new Vector3(), []);
|
||||
const cameraWorld = useMemo(() => new Vector3(), []);
|
||||
const { camera, size } = useThree();
|
||||
useFrame(() => {
|
||||
if (!bone || !ref.current) return;
|
||||
const parent = ref.current.parent;
|
||||
if (!parent) return;
|
||||
// 控制点与模型根节点是兄弟关系,必须转换到控制点父级坐标,否则点击区域会与骨骼错位。
|
||||
bone.getWorldPosition(world);
|
||||
model.worldToLocal(world);
|
||||
ref.current.position.copy(world);
|
||||
camera.getWorldPosition(cameraWorld);
|
||||
local.copy(world);
|
||||
parent.worldToLocal(local);
|
||||
ref.current.position.copy(local);
|
||||
const distance = cameraWorld.distanceTo(world);
|
||||
const visualPixels = selected ? 5 : dimmed ? 2.5 : 3.5;
|
||||
const hitPixels = selected ? 12 : 10;
|
||||
visibleRef.current?.scale.setScalar(screenPixelsToWorldRadius(camera, distance, visualPixels, size.height));
|
||||
hitRef.current?.scale.setScalar(screenPixelsToWorldRadius(camera, distance, hitPixels, size.height));
|
||||
});
|
||||
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>;
|
||||
const handlePointerDown = (event: ThreeEvent<PointerEvent>) => { event.stopPropagation(); onSelect(); };
|
||||
return <group ref={ref}>
|
||||
<mesh ref={visibleRef} onPointerDown={handlePointerDown} frustumCulled={false}>
|
||||
<sphereGeometry args={[1, 12, 8]} />
|
||||
<meshBasicMaterial color={selected ? "#f0b36a" : "#78a9ff"} depthTest={false} transparent opacity={selected ? 1 : dimmed ? 0.14 : 0.68} />
|
||||
</mesh>
|
||||
{/* 可视点保持小尺寸,透明球只负责提供稳定的点击面积。 */}
|
||||
<mesh ref={hitRef} onPointerDown={handlePointerDown} frustumCulled={false}>
|
||||
<sphereGeometry args={[1, 8, 6]} />
|
||||
<meshBasicMaterial transparent opacity={0} depthTest={false} />
|
||||
</mesh>
|
||||
</group>;
|
||||
}
|
||||
|
||||
function screenPixelsToWorldRadius(camera: Camera, distance: number, pixels: number, viewportHeight: number) {
|
||||
const height = Math.max(1, viewportHeight);
|
||||
if (camera instanceof PerspectiveCamera) return (pixels * 2 * Math.max(0.01, distance) * Math.tan((camera.fov * Math.PI) / 360)) / (height * Math.max(0.01, camera.zoom));
|
||||
if (camera instanceof OrthographicCamera) return (pixels * (camera.top - camera.bottom)) / (height * Math.max(0.01, camera.zoom));
|
||||
return pixels * 0.001;
|
||||
}
|
||||
|
||||
function directorFingerGroup(bone: string | null) {
|
||||
const match = bone?.match(/^(left|right)(Thumb|Index|Middle|Ring|Pinky)\d$/);
|
||||
return match ? `${match[1]}${match[2]}` : null;
|
||||
}
|
||||
|
||||
function normalizeModel(root: Object3D, castShadow: boolean, receiveShadow: boolean) {
|
||||
@@ -367,10 +424,26 @@ function readRigRestRotations(root: Object3D, rig: DirectorRig) {
|
||||
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 fingerPatterns = (side: "left" | "right", finger: "thumb" | "index" | "middle" | "ring" | "pinky", segment: 1 | 2 | 3) => [
|
||||
new RegExp(`^mixamorig${side}hand${finger}${segment}$`),
|
||||
new RegExp(`^${side}hand${finger}${segment}$`),
|
||||
new RegExp(`^${side}${finger}${segment}$`),
|
||||
new RegExp(`^${finger}0?${segment}${side === "left" ? "l" : "r"}$`),
|
||||
];
|
||||
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/],
|
||||
leftShoulder: [/leftshoulder|shoulder_l|mixamorigleftshoulder/], leftUpperArm: [/leftupperarm|leftarm|upperarm_l|mixamorigleftarm/], leftLowerArm: [/leftforearm|leftlowerarm|forearm_l|mixamorigleftforearm/], leftHand: [/^lefthand$/, /^handl$/, /^mixamoriglefthand$/],
|
||||
leftThumb1: fingerPatterns("left", "thumb", 1), leftThumb2: fingerPatterns("left", "thumb", 2), leftThumb3: fingerPatterns("left", "thumb", 3),
|
||||
leftIndex1: fingerPatterns("left", "index", 1), leftIndex2: fingerPatterns("left", "index", 2), leftIndex3: fingerPatterns("left", "index", 3),
|
||||
leftMiddle1: fingerPatterns("left", "middle", 1), leftMiddle2: fingerPatterns("left", "middle", 2), leftMiddle3: fingerPatterns("left", "middle", 3),
|
||||
leftRing1: fingerPatterns("left", "ring", 1), leftRing2: fingerPatterns("left", "ring", 2), leftRing3: fingerPatterns("left", "ring", 3),
|
||||
leftPinky1: fingerPatterns("left", "pinky", 1), leftPinky2: fingerPatterns("left", "pinky", 2), leftPinky3: fingerPatterns("left", "pinky", 3),
|
||||
rightShoulder: [/rightshoulder|shoulder_r|mixamorigrightshoulder/], rightUpperArm: [/rightupperarm|rightarm|upperarm_r|mixamorigrightarm/], rightLowerArm: [/rightforearm|rightlowerarm|forearm_r|mixamorigrightforearm/], rightHand: [/^righthand$/, /^handr$/, /^mixamorigrighthand$/],
|
||||
rightThumb1: fingerPatterns("right", "thumb", 1), rightThumb2: fingerPatterns("right", "thumb", 2), rightThumb3: fingerPatterns("right", "thumb", 3),
|
||||
rightIndex1: fingerPatterns("right", "index", 1), rightIndex2: fingerPatterns("right", "index", 2), rightIndex3: fingerPatterns("right", "index", 3),
|
||||
rightMiddle1: fingerPatterns("right", "middle", 1), rightMiddle2: fingerPatterns("right", "middle", 2), rightMiddle3: fingerPatterns("right", "middle", 3),
|
||||
rightRing1: fingerPatterns("right", "ring", 1), rightRing2: fingerPatterns("right", "ring", 2), rightRing3: fingerPatterns("right", "ring", 3),
|
||||
rightPinky1: fingerPatterns("right", "pinky", 1), rightPinky2: fingerPatterns("right", "pinky", 2), rightPinky3: fingerPatterns("right", "pinky", 3),
|
||||
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"];
|
||||
|
||||
@@ -122,7 +122,23 @@ export function interpolateDirectorBoneRotation(base: DirectorQuat, keyframes: D
|
||||
}
|
||||
|
||||
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;
|
||||
return ({
|
||||
hips: "骨盆", spine: "脊柱", chest: "胸腔", neck: "颈部", head: "头部",
|
||||
leftShoulder: "左肩", leftUpperArm: "左上臂", leftLowerArm: "左前臂", leftHand: "左手",
|
||||
rightShoulder: "右肩", rightUpperArm: "右上臂", rightLowerArm: "右前臂", rightHand: "右手",
|
||||
leftUpperLeg: "左大腿", leftLowerLeg: "左小腿", leftFoot: "左脚",
|
||||
rightUpperLeg: "右大腿", rightLowerLeg: "右小腿", rightFoot: "右脚",
|
||||
leftThumb1: "左拇指·根", leftThumb2: "左拇指·中", leftThumb3: "左拇指·尖",
|
||||
leftIndex1: "左食指·根", leftIndex2: "左食指·中", leftIndex3: "左食指·尖",
|
||||
leftMiddle1: "左中指·根", leftMiddle2: "左中指·中", leftMiddle3: "左中指·尖",
|
||||
leftRing1: "左无名指·根", leftRing2: "左无名指·中", leftRing3: "左无名指·尖",
|
||||
leftPinky1: "左小指·根", leftPinky2: "左小指·中", leftPinky3: "左小指·尖",
|
||||
rightThumb1: "右拇指·根", rightThumb2: "右拇指·中", rightThumb3: "右拇指·尖",
|
||||
rightIndex1: "右食指·根", rightIndex2: "右食指·中", rightIndex3: "右食指·尖",
|
||||
rightMiddle1: "右中指·根", rightMiddle2: "右中指·中", rightMiddle3: "右中指·尖",
|
||||
rightRing1: "右无名指·根", rightRing2: "右无名指·中", rightRing3: "右无名指·尖",
|
||||
rightPinky1: "右小指·根", rightPinky2: "右小指·中", rightPinky3: "右小指·尖",
|
||||
} as Record<string, string>)[bone] || bone;
|
||||
}
|
||||
|
||||
export function directorPoseLabel(pose: DirectorPose) {
|
||||
|
||||
@@ -2227,6 +2227,7 @@ function InfiniteCanvasPage() {
|
||||
onClose={() => setDirectorNodeId(null)}
|
||||
onChange={saveDirectorScene}
|
||||
onApply={applyDirectorOutput}
|
||||
onDeleteImageNode={(nodeId) => deleteNodes(new Set([nodeId]))}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
@@ -12,6 +12,7 @@ type DirectorWorkbenchStore = {
|
||||
playing: boolean;
|
||||
autoKey: boolean;
|
||||
sequencerHeight: number;
|
||||
sequencerVisible: boolean;
|
||||
setSelectedObjectId: (id: string | null) => void;
|
||||
setSelectedBone: (bone: string | null) => void;
|
||||
setSelectedLightId: (id: string | null) => void;
|
||||
@@ -21,10 +22,11 @@ type DirectorWorkbenchStore = {
|
||||
setPlaying: (playing: boolean) => void;
|
||||
setAutoKey: (autoKey: boolean) => void;
|
||||
setSequencerHeight: (height: number) => void;
|
||||
setSequencerVisible: (visible: boolean) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
const initialState = { selectedObjectId: null, selectedBone: null, selectedLightId: null, transformMode: "translate" as const, renderMode: "beauty" as const, playhead: 0, playing: false, autoKey: true, sequencerHeight: 300 };
|
||||
const initialState = { selectedObjectId: null, selectedBone: null, selectedLightId: null, transformMode: "translate" as const, renderMode: "beauty" as const, playhead: 0, playing: false, autoKey: true, sequencerHeight: 300, sequencerVisible: true };
|
||||
|
||||
export const useDirectorWorkbenchStore = create<DirectorWorkbenchStore>((set) => ({
|
||||
...initialState,
|
||||
@@ -37,5 +39,6 @@ export const useDirectorWorkbenchStore = create<DirectorWorkbenchStore>((set) =>
|
||||
setPlaying: (playing) => set({ playing }),
|
||||
setAutoKey: (autoKey) => set({ autoKey }),
|
||||
setSequencerHeight: (sequencerHeight) => set({ sequencerHeight: Math.max(180, Math.min(620, sequencerHeight)) }),
|
||||
setSequencerVisible: (sequencerVisible) => set({ sequencerVisible }),
|
||||
reset: () => set(initialState),
|
||||
}));
|
||||
|
||||
@@ -21,7 +21,11 @@ export type DirectorKeyframe = {
|
||||
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 DirectorFingerBone =
|
||||
| "leftThumb1" | "leftThumb2" | "leftThumb3" | "leftIndex1" | "leftIndex2" | "leftIndex3" | "leftMiddle1" | "leftMiddle2" | "leftMiddle3" | "leftRing1" | "leftRing2" | "leftRing3" | "leftPinky1" | "leftPinky2" | "leftPinky3"
|
||||
| "rightThumb1" | "rightThumb2" | "rightThumb3" | "rightIndex1" | "rightIndex2" | "rightIndex3" | "rightMiddle1" | "rightMiddle2" | "rightMiddle3" | "rightRing1" | "rightRing2" | "rightRing3" | "rightPinky1" | "rightPinky2" | "rightPinky3";
|
||||
|
||||
export type DirectorHumanoidBone = "root" | "hips" | "spine" | "chest" | "neck" | "head" | "leftShoulder" | "leftUpperArm" | "leftLowerArm" | "leftHand" | "rightShoulder" | "rightUpperArm" | "rightLowerArm" | "rightHand" | "leftUpperLeg" | "leftLowerLeg" | "leftFoot" | "rightUpperLeg" | "rightLowerLeg" | "rightFoot" | DirectorFingerBone;
|
||||
|
||||
export type DirectorBoneKeyframe = {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user