From d3a029732f6aa6be07957237b331ebcd112034ac Mon Sep 17 00:00:00 2001 From: QAyong Date: Mon, 17 Aug 2026 19:46:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E5=AF=BC=E6=BC=94=E5=8F=B0=20-?= =?UTF-8?q?=20=E5=AE=8C=E5=96=84=E9=AA=A8=E9=AA=BC=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E3=80=81=E5=AF=B9=E8=B1=A1=E7=AE=A1=E7=90=86=E4=B8=8E=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../director/canvas-director-workbench.tsx | 132 ++++++++++++++++-- .../canvas/director/director-sequencer.tsx | 15 +- .../canvas/director/director-viewport.tsx | 93 ++++++++++-- web/src/lib/canvas/director/director-scene.ts | 18 ++- web/src/pages/canvas/project.tsx | 1 + .../canvas/use-director-workbench-store.ts | 5 +- web/src/types/director.ts | 6 +- 7 files changed, 240 insertions(+), 30 deletions(-) diff --git a/web/src/components/canvas/director/canvas-director-workbench.tsx b/web/src/components/canvas/director/canvas-director-workbench.tsx index 6c90d570c..4aa627374 100644 --- a/web/src/components/canvas/director/canvas-director-workbench.tsx +++ b/web/src/components/canvas/director/canvas-director-workbench.tsx @@ -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 }) { +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; onDeleteImageNode: (nodeId: string) => void }) { const { message } = App.useApp(); const theme = canvasThemes[useThemeStore((state) => state.theme)]; const viewportRef = useRef(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) => commit((current) => ({ ...current, objects: current.objects.map((item) => (item.id === id ? { ...item, ...patch } : item)) })); const updateLight = (id: string, patch: Partial) => commit((current) => ({ ...current, lights: current.lights.map((item) => (item.id === id ? { ...item, ...patch } : item)) })); const updateShot = (id: string, patch: Partial) => 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: , label: "添加摄影机", onClick: addCamera }, + ]; + const addLightMenuItems: MenuProps["items"] = [ + { key: "directional", icon: , label: "方向光", onClick: () => addLight("directional", "方向光", [4, 6, 4], 2.4) }, + { key: "point", icon: , label: "点光源", onClick: () => addLight("point", "点光源") }, + { key: "spot", icon: , label: "聚光灯", onClick: () => addLight("spot", "聚光灯", [2, 4, 2], 2) }, + { key: "ambient", icon: , label: "环境光", onClick: () => addLight("ambient", "环境光", [0, 0, 0], 0.65) }, + ]; + const addObjectMenuItems: MenuProps["items"] = [ + { key: "actor", icon: , label: "演员", onClick: addActor }, + { key: "box", icon: , label: "立方体", onClick: () => addPrimitive("box", "立方体") }, + { key: "sphere", icon: , label: "球体", onClick: () => addPrimitive("sphere", "球体") }, + { key: "cylinder", icon: , label: "圆柱", onClick: () => addPrimitive("cylinder", "圆柱") }, + { key: "model", icon: , 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
@@ -320,20 +365,28 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
- 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); }} /> + 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); }} /> ); } -function ObjectInspector({ object, playhead, selectedBone, onSelectBone, onUpdate, onAddKeyframe, onDelete }: { object: DirectorObject; playhead: number; selectedBone: string | null; onSelectBone: (bone: string | null) => void; onUpdate: (patch: Partial) => 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) => 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[number]>) => activeMotionClip && onUpdate({ motionClips: motionClips.map((clip) => clip.id === activeMotionClip.id ? { ...clip, ...patch } : clip) }); + const updateSelectedBoneRotation = (rotation: DirectorQuat) => { + if (!selectedBoneId) return; + const patch: Partial = { 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 onUpdate({ name })} onDelete={onDelete}> onUpdate({ transform })} /> @@ -348,6 +401,7 @@ function ObjectInspector({ object, playhead, selectedBone, onSelectBone, onUpdat
角色绑定{object.rig?.status === "ready" ? `${mappedBones.length} 根骨骼` : "等待模型"}
{motionClips.length ? <> ({ label: directorBoneLabel(bone), value: bone }))} onChange={(bone) => onSelectBone(bone || null)} /> : null} + {selectedBoneId && selectedBoneRotation ? : null} : null} onUpdate({ visible })} /> onUpdate({ castShadow })} /> @@ -379,13 +433,61 @@ function TransformFields({ transform, onChange }: { transform: DirectorTransform return <> onChange({ ...transform, position })} /> onChange({ ...transform, rotation })} /> 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(initialDegrees); + const lastEmittedRotation = useRef(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
+ {degrees.map((value, index) =>
+ {["X", "Y", "Z"][index]} + updateAxis(index, Array.isArray(next) ? next[0] ?? 0 : next)} /> + {value.toFixed(1)}° +
)} +
; +} + +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
{value.map((item, index) => onChange(value.map((entry, itemIndex) => itemIndex === index ? next || 0 : entry) as DirectorVec3)} />)}
; } function Field({ label, children }: { label: string; children: ReactNode }) { return ; } function PanelTitle({ title, action }: { title: string; action?: ReactNode }) { return
{title}{action}
; } -function SceneRow({ active, icon, label, onClick }: { active?: boolean; icon: ReactElement; label: string; onClick: () => void }) { return ; } +function SceneRow({ active, icon, label, onClick, onDelete }: { active?: boolean; icon: ReactElement; label: string; onClick: () => void; onDelete?: () => void }) { + return
+ + {onDelete ? : null} +
; +} +function AddMenuButton({ label, items }: { label: string; items: MenuProps["items"] }) { + return ; +} function QuickAdd({ label, icon, onClick }: { label: string; icon: ReactElement; onClick: () => void }) { return ; } function IconButton({ label, disabled, children, onClick }: { label: string; disabled?: boolean; children: ReactNode; onClick: () => void }) { return ; } const poseOptions: Array<{ label: string; value: DirectorPose }> = [ diff --git a/web/src/components/canvas/director/director-sequencer.tsx b/web/src/components/canvas/director/director-sequencer.tsx index 9aa48a722..446608dfe 100644 --- a/web/src/components/canvas/director/director-sequencer.tsx +++ b/web/src/components/canvas/director/director-sequencer.tsx @@ -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>({}); 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
+
+ +
+
; + } + return (
@@ -79,6 +89,7 @@ export function DirectorSequencer({ scene, shot, camera, objects, selectedObject + diff --git a/web/src/components/canvas/director/director-viewport.tsx b/web/src/components/canvas/director/director-viewport.tsx index ca58f61fc..b6dbd308c 100644 --- a/web/src/components/canvas/director/director-viewport.tsx +++ b/web/src/components/canvas/director/director-viewport.tsx @@ -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) => { + 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 ; - return + return {selected && helper ? : null} - {selected && rig ? Object.entries(rig.boneMap).filter(([, name]) => Boolean(name)).map(([bone, name]) => 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 onSelectBone(bone)} />; + }) : null} {selected && selectedBoneObject ? onBoneTransform(selectedBone!, selectedBoneObject.quaternion.toArray() as DirectorQuat)} /> : null} ; } @@ -303,17 +322,55 @@ function LoadingBone({ from, to, color }: { from: DirectorVec3; to: DirectorVec3 return ; } -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(null); + const visibleRef = useRef(null); + const hitRef = useRef(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 { event.stopPropagation(); onSelect(); }}>; + const handlePointerDown = (event: ThreeEvent) => { event.stopPropagation(); onSelect(); }; + return + + + + + {/* 可视点保持小尺寸,透明球只负责提供稳定的点击面积。 */} + + + + + ; +} + +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(); 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 = { 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"]; diff --git a/web/src/lib/canvas/director/director-scene.ts b/web/src/lib/canvas/director/director-scene.ts index 7b26fb6c7..58a19c4ca 100644 --- a/web/src/lib/canvas/director/director-scene.ts +++ b/web/src/lib/canvas/director/director-scene.ts @@ -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)[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)[bone] || bone; } export function directorPoseLabel(pose: DirectorPose) { diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 68cce559c..f8577b134 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -2227,6 +2227,7 @@ function InfiniteCanvasPage() { onClose={() => setDirectorNodeId(null)} onChange={saveDirectorScene} onApply={applyDirectorOutput} + onDeleteImageNode={(nodeId) => deleteNodes(new Set([nodeId]))} /> ) : null} diff --git a/web/src/stores/canvas/use-director-workbench-store.ts b/web/src/stores/canvas/use-director-workbench-store.ts index a8c70d7b4..ea538d90a 100644 --- a/web/src/stores/canvas/use-director-workbench-store.ts +++ b/web/src/stores/canvas/use-director-workbench-store.ts @@ -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((set) => ({ ...initialState, @@ -37,5 +39,6 @@ export const useDirectorWorkbenchStore = create((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), })); diff --git a/web/src/types/director.ts b/web/src/types/director.ts index c45a5e4ec..5bb21e45d 100644 --- a/web/src/types/director.ts +++ b/web/src/types/director.ts @@ -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;