视频导出、时间轴与动作姿势模块
- 视频导出:WebCodecs 逐帧快速导出 MP4(固定分辨率不随视口布局变形),MediaRecorder 兜底 - 时间轴:面板可拖动调高(持久化、固定上限)、关键帧轨道区内滚动、无横向滚动条 - 动作模块:走路/跑步步态曲线(对侧摆臂修复、跑步独立步频、跑姿屈膝曲线、参考校准参数) - 姿势调节:两级折叠分组、动作偏移联动 - 删除对象时同步清理时间轴轨道 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,4 +8,3 @@ vite.config.js
|
||||
vite.config.d.ts
|
||||
release
|
||||
*.exe
|
||||
docs
|
||||
1670
docs/superpowers/plans/2026-07-23-keyframe-animation.md
Normal file
1670
docs/superpowers/plans/2026-07-23-keyframe-animation.md
Normal file
File diff suppressed because it is too large
Load Diff
24
package-lock.json
generated
24
package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"@react-three/fiber": "^8.17.14",
|
||||
"camera-controls": "^2.9.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"mp4-muxer": "^5.2.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"three": "^0.184.0",
|
||||
@@ -2369,6 +2370,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/dom-webcodecs": {
|
||||
"version": "0.1.19",
|
||||
"resolved": "https://registry.npmmirror.com/@types/dom-webcodecs/-/dom-webcodecs-0.1.19.tgz",
|
||||
"integrity": "sha512-+yFUIDUaASFz0vQpWBErPxun/Lb+F0TdrlTsj50gm7K797YzEx2TmV6fEiwciHyFNghlZYPgQXH5qRcwMHcJFQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/draco3d": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz",
|
||||
@@ -2526,6 +2533,12 @@
|
||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/wicg-file-system-access": {
|
||||
"version": "2020.9.8",
|
||||
"resolved": "https://registry.npmmirror.com/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.8.tgz",
|
||||
"integrity": "sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "2.10.3",
|
||||
"resolved": "https://registry.npmmirror.com/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||
@@ -5949,6 +5962,17 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/mp4-muxer": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/mp4-muxer/-/mp4-muxer-5.2.2.tgz",
|
||||
"integrity": "sha512-dhozjTywI0h2qFzeShagt8YYw811fh1XlwiDCE2f6Aeqf6xG2CyuShoSa5E0AZDO8pPF0JOZ3wOmWBNWIGdSpQ==",
|
||||
"deprecated": "This library is superseded by Mediabunny. Please migrate to it.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/dom-webcodecs": "^0.1.6",
|
||||
"@types/wicg-file-system-access": "^2020.9.5"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
||||
@@ -37,7 +37,9 @@
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": ["x64"]
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "public/icon.png",
|
||||
@@ -57,6 +59,7 @@
|
||||
"@react-three/fiber": "^8.17.14",
|
||||
"camera-controls": "^2.9.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"mp4-muxer": "^5.2.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"three": "^0.184.0",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Euler, Matrix4, PerspectiveCamera as ThreePerspectiveCamera, Quaternion
|
||||
import type { Object3D } from "three";
|
||||
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
|
||||
import { clearViewportCaptureHandler, setViewportCaptureHandler } from "../io/captureBridge";
|
||||
import { setVideoExportContext } from "../io/videoExportBridge";
|
||||
import { buildScreenshotMeta, type ScreenshotResult } from "../io/screenshotExport";
|
||||
import { useDirectorStore, type CameraShotSnapshot } from "../store/directorStore";
|
||||
import { DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT, getCameraViewSnapshotFromShot } from "../schema/cameraGeometry";
|
||||
@@ -481,6 +482,18 @@ function CanvasCaptureBridge({
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 把渲染三件套注册给视频导出逻辑(逐帧离线渲染用),模式仿 CanvasCaptureBridge */
|
||||
function VideoExportBridge() {
|
||||
const { camera, gl, scene } = useThree();
|
||||
|
||||
useEffect(() => {
|
||||
setVideoExportContext({ gl, scene, camera: camera as ThreePerspectiveCamera });
|
||||
return () => setVideoExportContext(null);
|
||||
}, [camera, gl, scene]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function DirectorViewCameraSync({
|
||||
controlsRef,
|
||||
snapshot,
|
||||
@@ -737,6 +750,7 @@ export function DirectorCanvas() {
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
<PlaybackFrameDriver />
|
||||
<VideoExportBridge />
|
||||
<PlaybackTransformSync />
|
||||
<ActiveCameraPlaybackSync activeCameraId={activeCamera?.id ?? null} />
|
||||
<Suspense fallback={null}>
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { useDirectorStore } from "../store/directorStore";
|
||||
import { evaluateCameraTrackAtTime, evaluateTrackAtTime } from "../schema/timeline";
|
||||
import { usePlaybackStore } from "../runtime/playbackStore";
|
||||
import { getViewportCameraQuaternion } from "./SceneRoot";
|
||||
import { applyTimelineTransformsAtTime } from "../runtime/playbackTransforms";
|
||||
|
||||
/**
|
||||
* 挂在 <Canvas> 内:每帧根据 playhead 把物体/相机线框的插值结果直接写到
|
||||
* 对应的 Three.js 对象。SceneRoot 的 group 通过 userData 标记身份:
|
||||
* - directorObjectId → 物体(覆盖 position/rotation/scale)
|
||||
* - directorCameraId → 导演视图相机线框(覆盖 position + 朝向 target)
|
||||
* 对应的 Three.js 对象(逻辑在 playbackTransforms.ts,与视频导出共用)。
|
||||
* 无轨道或无关键帧的对象保持静态,不被触碰。
|
||||
*/
|
||||
export function PlaybackTransformSync() {
|
||||
@@ -19,39 +16,8 @@ export function PlaybackTransformSync() {
|
||||
const editingObjectId = usePlaybackStore((state) => state.editingObjectId);
|
||||
|
||||
useFrame(() => {
|
||||
if (tracks.length === 0 && cameraTracks.length === 0) return;
|
||||
|
||||
const trackMap = new Map(tracks.map((track) => [track.objectId, track]));
|
||||
const cameraTrackMap = new Map(cameraTracks.map((track) => [track.cameraId, track]));
|
||||
|
||||
scene.traverse((object) => {
|
||||
// 播放、scrub 时都按 playhead 插值覆盖;但跳过正在被 gizmo 拖拽的对象(editingObjectId),
|
||||
// 否则 gizmo 改的值会被立刻覆盖回去,拖不动。
|
||||
const objectId = object.userData?.directorObjectId;
|
||||
if (objectId && objectId !== editingObjectId) {
|
||||
const track = trackMap.get(objectId);
|
||||
if (track && track.keyframes.length > 0) {
|
||||
const value = evaluateTrackAtTime(track, playhead);
|
||||
if (value) {
|
||||
object.position.set(value.position[0], value.position[1], value.position[2]);
|
||||
object.rotation.set(value.rotation[0], value.rotation[1], value.rotation[2]);
|
||||
object.scale.set(value.scale[0], value.scale[1], value.scale[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cameraId = object.userData?.directorCameraId;
|
||||
if (cameraId && cameraId !== editingObjectId) {
|
||||
const track = cameraTrackMap.get(cameraId);
|
||||
if (track && track.keyframes.length > 0) {
|
||||
const value = evaluateCameraTrackAtTime(track, playhead);
|
||||
if (value) {
|
||||
object.position.set(value.position[0], value.position[1], value.position[2]);
|
||||
object.quaternion.copy(getViewportCameraQuaternion(value.position, value.target));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// 播放、scrub 时都按 playhead 插值覆盖;editingObjectId 的跳过逻辑在共享函数里
|
||||
applyTimelineTransformsAtTime(scene, tracks, cameraTracks, playhead, editingObjectId);
|
||||
});
|
||||
|
||||
return null;
|
||||
|
||||
@@ -476,6 +476,7 @@ function ObjectSceneNode({
|
||||
} | null>(null);
|
||||
const updateObjectTransform = useDirectorStore((state) => state.updateObjectTransform);
|
||||
const isPlaying = usePlaybackStore((state) => state.isPlaying);
|
||||
const motionTime = usePlaybackStore((state) => state.playhead);
|
||||
const isImportedModel = asset?.sourceType === "model";
|
||||
const characterLabelKey = `${item.id}:${item.bodyType ?? ""}:${item.characterRig?.rigType ?? ""}`;
|
||||
const fallbackCharacterLabelY =
|
||||
@@ -539,6 +540,8 @@ function ObjectSceneNode({
|
||||
color={item.color}
|
||||
onLabelAnchorYChange={handleCharacterLabelAnchorYChange}
|
||||
rigState={item.characterRig}
|
||||
motionTime={motionTime}
|
||||
characterId={item.id}
|
||||
/>
|
||||
</Suspense>
|
||||
{showLabels ? (
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { flushSync } from "react-dom";
|
||||
import { ArrayBufferTarget, Muxer } from "mp4-muxer";
|
||||
import { Vector2 } from "three";
|
||||
import { useDirectorStore } from "../store/directorStore";
|
||||
import { usePlaybackStore } from "../runtime/playbackStore";
|
||||
import { getVideoExportContext } from "./videoExportBridge";
|
||||
import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "../runtime/playbackTransforms";
|
||||
import { getViewportAspectRatioValue } from "../schema/viewportAspectRatio";
|
||||
|
||||
export type VideoExportView = "director" | "camera";
|
||||
|
||||
const EXPORT_FPS = 30;
|
||||
/** 导出分辨率按「短边 = 720」基准缩放,画面比例取自 viewportAspectRatio(auto 时用视口当前比例) */
|
||||
const EXPORT_SHORT_EDGE = 720;
|
||||
|
||||
function pickMimeType(): { mimeType: string; ext: string } {
|
||||
if (typeof MediaRecorder === "undefined") return { mimeType: "", ext: "webm" };
|
||||
const candidates = [
|
||||
@@ -45,13 +55,181 @@ function waitNextFrame(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 录制视口画面为视频:
|
||||
* - 切到指定视角(director=上帝视角 / camera=机位视角)
|
||||
* - 从 playhead 0 播放到时间轴末尾(stopAtEnd,不循环)
|
||||
* - 优先 MP4(Chrome 120+ / Safari),不支持时回退 WebM(Chrome / Firefox)
|
||||
* - 录完自动下载
|
||||
* 按视频比例(viewportAspectRatio)计算导出尺寸,短边 720,宽高取偶(编码器要求)。
|
||||
* 「自动」也固定按 16:9 导出 —— 导出画面比例绝不受视口布局(如时间轴拖高压扁视口)影响,
|
||||
* 否则视口是扁的时导出也会跟着扁。
|
||||
*/
|
||||
export async function exportVideo(view: VideoExportView): Promise<void> {
|
||||
export function computeExportSize(
|
||||
aspectRatioId: ReturnType<typeof useDirectorStore.getState>["viewportAspectRatio"]
|
||||
): { width: number; height: number } {
|
||||
const aspect = getViewportAspectRatioValue(aspectRatioId) ?? 16 / 9;
|
||||
let width: number;
|
||||
let height: number;
|
||||
if (aspect >= 1) {
|
||||
height = EXPORT_SHORT_EDGE;
|
||||
width = Math.round(height * aspect);
|
||||
} else {
|
||||
width = EXPORT_SHORT_EDGE;
|
||||
height = Math.round(width / aspect);
|
||||
}
|
||||
return { width: width + (width % 2), height: height + (height % 2) };
|
||||
}
|
||||
|
||||
async function pickVideoEncoderConfig(width: number, height: number) {
|
||||
const candidates = [
|
||||
{ codec: "avc1.640028" }, // High 4.0
|
||||
{ codec: "avc1.4d0028" }, // Main 4.0
|
||||
{ codec: "avc1.42001f" }, // Baseline 3.1
|
||||
];
|
||||
const bitrate = Math.round(width * height * EXPORT_FPS * 0.15); // ≈6Mbps @720p30
|
||||
for (const { codec } of candidates) {
|
||||
const config: VideoEncoderConfig = {
|
||||
codec,
|
||||
width,
|
||||
height,
|
||||
bitrate,
|
||||
framerate: EXPORT_FPS,
|
||||
};
|
||||
try {
|
||||
const support = await VideoEncoder.isConfigSupported(config);
|
||||
if (support.supported) return config;
|
||||
} catch {
|
||||
// 继续尝试下一个候选
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐帧离线导出(快,不用实时播放一遍):
|
||||
* - 渲染分辨率固定为视频比例(短边 720),不受视口布局影响 —— 时间轴拖多高画面都不会变扁
|
||||
* - 每帧直接 setPlayhead(t) + 手动渲染 + WebCodecs 编码,渲染多快导多快
|
||||
* - H.264 编码进 MP4(Chrome / Electron 均支持)
|
||||
*/
|
||||
async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
|
||||
const bridge = getVideoExportContext();
|
||||
if (!bridge) throw new Error("找不到视口渲染上下文");
|
||||
const { gl, scene, camera } = bridge;
|
||||
const canvas = gl.domElement;
|
||||
|
||||
const directorStore = useDirectorStore.getState();
|
||||
const { width, height } = computeExportSize(directorStore.viewportAspectRatio);
|
||||
const duration = directorStore.project.timeline.duration;
|
||||
const totalFrames = Math.max(1, Math.round(duration * EXPORT_FPS));
|
||||
|
||||
const encoderConfig = await pickVideoEncoderConfig(width, height);
|
||||
if (!encoderConfig) throw new Error("当前浏览器不支持 H.264 编码,请用最新版 Chrome / Edge");
|
||||
|
||||
// 保存现场:导出完原样恢复(视角 / 指针 / 渲染尺寸 / 相机比例)
|
||||
const previousViewMode = directorStore.viewMode;
|
||||
const previousPlayhead = usePlaybackStore.getState().playhead;
|
||||
const previousPlaying = usePlaybackStore.getState().isPlaying;
|
||||
const previousSize = new Vector2();
|
||||
gl.getSize(previousSize);
|
||||
const previousPixelRatio = gl.getPixelRatio();
|
||||
const previousAspect = camera.aspect;
|
||||
|
||||
const muxer = new Muxer({
|
||||
target: new ArrayBufferTarget(),
|
||||
video: { codec: "avc", width, height },
|
||||
fastStart: "in-memory",
|
||||
});
|
||||
let encodeError: unknown = null;
|
||||
const encoder = new VideoEncoder({
|
||||
output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
|
||||
error: (error) => {
|
||||
encodeError = encodeError ?? error;
|
||||
},
|
||||
});
|
||||
encoder.configure(encoderConfig);
|
||||
|
||||
// 切到导出状态:固定分辨率渲染(不改 CSS 尺寸,canvas 加 object-fit: contain 保持显示不变形)
|
||||
usePlaybackStore.getState().setPlaying(false);
|
||||
directorStore.setViewMode(view);
|
||||
canvas.classList.add("is-video-exporting");
|
||||
gl.setPixelRatio(1);
|
||||
gl.setSize(width, height, false);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
|
||||
// 切视角会让 R3F 更换渲染相机(机位视角挂载 drei PerspectiveCamera),
|
||||
// 等一拍让 VideoExportBridge 重新注册最新相机,导出循环里也每帧取最新引用
|
||||
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
|
||||
|
||||
const frameDurationUs = Math.round(1_000_000 / EXPORT_FPS);
|
||||
try {
|
||||
for (let frameIndex = 0; frameIndex <= totalFrames; frameIndex += 1) {
|
||||
if (encodeError) throw encodeError;
|
||||
const time = Math.min(frameIndex / EXPORT_FPS, duration);
|
||||
// 相机可能因视角切换被替换,每帧从桥取最新;并保证导出画面比例
|
||||
const frameCamera = getVideoExportContext()?.camera ?? camera;
|
||||
frameCamera.aspect = width / height;
|
||||
frameCamera.updateProjectionMatrix();
|
||||
|
||||
// 同步推进 playhead 让 React 侧内容(角色动作等)立刻更新到 t
|
||||
flushSync(() => {
|
||||
usePlaybackStore.getState().setPlayhead(time);
|
||||
});
|
||||
// 手动应用物体 / 相机运镜插值(与预览的 useFrame 同步逻辑共用)
|
||||
applyTimelineTransformsAtTime(
|
||||
scene,
|
||||
directorStore.project.timeline.tracks,
|
||||
directorStore.project.timeline.cameraTracks,
|
||||
time,
|
||||
usePlaybackStore.getState().editingObjectId
|
||||
);
|
||||
applyActiveCameraViewAtTime({
|
||||
camera: frameCamera,
|
||||
cameraTracks: directorStore.project.timeline.cameraTracks,
|
||||
activeCameraId: directorStore.project.activeCameraId,
|
||||
playhead: time,
|
||||
viewMode: view,
|
||||
editingObjectId: usePlaybackStore.getState().editingObjectId,
|
||||
});
|
||||
|
||||
gl.render(scene, frameCamera);
|
||||
|
||||
const frame = new VideoFrame(canvas, {
|
||||
timestamp: frameIndex * frameDurationUs,
|
||||
duration: frameDurationUs,
|
||||
});
|
||||
encoder.encode(frame, { keyFrame: frameIndex % (EXPORT_FPS * 2) === 0 });
|
||||
frame.close();
|
||||
|
||||
// 背压:编码队列太深时等一等,防止内存堆积
|
||||
while (encoder.encodeQueueSize > 8) {
|
||||
await new Promise<void>((resolve) => window.setTimeout(resolve, 4));
|
||||
}
|
||||
// 让出主线程,保持界面可响应(进度画面快进效果)
|
||||
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
await encoder.flush();
|
||||
muxer.finalize();
|
||||
if (encodeError) throw encodeError;
|
||||
|
||||
const blob = new Blob([muxer.target.buffer], { type: "video/mp4" });
|
||||
const viewLabel = view === "director" ? "上帝视角" : "机位视角";
|
||||
downloadBlob(blob, `director-desk-${viewLabel}-${Date.now()}.mp4`);
|
||||
} finally {
|
||||
encoder.close();
|
||||
canvas.classList.remove("is-video-exporting");
|
||||
gl.setPixelRatio(previousPixelRatio);
|
||||
gl.setSize(previousSize.x, previousSize.y, false);
|
||||
const restoreCamera = getVideoExportContext()?.camera ?? camera;
|
||||
restoreCamera.aspect = previousAspect;
|
||||
restoreCamera.updateProjectionMatrix();
|
||||
directorStore.setViewMode(previousViewMode);
|
||||
usePlaybackStore.getState().setPlaying(previousPlaying);
|
||||
usePlaybackStore.getState().setPlayhead(previousPlayhead);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧版实时录制(MediaRecorder 兜底路径):只在浏览器不支持 WebCodecs 时使用。
|
||||
* 从 playhead 0 播放到时间轴末尾录屏,耗时 = 视频时长。
|
||||
*/
|
||||
async function exportVideoWithMediaRecorder(view: VideoExportView): Promise<void> {
|
||||
const canvas = findViewportCanvas();
|
||||
if (!canvas) throw new Error("找不到视口画布");
|
||||
if (typeof MediaRecorder === "undefined") {
|
||||
@@ -118,3 +296,15 @@ export async function exportVideo(view: VideoExportView): Promise<void> {
|
||||
const blob = new Blob(chunks, { type: mimeType || "video/webm" });
|
||||
downloadBlob(blob, `director-desk-${viewLabel}-${Date.now()}.${ext}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出视频:优先 WebCodecs 逐帧快速导出(MP4,固定分辨率,不受视口布局影响);
|
||||
* 浏览器不支持时回退 MediaRecorder 实时录屏(WebM/MP4,耗时 = 视频时长)。
|
||||
*/
|
||||
export async function exportVideo(view: VideoExportView): Promise<void> {
|
||||
if (typeof VideoEncoder !== "undefined") {
|
||||
await exportVideoFrameByFrame(view);
|
||||
return;
|
||||
}
|
||||
await exportVideoWithMediaRecorder(view);
|
||||
}
|
||||
|
||||
22
src/editor/io/videoExportBridge.ts
Normal file
22
src/editor/io/videoExportBridge.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Object3D, PerspectiveCamera, WebGLRenderer } from "three";
|
||||
|
||||
/**
|
||||
* 把 R3F <Canvas> 内部的渲染三件套(renderer / scene / camera)暴露给
|
||||
* Canvas 外的视频导出逻辑(videoExport.ts),供逐帧离线渲染。
|
||||
* 模式与 captureBridge.ts 一致:Canvas 内的桥组件注册,导出方读取。
|
||||
*/
|
||||
export interface VideoExportContext {
|
||||
gl: WebGLRenderer;
|
||||
scene: Object3D;
|
||||
camera: PerspectiveCamera;
|
||||
}
|
||||
|
||||
let videoExportContext: VideoExportContext | null = null;
|
||||
|
||||
export function setVideoExportContext(context: VideoExportContext | null) {
|
||||
videoExportContext = context;
|
||||
}
|
||||
|
||||
export function getVideoExportContext() {
|
||||
return videoExportContext;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach } from "vitest";
|
||||
import { createInitialDirectorState, useDirectorStore } from "../store/directorStore";
|
||||
import { usePlaybackStore } from "../runtime/playbackStore";
|
||||
import { hashIdToPhase } from "../runtime/mannequin/motion";
|
||||
import { CharacterPanel } from "./CharacterPanel";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -157,3 +159,42 @@ it("applies pose presets to every member in a selected crowd group", async () =>
|
||||
expect(new Set(crowdMembers.map((item) => item.characterRig?.posePresetId))).toEqual(new Set(["t-pose"]));
|
||||
expect(new Set(crowdMembers.map((item) => item.characterRig?.controls["leftShoulder.spread"]))).toEqual(new Set([-70]));
|
||||
});
|
||||
|
||||
it("动作开启时姿势滑杆渲染动作默认数据(偏移 0 = 动作当前值)", async () => {
|
||||
const user = userEvent.setup();
|
||||
// 把播放头对到 θL=π/2(左髋前摆峰值 25°)
|
||||
const phase = hashIdToPhase("char_default_a");
|
||||
const t = (Math.PI / 2 - phase) / (Math.PI * 2);
|
||||
usePlaybackStore.setState({ playhead: t });
|
||||
useDirectorStore.getState().setMotion("char_default_a", "walk");
|
||||
|
||||
render(<CharacterPanel />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "姿势" }));
|
||||
|
||||
expect(screen.getByLabelText("左髋 · 前抬")).toHaveValue(25);
|
||||
expect(screen.getByLabelText("右髋 · 前抬")).toHaveValue(-12);
|
||||
// 左膝着地伸直
|
||||
expect(screen.getByLabelText("左膝 · 弯曲")).toHaveValue(0);
|
||||
});
|
||||
|
||||
it("动作开启时拖动滑杆写入的是偏移而非绝对值", async () => {
|
||||
const user = userEvent.setup();
|
||||
const phase = hashIdToPhase("char_default_a");
|
||||
const t = (Math.PI / 2 - phase) / (Math.PI * 2);
|
||||
usePlaybackStore.setState({ playhead: t });
|
||||
useDirectorStore.getState().setMotion("char_default_a", "walk");
|
||||
|
||||
render(<CharacterPanel />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "姿势" }));
|
||||
await user.clear(screen.getByLabelText("左髋 · 前抬"));
|
||||
await user.type(screen.getByLabelText("左髋 · 前抬"), "40");
|
||||
|
||||
// 40 = 动作值 25 + 偏移 15(度数↔弧度往返有浮点尾差,用 toBeCloseTo)
|
||||
expect(
|
||||
useDirectorStore.getState().project.objects.find((item) => item.id === "char_default_a")?.characterRig?.controls[
|
||||
"leftHip.pitch"
|
||||
]
|
||||
).toBeCloseTo(15);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
InspectorAxisGroup,
|
||||
InspectorCheckboxField,
|
||||
InspectorColorField,
|
||||
InspectorPanel,
|
||||
InspectorRangeNumberField,
|
||||
@@ -9,13 +10,18 @@ import {
|
||||
} from "./InspectorControls";
|
||||
import { MANNEQUIN_POSE_PRESETS } from "../presets/mannequinPosePresets";
|
||||
import { getCrowdAnchorTransform, useDirectorStore } from "../store/directorStore";
|
||||
import { MOTION_OPTIONS, computeMotionOverrides } from "../runtime/mannequin/motion";
|
||||
import { usePlaybackStore } from "../runtime/playbackStore";
|
||||
import type { CharacterMotionState, MotionKind } from "../schema/directorProject";
|
||||
|
||||
function replaceAxis(tuple: [number, number, number], axis: 0 | 1 | 2, value: number): [number, number, number] {
|
||||
return tuple.map((item, index) => (index === axis ? value : item)) as [number, number, number];
|
||||
}
|
||||
|
||||
const DEFAULT_MOTION: CharacterMotionState = { kind: "none", speed: 1, amplitude: 1, enabled: true };
|
||||
|
||||
export function CharacterPanel() {
|
||||
const [activeTab, setActiveTab] = useState<"properties" | "pose">("properties");
|
||||
const [activeTab, setActiveTab] = useState<"properties" | "pose" | "motion">("properties");
|
||||
const selectedCrowdId = useDirectorStore((state) => state.selectedCrowdId);
|
||||
const selectedObjectId = useDirectorStore((state) => state.selectedObjectId);
|
||||
const objects = useDirectorStore((state) => state.project.objects);
|
||||
@@ -31,6 +37,15 @@ export function CharacterPanel() {
|
||||
const applyCrowdPosePreset = useDirectorStore((state) => state.applyCrowdPosePreset);
|
||||
const updatePoseControl = useDirectorStore((state) => state.updatePoseControl);
|
||||
const updateCrowdPoseControl = useDirectorStore((state) => state.updateCrowdPoseControl);
|
||||
const setMotion = useDirectorStore((state) => state.setMotion);
|
||||
const setCrowdMotion = useDirectorStore((state) => state.setCrowdMotion);
|
||||
const setMotionSpeed = useDirectorStore((state) => state.setMotionSpeed);
|
||||
const setCrowdMotionSpeed = useDirectorStore((state) => state.setCrowdMotionSpeed);
|
||||
const setMotionAmplitude = useDirectorStore((state) => state.setMotionAmplitude);
|
||||
const setCrowdMotionAmplitude = useDirectorStore((state) => state.setCrowdMotionAmplitude);
|
||||
const setMotionEnabled = useDirectorStore((state) => state.setMotionEnabled);
|
||||
const setCrowdMotionEnabled = useDirectorStore((state) => state.setCrowdMotionEnabled);
|
||||
const playhead = usePlaybackStore((state) => state.playhead);
|
||||
|
||||
const selection = useMemo(() => {
|
||||
const role = objects.find((item) => item.id === selectedObjectId && item.kind === "character");
|
||||
@@ -71,79 +86,118 @@ export function CharacterPanel() {
|
||||
const roleColor = selection.color;
|
||||
const transform = selection.crowdAnchor;
|
||||
const isCrowd = selection.mode === "crowd";
|
||||
const poseGroups = [
|
||||
const motion: CharacterMotionState = role.characterRig?.motion ?? DEFAULT_MOTION;
|
||||
const isMotionActive = motion.kind !== "none" && motion.enabled;
|
||||
// 动作当前时刻(播放头)的关节值(度数域)——姿势滑杆显示「动作值 + 偏移」,
|
||||
// 偏移为 0 时滑杆显示的就是动作默认数据本身;拖动时间轴时实时跟随
|
||||
const motionDegrees: Record<string, number> = {};
|
||||
if (isMotionActive) {
|
||||
const overrides = computeMotionOverrides({ motion, time: playhead, characterId: role.id });
|
||||
for (const [key, radians] of Object.entries(overrides)) {
|
||||
if (typeof radians === "number" && Number.isFinite(radians)) {
|
||||
motionDegrees[key] = (radians * 180) / Math.PI;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 两级结构:一级 = 身体区域(可折叠),二级 = 区域内的部位分组 + 滑杆
|
||||
const poseSections = [
|
||||
{
|
||||
title: "身体",
|
||||
controls: [
|
||||
{ key: "body.pitch", label: "前倾" },
|
||||
{ key: "body.yaw", label: "转身" },
|
||||
{ key: "body.roll", label: "侧倾" },
|
||||
title: "核心",
|
||||
groups: [
|
||||
{
|
||||
title: "身体",
|
||||
controls: [
|
||||
{ key: "body.pitch", label: "前倾" },
|
||||
{ key: "body.yaw", label: "转身" },
|
||||
{ key: "body.roll", label: "侧倾" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "躯干",
|
||||
controls: [
|
||||
{ key: "torso.pitch", label: "前倾" },
|
||||
{ key: "torso.yaw", label: "扭转" },
|
||||
{ key: "torso.roll", label: "侧倾" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "头部",
|
||||
controls: [
|
||||
{ key: "head.pitch", label: "点头" },
|
||||
{ key: "head.yaw", label: "转头" },
|
||||
{ key: "head.roll", label: "歪头" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "躯干",
|
||||
controls: [
|
||||
{ key: "torso.pitch", label: "前倾" },
|
||||
{ key: "torso.yaw", label: "扭转" },
|
||||
{ key: "torso.roll", label: "侧倾" },
|
||||
title: "左臂",
|
||||
groups: [
|
||||
{
|
||||
title: "左肩",
|
||||
controls: [
|
||||
{ key: "leftShoulder.pitch", label: "前举" },
|
||||
{ key: "leftShoulder.spread", label: "外展" },
|
||||
{ key: "leftShoulder.twist", label: "扭转" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "左肘",
|
||||
controls: [{ key: "leftElbow.bend", label: "弯曲" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "头部",
|
||||
controls: [
|
||||
{ key: "head.pitch", label: "点头" },
|
||||
{ key: "head.yaw", label: "转头" },
|
||||
{ key: "head.roll", label: "歪头" },
|
||||
title: "右臂",
|
||||
groups: [
|
||||
{
|
||||
title: "右肩",
|
||||
controls: [
|
||||
{ key: "rightShoulder.pitch", label: "前举" },
|
||||
{ key: "rightShoulder.spread", label: "外展" },
|
||||
{ key: "rightShoulder.twist", label: "扭转" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "右肘",
|
||||
controls: [{ key: "rightElbow.bend", label: "弯曲" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "左肩",
|
||||
controls: [
|
||||
{ key: "leftShoulder.pitch", label: "前举" },
|
||||
{ key: "leftShoulder.spread", label: "外展" },
|
||||
{ key: "leftShoulder.twist", label: "扭转" },
|
||||
title: "左腿",
|
||||
groups: [
|
||||
{
|
||||
title: "左髋",
|
||||
controls: [
|
||||
{ key: "leftHip.pitch", label: "前抬" },
|
||||
{ key: "leftHip.spread", label: "外展" },
|
||||
{ key: "leftHip.twist", label: "扭转" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "左膝",
|
||||
controls: [{ key: "leftKnee.bend", label: "弯曲" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "右肩",
|
||||
controls: [
|
||||
{ key: "rightShoulder.pitch", label: "前举" },
|
||||
{ key: "rightShoulder.spread", label: "外展" },
|
||||
{ key: "rightShoulder.twist", label: "扭转" },
|
||||
title: "右腿",
|
||||
groups: [
|
||||
{
|
||||
title: "右髋",
|
||||
controls: [
|
||||
{ key: "rightHip.pitch", label: "前抬" },
|
||||
{ key: "rightHip.spread", label: "外展" },
|
||||
{ key: "rightHip.twist", label: "扭转" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "右膝",
|
||||
controls: [{ key: "rightKnee.bend", label: "弯曲" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "左肘",
|
||||
controls: [{ key: "leftElbow.bend", label: "弯曲" }],
|
||||
},
|
||||
{
|
||||
title: "右肘",
|
||||
controls: [{ key: "rightElbow.bend", label: "弯曲" }],
|
||||
},
|
||||
{
|
||||
title: "左髋",
|
||||
controls: [
|
||||
{ key: "leftHip.pitch", label: "前抬" },
|
||||
{ key: "leftHip.spread", label: "外展" },
|
||||
{ key: "leftHip.twist", label: "扭转" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "右髋",
|
||||
controls: [
|
||||
{ key: "rightHip.pitch", label: "前抬" },
|
||||
{ key: "rightHip.spread", label: "外展" },
|
||||
{ key: "rightHip.twist", label: "扭转" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "左膝",
|
||||
controls: [{ key: "leftKnee.bend", label: "弯曲" }],
|
||||
},
|
||||
{
|
||||
title: "右膝",
|
||||
controls: [{ key: "rightKnee.bend", label: "弯曲" }],
|
||||
},
|
||||
] as const;
|
||||
|
||||
return (
|
||||
@@ -154,6 +208,7 @@ export function CharacterPanel() {
|
||||
tabs={[
|
||||
{ label: "属性", active: activeTab === "properties", onClick: () => setActiveTab("properties") },
|
||||
{ label: "姿势", active: activeTab === "pose", onClick: () => setActiveTab("pose") },
|
||||
{ label: "动作", active: activeTab === "motion", onClick: () => setActiveTab("motion") },
|
||||
]}
|
||||
>
|
||||
{activeTab === "properties" ? (
|
||||
@@ -333,7 +388,7 @@ export function CharacterPanel() {
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
) : activeTab === "pose" ? (
|
||||
<InspectorSection title="姿势预设" className="pose-preset-section">
|
||||
{role.characterRig ? (
|
||||
<>
|
||||
@@ -354,36 +409,147 @@ export function CharacterPanel() {
|
||||
))}
|
||||
</div>
|
||||
<InspectorSection title="姿势调节" className="pose-adjust-section">
|
||||
<div className="pose-groups">
|
||||
{poseGroups.map((group) => (
|
||||
<section key={group.title} className="pose-group">
|
||||
<h4>{group.title}</h4>
|
||||
{group.controls.map((control) => (
|
||||
<InspectorRangeNumberField
|
||||
key={control.key}
|
||||
label={control.label}
|
||||
rangeAriaLabel={`${group.title} · ${control.label} 滑杆`}
|
||||
numberAriaLabel={`${group.title} · ${control.label}`}
|
||||
max="90"
|
||||
min="-90"
|
||||
step="1"
|
||||
value={role.characterRig?.controls[control.key] ?? 0}
|
||||
onValueChange={(value) =>
|
||||
isCrowd && selection.crowdId
|
||||
? updateCrowdPoseControl(selection.crowdId, control.key, Number(value))
|
||||
: updatePoseControl(role.id, control.key, Number(value))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
<div className="pose-sections">
|
||||
{poseSections.map((section) => (
|
||||
<details key={section.title} className="pose-section">
|
||||
<summary className="pose-section-summary">{section.title}</summary>
|
||||
<div className="pose-groups">
|
||||
{section.groups.map((group) => (
|
||||
<section key={group.title} className="pose-group">
|
||||
<h4>{group.title}</h4>
|
||||
{group.controls.map((control) => {
|
||||
const motionDeg = motionDegrees[control.key] ?? 0;
|
||||
const offset = role.characterRig?.controls[control.key] ?? 0;
|
||||
const displayValue = Math.round(Math.min(90, Math.max(-90, motionDeg + offset)));
|
||||
return (
|
||||
<InspectorRangeNumberField
|
||||
key={control.key}
|
||||
label={control.label}
|
||||
rangeAriaLabel={`${group.title} · ${control.label} 滑杆`}
|
||||
numberAriaLabel={`${group.title} · ${control.label}`}
|
||||
max="90"
|
||||
min="-90"
|
||||
step="1"
|
||||
value={displayValue}
|
||||
onValueChange={(value) => {
|
||||
// 滑杆值 = 动作当前值 + 偏移 → 回写时反算偏移
|
||||
const nextOffset = Number(value) - motionDeg;
|
||||
if (isCrowd && selection.crowdId) {
|
||||
updateCrowdPoseControl(selection.crowdId, control.key, nextOffset);
|
||||
return;
|
||||
}
|
||||
updatePoseControl(role.id, control.key, nextOffset);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
{isMotionActive ? (
|
||||
<p className="pose-motion-notice">
|
||||
滑杆显示「动作当前值 + 偏移」,随时间轴指针实时更新;拖动滑杆即设置该关节偏移。应用姿势预设会关闭动作并整身替换。
|
||||
</p>
|
||||
) : null}
|
||||
</InspectorSection>
|
||||
</>
|
||||
) : (
|
||||
<p>该模型未识别到标准 humanoid 骨骼,暂不支持姿势编辑。</p>
|
||||
)}
|
||||
</InspectorSection>
|
||||
) : (
|
||||
<InspectorSection title="基础动作" className="motion-section">
|
||||
{role.characterRig ? (
|
||||
<>
|
||||
<fieldset className="motion-kind-field">
|
||||
<legend>动作类型</legend>
|
||||
<div className="motion-kind-options">
|
||||
{MOTION_OPTIONS.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={`motion-kind-option${motion.kind === option.value ? " is-active" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`motion-kind-${role.id}`}
|
||||
value={option.value}
|
||||
aria-label={`角色动作类型 ${option.label}`}
|
||||
checked={motion.kind === option.value}
|
||||
onChange={() => {
|
||||
const kind = option.value as MotionKind;
|
||||
if (isCrowd && selection.crowdId) {
|
||||
setCrowdMotion(selection.crowdId, kind);
|
||||
return;
|
||||
}
|
||||
setMotion(role.id, kind);
|
||||
}}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<InspectorRangeNumberField
|
||||
label="速度"
|
||||
rangeAriaLabel="角色动作速度滑杆"
|
||||
numberAriaLabel="角色动作速度"
|
||||
min="0.5"
|
||||
max="2.5"
|
||||
step="0.05"
|
||||
value={motion.speed}
|
||||
onValueChange={(value) => {
|
||||
const next = Number(value);
|
||||
if (isCrowd && selection.crowdId) {
|
||||
setCrowdMotionSpeed(selection.crowdId, next);
|
||||
return;
|
||||
}
|
||||
setMotionSpeed(role.id, next);
|
||||
}}
|
||||
/>
|
||||
<InspectorRangeNumberField
|
||||
label="幅度"
|
||||
rangeAriaLabel="角色动作幅度滑杆"
|
||||
numberAriaLabel="角色动作幅度"
|
||||
min="0.5"
|
||||
max="1.5"
|
||||
step="0.05"
|
||||
value={motion.amplitude}
|
||||
onValueChange={(value) => {
|
||||
const next = Number(value);
|
||||
if (isCrowd && selection.crowdId) {
|
||||
setCrowdMotionAmplitude(selection.crowdId, next);
|
||||
return;
|
||||
}
|
||||
setMotionAmplitude(role.id, next);
|
||||
}}
|
||||
/>
|
||||
<InspectorCheckboxField
|
||||
label="启用循环"
|
||||
ariaLabel="启用角色动作循环"
|
||||
checked={motion.enabled}
|
||||
onChange={(checked) => {
|
||||
if (isCrowd && selection.crowdId) {
|
||||
setCrowdMotionEnabled(selection.crowdId, checked);
|
||||
return;
|
||||
}
|
||||
setMotionEnabled(role.id, checked);
|
||||
}}
|
||||
/>
|
||||
{motion.kind === "idle" ? (
|
||||
<p className="motion-hint">闲置动作:当前仅作为配置项保留,关节驱动在下一阶段接入。</p>
|
||||
) : motion.kind === "run" ? (
|
||||
<p className="motion-hint">跑步动作:切换动作时姿势已重置为站立基础态;可在「姿势」标签对各部位叠加偏移微调。</p>
|
||||
) : motion.kind === "walk" ? (
|
||||
<p className="motion-hint">走路动作:切换动作时姿势已重置为站立基础态;可在「姿势」标签对各部位叠加偏移微调。</p>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<p>该模型未识别到标准 humanoid 骨骼,暂不支持基础动作。</p>
|
||||
)}
|
||||
</InspectorSection>
|
||||
)}
|
||||
</InspectorPanel>
|
||||
);
|
||||
|
||||
@@ -53,6 +53,7 @@ type RangeNumberFieldProps = {
|
||||
min: string | number;
|
||||
max: string | number;
|
||||
step: string | number;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type InspectorSelectOption = {
|
||||
@@ -437,6 +438,7 @@ export function InspectorRangeNumberField({
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
disabled,
|
||||
}: RangeNumberFieldProps) {
|
||||
const rangeDragCleanupRef = useRef<(() => void) | null>(null);
|
||||
const { beginInteraction, endInteraction } = useUndoBatchInteraction();
|
||||
@@ -459,12 +461,13 @@ export function InspectorRangeNumberField({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inspector-field inspector-range-field">
|
||||
<div className={`inspector-field inspector-range-field${disabled ? " is-disabled" : ""}`}>
|
||||
<span className="inspector-field-label">{label}</span>
|
||||
<div className="inspector-range-row">
|
||||
<input
|
||||
aria-label={rangeAriaLabel}
|
||||
className="inspector-range"
|
||||
disabled={disabled}
|
||||
max={max}
|
||||
min={min}
|
||||
step={step}
|
||||
@@ -478,6 +481,7 @@ export function InspectorRangeNumberField({
|
||||
<input
|
||||
aria-label={numberAriaLabel}
|
||||
className="inspector-text-input inspector-range-value"
|
||||
disabled={disabled}
|
||||
max={max}
|
||||
min={min}
|
||||
step={step}
|
||||
@@ -546,3 +550,35 @@ export function InspectorSection({ title, className, children }: { title: string
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function InspectorCheckboxField({
|
||||
label,
|
||||
ariaLabel,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
ariaLabel: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { beginInteraction, endInteraction } = useUndoBatchInteraction();
|
||||
|
||||
return (
|
||||
<label className={`inspector-field inspector-checkbox-field${disabled ? " is-disabled" : ""}`}>
|
||||
<span className="inspector-field-label">{label}</span>
|
||||
<input
|
||||
aria-label={ariaLabel}
|
||||
checked={checked}
|
||||
className="inspector-checkbox"
|
||||
disabled={disabled}
|
||||
type="checkbox"
|
||||
onBlur={endInteraction}
|
||||
onChange={(event) => onChange(event.currentTarget.checked)}
|
||||
onFocus={beginInteraction}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,3 +91,40 @@ it("clears all keyframes via the clear-all button", () => {
|
||||
|
||||
expect(useDirectorStore.getState().project.timeline.tracks).toEqual([]);
|
||||
});
|
||||
|
||||
it("grows the timeline panel when dragging the top resizer upward", () => {
|
||||
const { container } = render(<TimelinePanel />);
|
||||
const panel = container.querySelector<HTMLElement>(".timeline-panel")!;
|
||||
expect(panel.style.height).toBe("300px");
|
||||
|
||||
const resizer = screen.getByRole("separator", { name: "拖动调整时间轴高度" });
|
||||
fireEvent.pointerDown(resizer, { clientY: 500, pointerId: 1 });
|
||||
fireEvent.pointerMove(resizer, { clientY: 440, pointerId: 1 }); // 向上拖 60px
|
||||
fireEvent.pointerUp(resizer, { clientY: 440, pointerId: 1 });
|
||||
|
||||
expect(panel.style.height).toBe("360px");
|
||||
});
|
||||
|
||||
it("clamps the timeline height to a minimum when dragging downward far", () => {
|
||||
const { container } = render(<TimelinePanel />);
|
||||
const panel = container.querySelector<HTMLElement>(".timeline-panel")!;
|
||||
|
||||
const resizer = screen.getByRole("separator", { name: "拖动调整时间轴高度" });
|
||||
fireEvent.pointerDown(resizer, { clientY: 500, pointerId: 1 });
|
||||
fireEvent.pointerMove(resizer, { clientY: 900, pointerId: 1 }); // 向下拖 400px
|
||||
fireEvent.pointerUp(resizer, { clientY: 900, pointerId: 1 });
|
||||
|
||||
expect(panel.style.height).toBe("160px");
|
||||
});
|
||||
|
||||
it("clamps the timeline height to the fixed 420px maximum when dragging upward far", () => {
|
||||
const { container } = render(<TimelinePanel />);
|
||||
const panel = container.querySelector<HTMLElement>(".timeline-panel")!;
|
||||
|
||||
const resizer = screen.getByRole("separator", { name: "拖动调整时间轴高度" });
|
||||
fireEvent.pointerDown(resizer, { clientY: 500, pointerId: 1 });
|
||||
fireEvent.pointerMove(resizer, { clientY: 100, pointerId: 1 }); // 向上拖 400px → 700 超上限
|
||||
fireEvent.pointerUp(resizer, { clientY: 100, pointerId: 1 });
|
||||
|
||||
expect(panel.style.height).toBe("420px");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import type { CameraKeyframeValue, KeyframeTransformValue } from "../schema/timeline";
|
||||
import { useDirectorStore } from "../store/directorStore";
|
||||
import { usePlaybackStore } from "../runtime/playbackStore";
|
||||
@@ -10,6 +11,27 @@ interface SelectedKeyframe {
|
||||
keyframeId: string;
|
||||
}
|
||||
|
||||
const TIMELINE_MIN_HEIGHT = 160;
|
||||
const TIMELINE_DEFAULT_HEIGHT = 300;
|
||||
/** 面板最大高度固定值:内容再多也在面板内部滚动,不把 3D 视口压没 */
|
||||
const TIMELINE_MAX_HEIGHT = 420;
|
||||
const TIMELINE_HEIGHT_STORAGE_KEY = "director.timeline.height";
|
||||
|
||||
function clampTimelineHeight(height: number) {
|
||||
return Math.min(Math.max(Math.round(height), TIMELINE_MIN_HEIGHT), TIMELINE_MAX_HEIGHT);
|
||||
}
|
||||
|
||||
function loadTimelineHeight() {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(TIMELINE_HEIGHT_STORAGE_KEY);
|
||||
if (!raw) return TIMELINE_DEFAULT_HEIGHT;
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) ? clampTimelineHeight(value) : TIMELINE_DEFAULT_HEIGHT;
|
||||
} catch {
|
||||
return TIMELINE_DEFAULT_HEIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(t: number) {
|
||||
return `${t.toFixed(1)}s`;
|
||||
}
|
||||
@@ -39,6 +61,52 @@ export function TimelinePanel() {
|
||||
const [selectedKeyframe, setSelectedKeyframe] = useState<SelectedKeyframe | null>(null);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
|
||||
// 时间轴面板高度:可拖动顶部手柄调整(向上拖变大),并持久化到 localStorage
|
||||
const [timelineHeight, setTimelineHeight] = useState(loadTimelineHeight);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const resizeStartRef = useRef<{ pointerY: number; startHeight: number } | null>(null);
|
||||
const timelineHeightRef = useRef(timelineHeight);
|
||||
timelineHeightRef.current = timelineHeight;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) return;
|
||||
document.body.classList.add("is-resizing-timeline");
|
||||
return () => document.body.classList.remove("is-resizing-timeline");
|
||||
}, [isResizing]);
|
||||
|
||||
function handleResizeStart(event: ReactPointerEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
resizeStartRef.current = { pointerY: event.clientY, startHeight: timelineHeight };
|
||||
setIsResizing(true);
|
||||
try {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// 某些环境(如 jsdom)不支持 pointer capture,忽略后仍可通过 move/up 事件拖拽
|
||||
}
|
||||
}
|
||||
|
||||
function handleResizeMove(event: ReactPointerEvent<HTMLDivElement>) {
|
||||
const start = resizeStartRef.current;
|
||||
if (!start) return;
|
||||
setTimelineHeight(clampTimelineHeight(start.startHeight + (start.pointerY - event.clientY)));
|
||||
}
|
||||
|
||||
function handleResizeEnd(event: ReactPointerEvent<HTMLDivElement>) {
|
||||
if (!resizeStartRef.current) return;
|
||||
resizeStartRef.current = null;
|
||||
setIsResizing(false);
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// 同上,忽略不支持的环境
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(TIMELINE_HEIGHT_STORAGE_KEY, String(timelineHeightRef.current));
|
||||
} catch {
|
||||
// 持久化失败(如隐私模式)不影响功能
|
||||
}
|
||||
}
|
||||
|
||||
const timeline = project.timeline;
|
||||
|
||||
// 选中目标解析:物体 or 相机
|
||||
@@ -138,7 +206,22 @@ export function TimelinePanel() {
|
||||
: "请先选择一个对象或相机";
|
||||
|
||||
return (
|
||||
<section className="panel-card timeline-panel" aria-label="时间轴">
|
||||
<section
|
||||
className="panel-card timeline-panel"
|
||||
aria-label="时间轴"
|
||||
style={{ height: timelineHeight }}
|
||||
>
|
||||
<div
|
||||
className={`timeline-resizer${isResizing ? " is-active" : ""}`}
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="拖动调整时间轴高度"
|
||||
title="向上拖动增大时间轴高度"
|
||||
onPointerDown={handleResizeStart}
|
||||
onPointerMove={handleResizeMove}
|
||||
onPointerUp={handleResizeEnd}
|
||||
onPointerCancel={handleResizeEnd}
|
||||
/>
|
||||
<div className="timeline-toolbar">
|
||||
<button type="button" onClick={togglePlaying}>
|
||||
{isPlaying ? "暂停" : "播放"}
|
||||
@@ -185,16 +268,16 @@ export function TimelinePanel() {
|
||||
className="timeline-export-video"
|
||||
disabled={isRecording}
|
||||
onClick={() => void handleExportVideo("director")}
|
||||
title="从当前导演视角录制整段动画为 WebM 视频"
|
||||
title="从导演视角逐帧快速导出整段动画为 MP4 视频"
|
||||
>
|
||||
{isRecording ? "录制中…" : "导出视频·上帝视角"}
|
||||
{isRecording ? "导出中…" : "导出视频·上帝视角"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="timeline-export-video"
|
||||
disabled={isRecording}
|
||||
onClick={() => void handleExportVideo("camera")}
|
||||
title="从当前选中相机的运镜画面录制整段动画为 WebM 视频"
|
||||
title="从当前选中相机的运镜画面逐帧快速导出为 MP4 视频"
|
||||
>
|
||||
{isRecording ? "录制中…" : "导出视频·机位视角"}
|
||||
</button>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { PerspectiveCamera as ThreePerspectiveCamera } from "three";
|
||||
import { useDirectorStore } from "../store/directorStore";
|
||||
import { evaluateCameraTrackAtTime } from "../schema/timeline";
|
||||
import { usePlaybackStore } from "./playbackStore";
|
||||
import { applyActiveCameraViewAtTime } from "./playbackTransforms";
|
||||
|
||||
/**
|
||||
* 机位视图下:若当前激活相机有运镜轨道,每帧用插值结果覆盖渲染相机的
|
||||
* position / lookAt(target) / fov,呈现真正的运镜画面。
|
||||
* 挂在 <Canvas> 内;非机位视图或相机无轨道时为空操作。
|
||||
* position / lookAt(target) / fov,呈现真正的运镜画面(逻辑在 playbackTransforms.ts,
|
||||
* 与视频导出共用)。挂在 <Canvas> 内;非机位视图或相机无轨道时为空操作。
|
||||
*/
|
||||
export function ActiveCameraPlaybackSync({ activeCameraId }: { activeCameraId: string | null }) {
|
||||
const camera = useThree((state) => state.camera);
|
||||
@@ -17,24 +17,15 @@ export function ActiveCameraPlaybackSync({ activeCameraId }: { activeCameraId: s
|
||||
const editingObjectId = usePlaybackStore((state) => state.editingObjectId);
|
||||
|
||||
useFrame(() => {
|
||||
// 机位视角下覆盖渲染相机(播放 + scrub 均生效):
|
||||
// - 导演视角不覆盖,避免抢走 OrbitControls 控制的导演相机
|
||||
// - 正在通过 gizmo / CameraPanel 编辑该相机时不覆盖
|
||||
if (viewMode !== "camera") return;
|
||||
if (!activeCameraId) return;
|
||||
if (editingObjectId === activeCameraId) return;
|
||||
const track = cameraTracks.find((item) => item.cameraId === activeCameraId);
|
||||
if (!track || track.keyframes.length === 0) return;
|
||||
const value = evaluateCameraTrackAtTime(track, playhead);
|
||||
if (!value) return;
|
||||
|
||||
const perspectiveCamera = camera as ThreePerspectiveCamera;
|
||||
perspectiveCamera.position.set(value.position[0], value.position[1], value.position[2]);
|
||||
perspectiveCamera.lookAt(value.target[0], value.target[1], value.target[2]);
|
||||
if (Math.abs(perspectiveCamera.fov - value.fov) > 0.001) {
|
||||
perspectiveCamera.fov = value.fov;
|
||||
perspectiveCamera.updateProjectionMatrix();
|
||||
}
|
||||
// 机位视角下覆盖渲染相机(播放 + scrub 均生效),导演视角 / 编辑中不覆盖
|
||||
applyActiveCameraViewAtTime({
|
||||
camera: camera as ThreePerspectiveCamera,
|
||||
cameraTracks,
|
||||
activeCameraId,
|
||||
playhead,
|
||||
viewMode,
|
||||
editingObjectId,
|
||||
});
|
||||
});
|
||||
|
||||
return null;
|
||||
|
||||
@@ -9,6 +9,8 @@ interface CharacterModelProps {
|
||||
color?: string;
|
||||
onLabelAnchorYChange?: (anchorY: number) => void;
|
||||
rigState?: CharacterRigState;
|
||||
motionTime?: number;
|
||||
characterId?: string;
|
||||
}
|
||||
|
||||
class CharacterModelBoundary extends Component<
|
||||
@@ -31,8 +33,8 @@ class CharacterModelBoundary extends Component<
|
||||
}
|
||||
}
|
||||
|
||||
export function CharacterModel({ bodyType, color, onLabelAnchorYChange, rigState }: CharacterModelProps) {
|
||||
const fallback = <PrimitiveMannequin bodyType={bodyType} color={color} rigState={rigState} />;
|
||||
export function CharacterModel({ bodyType, color, onLabelAnchorYChange, rigState, motionTime, characterId }: CharacterModelProps) {
|
||||
const fallback = <PrimitiveMannequin bodyType={bodyType} color={color} rigState={rigState} motionTime={motionTime} characterId={characterId} />;
|
||||
|
||||
if (rigState?.rigType !== "ue4-mannequin") {
|
||||
return fallback;
|
||||
@@ -45,6 +47,8 @@ export function CharacterModel({ bodyType, color, onLabelAnchorYChange, rigState
|
||||
color={color}
|
||||
onLabelAnchorYChange={onLabelAnchorYChange}
|
||||
rigState={rigState}
|
||||
motionTime={motionTime}
|
||||
characterId={characterId}
|
||||
/>
|
||||
</CharacterModelBoundary>
|
||||
);
|
||||
|
||||
@@ -6,8 +6,10 @@ interface PrimitiveMannequinProps {
|
||||
bodyType?: CharacterBodyType;
|
||||
color?: string;
|
||||
rigState?: CharacterRigState;
|
||||
motionTime?: number;
|
||||
characterId?: string;
|
||||
}
|
||||
|
||||
export function PrimitiveMannequin({ bodyType, color = "#4F8EF7", rigState }: PrimitiveMannequinProps) {
|
||||
return <ProceduralMannequin bodyType={bodyType} color={color} rigState={rigState} />;
|
||||
export function PrimitiveMannequin({ bodyType, color = "#4F8EF7", rigState, motionTime, characterId }: PrimitiveMannequinProps) {
|
||||
return <ProceduralMannequin bodyType={bodyType} color={color} rigState={rigState} motionTime={motionTime} characterId={characterId} />;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.j
|
||||
import type { CharacterRigState } from "../schema/directorProject";
|
||||
import { VIEWPORT_OBJECT_LABEL_VERTICAL_GAP } from "../schema/viewportLabels";
|
||||
import type { CharacterBodyType } from "./mannequin/bodyTypes";
|
||||
import { applyMotionOverrides } from "./mannequin/motion";
|
||||
import {
|
||||
UE4_MANNEQUIN_MODEL_URL,
|
||||
getUE4ModelScale,
|
||||
@@ -27,6 +28,10 @@ interface UE4MannequinModelProps {
|
||||
color?: string;
|
||||
onLabelAnchorYChange?: (anchorY: number) => void;
|
||||
rigState?: CharacterRigState;
|
||||
/** 全局时间(秒)= playbackStore.playhead;驱动基础动作循环。 */
|
||||
motionTime?: number;
|
||||
/** 稳定 id,用于群众相位偏移。 */
|
||||
characterId?: string;
|
||||
}
|
||||
|
||||
interface LoadedGLTF {
|
||||
@@ -129,18 +134,27 @@ export function UE4MannequinModel({
|
||||
color = "#F3F5F7",
|
||||
onLabelAnchorYChange,
|
||||
rigState,
|
||||
motionTime,
|
||||
characterId,
|
||||
}: UE4MannequinModelProps) {
|
||||
const gltf = useLoader(GLTFLoader, UE4_MANNEQUIN_MODEL_URL) as LoadedGLTF;
|
||||
const scene = useMemo(() => cloneSkeleton(gltf.scene) as Group, [gltf.scene]);
|
||||
const restPose = useMemo(() => captureUE4RestPose(scene), [scene]);
|
||||
const modelScale = getUE4ModelScale(bodyType);
|
||||
const rigControls = rigState?.controls ?? {};
|
||||
// 基础动作(走/跑)按 motionTime 计算关节角覆盖后合并进 controls(度数域)。
|
||||
// 播放/scrub 时 motionTime 每帧变化 → effect 每帧重摆姿势,即动画本体。
|
||||
const controlsWithMotion = useMemo(
|
||||
() => applyMotionOverrides(rigControls, { motion: rigState?.motion, time: motionTime ?? 0, characterId: characterId ?? "" }),
|
||||
[rigControls, rigState?.motion, motionTime, characterId]
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
isolateAndTintUE4MannequinMaterials(scene, color);
|
||||
|
||||
applyUE4RestPoseAndRig(scene, {
|
||||
bodyType,
|
||||
controls: rigState?.controls ?? {},
|
||||
controls: controlsWithMotion,
|
||||
restPose,
|
||||
});
|
||||
alignUE4MannequinToGround(scene);
|
||||
@@ -152,7 +166,7 @@ export function UE4MannequinModel({
|
||||
if (Number.isFinite(labelAnchorY)) {
|
||||
onLabelAnchorYChange?.(Number(labelAnchorY.toFixed(4)));
|
||||
}
|
||||
}, [bodyType, color, onLabelAnchorYChange, restPose, rigState?.controls, scene]);
|
||||
}, [bodyType, color, controlsWithMotion, onLabelAnchorYChange, restPose, scene]);
|
||||
|
||||
return (
|
||||
<group name={`ue-retopology-mannequin-${bodyType}`} scale={modelScale}>
|
||||
|
||||
@@ -2,11 +2,16 @@ import type { CharacterRigState } from "../../schema/directorProject";
|
||||
import { getBodyPreset, type CharacterBodyType } from "./bodyTypes";
|
||||
import { degreesToRadians, getBodyTypePoseLimit, getRotationFromControls, getSingleAxisRotation } from "./mannequinPose";
|
||||
import { Foot, Hand, Head, Joint, Segment, Torso } from "./mannequinParts";
|
||||
import { applyMotionOverrides } from "./motion";
|
||||
|
||||
interface ProceduralMannequinProps {
|
||||
bodyType?: CharacterBodyType;
|
||||
color?: string;
|
||||
rigState?: CharacterRigState;
|
||||
/** 全局时间(秒),通常 = playbackStore.playhead。仅在 motion.kind !== "none" 时被使用。 */
|
||||
motionTime?: number;
|
||||
/** 稳定 id,用于驱动 phase 偏移;缺省时所有个体相位一致。 */
|
||||
characterId?: string;
|
||||
}
|
||||
|
||||
function clampDegrees(value: number, bodyType?: CharacterBodyType) {
|
||||
@@ -26,9 +31,13 @@ function getLimbRotation(
|
||||
];
|
||||
}
|
||||
|
||||
export function ProceduralMannequin({ bodyType, color = "#4F8EF7", rigState }: ProceduralMannequinProps) {
|
||||
export function ProceduralMannequin({ bodyType, color = "#4F8EF7", rigState, motionTime, characterId }: ProceduralMannequinProps) {
|
||||
const preset = getBodyPreset(bodyType);
|
||||
const controls = rigState?.controls ?? {};
|
||||
const controls = applyMotionOverrides(rigState?.controls ?? {}, {
|
||||
motion: rigState?.motion,
|
||||
time: motionTime ?? 0,
|
||||
characterId: characterId ?? "",
|
||||
});
|
||||
const p = preset.proportions;
|
||||
|
||||
const bodyRotation = getRotationFromControls(controls, "body", preset.bodyType);
|
||||
|
||||
242
src/editor/runtime/mannequin/motion.test.ts
Normal file
242
src/editor/runtime/mannequin/motion.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyMotionOverrides,
|
||||
computeMotionOverrides,
|
||||
hashIdToPhase,
|
||||
MOTION_DRIVEN_CONTROL_KEYS,
|
||||
RUN_FREQUENCY_MULTIPLIER,
|
||||
} from "./motion";
|
||||
import type { CharacterMotionState } from "../../schema/directorProject";
|
||||
|
||||
const WALK: CharacterMotionState = { kind: "walk", speed: 1, amplitude: 1, enabled: true };
|
||||
const RUN: CharacterMotionState = { kind: "run", speed: 1, amplitude: 1, enabled: true };
|
||||
|
||||
describe("computeMotionOverrides", () => {
|
||||
it("returns empty override when motion is null/undefined", () => {
|
||||
expect(computeMotionOverrides({ motion: null, time: 1, characterId: "x" })).toEqual({});
|
||||
expect(computeMotionOverrides({ motion: undefined, time: 1, characterId: "x" })).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty override when kind is none", () => {
|
||||
expect(computeMotionOverrides({ motion: { ...WALK, kind: "none" }, time: 1, characterId: "x" })).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty override when enabled=false", () => {
|
||||
expect(computeMotionOverrides({ motion: { ...WALK, enabled: false }, time: 1, characterId: "x" })).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty override for idle (MVP placeholder)", () => {
|
||||
expect(
|
||||
computeMotionOverrides({ motion: { ...WALK, kind: "idle" }, time: 1, characterId: "x" })
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
/** 求使左肢相位 θL = target 的 time(speed=1,走路频率)。 */
|
||||
function timeForLeftPhase(target: number, characterId: string): number {
|
||||
const phase = hashIdToPhase(characterId);
|
||||
return (target - phase) / (Math.PI * 2);
|
||||
}
|
||||
|
||||
/** 求使跑步左肢相位 θL = target 的 time(speed=1;跑步基础频率 ×1.5)。 */
|
||||
function timeForRunLeftPhase(target: number, characterId: string): number {
|
||||
const phase = hashIdToPhase(characterId);
|
||||
return (target - phase) / (RUN_FREQUENCY_MULTIPLIER * Math.PI * 2);
|
||||
}
|
||||
|
||||
it("walk: 腿摆到最前(脚跟着地)时膝盖几乎伸直 —— 自然步态关键特征", () => {
|
||||
const t = timeForLeftPhase(Math.PI / 2, "x");
|
||||
const o = computeMotionOverrides({ motion: WALK, time: t, characterId: "x" });
|
||||
|
||||
// 左髋前摆峰值 25°
|
||||
expect((o["leftHip.pitch"] as number) * 180 / Math.PI).toBeCloseTo(25);
|
||||
// 右髋后摆 -12°(不对称摆幅)
|
||||
expect((o["rightHip.pitch"] as number) * 180 / Math.PI).toBeCloseTo(-12);
|
||||
// 左膝着地瞬间伸直
|
||||
expect(o["leftKnee.bend"]).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("walk: 摆动中期膝峰 45°,对侧腿同时伸直", () => {
|
||||
const t = timeForLeftPhase(0, "x");
|
||||
const o = computeMotionOverrides({ motion: WALK, time: t, characterId: "x" });
|
||||
|
||||
expect((o["leftKnee.bend"] as number) * 180 / Math.PI).toBeCloseTo(45);
|
||||
expect(o["rightKnee.bend"]).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("walk: 肘部微屈并随摆臂开合(前摆收紧、后摆打开)", () => {
|
||||
const t = timeForLeftPhase(Math.PI / 2, "x");
|
||||
const o = computeMotionOverrides({ motion: WALK, time: t, characterId: "x" });
|
||||
|
||||
// θL=π/2:左臂后摆(肘打开 6°),右臂前摆(肘收紧 18°);中心 12° ± 6°
|
||||
expect((o["leftElbow.bend"] as number) * 180 / Math.PI).toBeCloseTo(6);
|
||||
expect((o["rightElbow.bend"] as number) * 180 / Math.PI).toBeCloseTo(18);
|
||||
expect(o).toHaveProperty("torso.yaw");
|
||||
});
|
||||
|
||||
it("walk: 臂与对侧腿同相(正值=前摆;左臂随右腿,不顺拐)", () => {
|
||||
const t = timeForLeftPhase(Math.PI / 2, "x");
|
||||
const o = computeMotionOverrides({ motion: WALK, time: t, characterId: "x" });
|
||||
|
||||
// 左腿前摆(+25)→ 左臂后摆(-16)、右臂前摆(+24),对侧摆动
|
||||
expect((o["leftShoulder.pitch"] as number) * 180 / Math.PI).toBeCloseTo(-16);
|
||||
expect((o["rightShoulder.pitch"] as number) * 180 / Math.PI).toBeCloseTo(24);
|
||||
});
|
||||
|
||||
it("run: 前倾 -11°、肩对称摆 ±36°+微内旋 8°+贴身外展、屈肘明显", () => {
|
||||
const t = timeForRunLeftPhase(0, "x");
|
||||
const o = computeMotionOverrides({ motion: RUN, time: t, characterId: "x" });
|
||||
|
||||
// 摆动中期(θarm 中点)肘角 55°(区间中点)
|
||||
expect((o["leftElbow.bend"] as number) * 180 / Math.PI).toBeCloseTo(55);
|
||||
// 肩部:对称摆幅 ±36°,中心 0(此刻两臂都过中点)
|
||||
expect((o["leftShoulder.pitch"] as number) * 180 / Math.PI).toBeCloseTo(0);
|
||||
// 上臂微内旋 8°
|
||||
expect((o["leftShoulder.twist"] as number) * 180 / Math.PI).toBeCloseTo(8);
|
||||
expect((o["rightShoulder.twist"] as number) * 180 / Math.PI).toBeCloseTo(8);
|
||||
// 双臂贴身:微外展 8°(左右符号镜像)
|
||||
expect((o["leftShoulder.spread"] as number) * 180 / Math.PI).toBeCloseTo(-8);
|
||||
expect((o["rightShoulder.spread"] as number) * 180 / Math.PI).toBeCloseTo(8);
|
||||
// 身体前倾为负值方向,中心 -14° ± 2° 起伏
|
||||
const bodyPitchDeg = (o["body.pitch"] as number) * 180 / Math.PI;
|
||||
expect(bodyPitchDeg).toBeLessThan(-12);
|
||||
expect(bodyPitchDeg).toBeGreaterThan(-16);
|
||||
// 腕部微屈(握拳近似)
|
||||
expect((o["leftHand.pitch"] as number) * 180 / Math.PI).toBeCloseTo(12);
|
||||
// 摆动中期膝峰 90°(跑步小腿折叠)
|
||||
expect((o["leftKnee.bend"] as number) * 180 / Math.PI).toBeCloseTo(90);
|
||||
});
|
||||
|
||||
it("run: 肘部在 48°~62° 区间开合(前摆收紧、后摆稍开),肩前 +36 后 -36", () => {
|
||||
// θL=π/2:左腿前摆 → 左臂后摆(肘 48),右臂前摆(肘收紧 62)
|
||||
const t = timeForRunLeftPhase(Math.PI / 2, "x");
|
||||
const o = computeMotionOverrides({ motion: RUN, time: t, characterId: "x" });
|
||||
|
||||
expect((o["leftElbow.bend"] as number) * 180 / Math.PI).toBeCloseTo(48);
|
||||
expect((o["rightElbow.bend"] as number) * 180 / Math.PI).toBeCloseTo(62);
|
||||
// 肩摆(正=前摆):右臂随左腿对侧前摆到 +36,左臂后摆到 -36 —— 对侧,不顺拐
|
||||
expect((o["rightShoulder.pitch"] as number) * 180 / Math.PI).toBeCloseTo(36);
|
||||
expect((o["leftShoulder.pitch"] as number) * 180 / Math.PI).toBeCloseTo(-36);
|
||||
// 腿摆幅加大:左髋前摆峰值 52°,右髋同时后摆 -26°
|
||||
expect((o["leftHip.pitch"] as number) * 180 / Math.PI).toBeCloseTo(52);
|
||||
expect((o["rightHip.pitch"] as number) * 180 / Math.PI).toBeCloseTo(-26);
|
||||
// 跑姿前摆腿保持屈膝:腿到最前时膝 55°(不是走路的伸直 0°)
|
||||
expect((o["leftKnee.bend"] as number) * 180 / Math.PI).toBeCloseTo(55);
|
||||
});
|
||||
|
||||
it("walk respects amplitude multiplier", () => {
|
||||
const strong: CharacterMotionState = { ...WALK, amplitude: 1.5 };
|
||||
const mild = computeMotionOverrides({ motion: WALK, time: 0.25, characterId: "x" });
|
||||
const big = computeMotionOverrides({ motion: strong, time: 0.25, characterId: "x" });
|
||||
// amplitude 是纯乘子
|
||||
expect((big["leftHip.pitch"] as number) / (mild["leftHip.pitch"] as number)).toBeCloseTo(1.5);
|
||||
});
|
||||
|
||||
it("walk respects speed multiplier", () => {
|
||||
const fast: CharacterMotionState = { ...WALK, speed: 2 };
|
||||
const slow = computeMotionOverrides({ motion: WALK, time: 0.25, characterId: "x" });
|
||||
const quick = computeMotionOverrides({ motion: fast, time: 0.125, characterId: "x" });
|
||||
// speed=2, t=0.125 等价于 speed=1, t=0.25
|
||||
expect(quick["leftHip.pitch"] as number).toBeCloseTo(slow["leftHip.pitch"] as number);
|
||||
});
|
||||
|
||||
it("walk covers expected joint keys", () => {
|
||||
const overrides = computeMotionOverrides({ motion: WALK, time: 0.5, characterId: "char_42" });
|
||||
const keys = new Set(Object.keys(overrides));
|
||||
for (const expected of [
|
||||
"leftHip.pitch",
|
||||
"rightHip.pitch",
|
||||
"leftKnee.bend",
|
||||
"rightKnee.bend",
|
||||
"leftShoulder.pitch",
|
||||
"rightShoulder.pitch",
|
||||
"leftElbow.bend",
|
||||
"rightElbow.bend",
|
||||
"torso.yaw",
|
||||
]) {
|
||||
expect(keys).toContain(expected);
|
||||
}
|
||||
// 所有覆盖的键必须在 MOTION_DRIVEN_CONTROL_KEYS 中
|
||||
for (const k of keys) {
|
||||
expect(MOTION_DRIVEN_CONTROL_KEYS.has(k)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("left and right hips are 180° out of phase (asymmetric swing: forward 25° / backward 12°)", () => {
|
||||
// θL=π/2:左髋前摆峰值 25°,右髋(θR=θL+π)同时到达后摆峰值 -12°
|
||||
const t = timeForLeftPhase(Math.PI / 2, "char_42");
|
||||
const o = computeMotionOverrides({ motion: WALK, time: t, characterId: "char_42" });
|
||||
expect((o["leftHip.pitch"] as number) * 180 / Math.PI).toBeCloseTo(25);
|
||||
expect((o["rightHip.pitch"] as number) * 180 / Math.PI).toBeCloseTo(-12);
|
||||
|
||||
// 半周期后左右互换(真正的 180° 反相验证)
|
||||
const tHalf = timeForLeftPhase(Math.PI / 2 + Math.PI, "char_42");
|
||||
const oHalf = computeMotionOverrides({ motion: WALK, time: tHalf, characterId: "char_42" });
|
||||
expect((oHalf["leftHip.pitch"] as number) * 180 / Math.PI).toBeCloseTo(-12);
|
||||
expect((oHalf["rightHip.pitch"] as number) * 180 / Math.PI).toBeCloseTo(25);
|
||||
});
|
||||
|
||||
it("different character ids produce different phase offsets", () => {
|
||||
const a = computeMotionOverrides({ motion: WALK, time: 0.3, characterId: "char_a" });
|
||||
const b = computeMotionOverrides({ motion: WALK, time: 0.3, characterId: "char_b" });
|
||||
// 群体里不同个体不应齐刷刷
|
||||
expect(a["leftHip.pitch"]).not.toBeCloseTo(b["leftHip.pitch"] as number);
|
||||
});
|
||||
|
||||
it("returns empty override for non-finite time", () => {
|
||||
expect(computeMotionOverrides({ motion: WALK, time: Number.NaN, characterId: "x" })).toEqual({});
|
||||
expect(computeMotionOverrides({ motion: WALK, time: Number.POSITIVE_INFINITY, characterId: "x" })).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyMotionOverrides", () => {
|
||||
it("returns the same controls reference when nothing is overridden", () => {
|
||||
const controls = { "head.yaw": 5 };
|
||||
expect(
|
||||
applyMotionOverrides(controls, { motion: null, time: 1, characterId: "x" })
|
||||
).toBe(controls);
|
||||
expect(
|
||||
applyMotionOverrides(controls, { motion: { ...WALK, kind: "none" }, time: 1, characterId: "x" })
|
||||
).toBe(controls);
|
||||
});
|
||||
|
||||
it("分层合并:动作角度 + controls 偏移(度数域)", () => {
|
||||
const controls = { "head.yaw": 5, "leftHip.pitch": 3 };
|
||||
const merged = applyMotionOverrides(controls, { motion: WALK, time: 0.25, characterId: "x" });
|
||||
const expected = computeMotionOverrides({ motion: WALK, time: 0.25, characterId: "x" });
|
||||
|
||||
expect(merged).not.toBe(controls);
|
||||
expect(merged["head.yaw"]).toBe(5); // 动作未驱动的关节原样保留
|
||||
// 动作驱动的关节 = 动作角度 + controls 偏移
|
||||
expect(merged["leftHip.pitch"]).toBeCloseTo(3 + ((expected["leftHip.pitch"] as number) * 180) / Math.PI);
|
||||
});
|
||||
|
||||
it("偏移微调示例:髋 +10° 偏移叠加在动作循环之上", () => {
|
||||
const base = computeMotionOverrides({ motion: WALK, time: 0.25, characterId: "x" });
|
||||
const offsetDeg = 10;
|
||||
const merged = applyMotionOverrides(
|
||||
{ "leftHip.pitch": offsetDeg },
|
||||
{ motion: WALK, time: 0.25, characterId: "x" }
|
||||
);
|
||||
|
||||
expect(merged["leftHip.pitch"]).toBeCloseTo(offsetDeg + ((base["leftHip.pitch"] as number) * 180) / Math.PI);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashIdToPhase", () => {
|
||||
it("produces a value in [0, 2π)", () => {
|
||||
for (const id of ["a", "char_1", "long-character-id-123", "中文-id-也-ok"]) {
|
||||
const p = hashIdToPhase(id);
|
||||
expect(p).toBeGreaterThanOrEqual(0);
|
||||
expect(p).toBeLessThan(Math.PI * 2);
|
||||
}
|
||||
});
|
||||
|
||||
it("is deterministic", () => {
|
||||
expect(hashIdToPhase("foo")).toBe(hashIdToPhase("foo"));
|
||||
});
|
||||
|
||||
it("different ids yield different phases in general", () => {
|
||||
const phases = new Set(["a", "b", "c", "d", "e", "f"].map(hashIdToPhase));
|
||||
expect(phases.size).toBeGreaterThan(4);
|
||||
});
|
||||
});
|
||||
221
src/editor/runtime/mannequin/motion.ts
Normal file
221
src/editor/runtime/mannequin/motion.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import type { CharacterMotionState, MotionKind } from "../../schema/directorProject";
|
||||
|
||||
/**
|
||||
* 基础动作关节驱动
|
||||
*
|
||||
* 接受 motion 配置 + 时间 t(秒)+ character id,返回一组「关节角覆盖」。
|
||||
* 返回的对象只包含被动作驱动的键;调用方应当把它放在 rigState.controls **之上**进行合并。
|
||||
*
|
||||
* 设计要点:
|
||||
* - 纯函数:可在测试里直接断言 t=0 / π/2 / π 时的输出
|
||||
* - phase 偏移:根据 characterId 哈希成 0 – 2π 的常数偏移,让群体里不同个体不齐刷刷
|
||||
* - 不直接依赖 store / playbackStore / R3F,方便单测
|
||||
*/
|
||||
|
||||
export type MotionOverride = Partial<Record<string, number>>;
|
||||
|
||||
export interface MotionParams {
|
||||
motion: CharacterMotionState | null | undefined;
|
||||
/** 全局时间(秒),通常 = playbackStore.playhead */
|
||||
time: number;
|
||||
/** 用于产生 phase 偏移的稳定 id(一般是 directorObject.id) */
|
||||
characterId: string;
|
||||
}
|
||||
|
||||
const DEFAULT_MOTION: CharacterMotionState = {
|
||||
kind: "none",
|
||||
speed: 1,
|
||||
amplitude: 1,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
function normalizeMotion(motion: CharacterMotionState | null | undefined): CharacterMotionState {
|
||||
if (!motion) return DEFAULT_MOTION;
|
||||
return {
|
||||
kind: motion.kind ?? "none",
|
||||
speed: typeof motion.speed === "number" ? motion.speed : 1,
|
||||
amplitude: typeof motion.amplitude === "number" ? motion.amplitude : 1,
|
||||
enabled: motion.enabled !== false,
|
||||
};
|
||||
}
|
||||
|
||||
/** 把任意 string hash 成 [0, 2π) 的浮点。FNV-1a 足够稳定。 */
|
||||
export function hashIdToPhase(id: string): number {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < id.length; i += 1) {
|
||||
hash ^= id.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
// 映射到 [0, 2π)
|
||||
return ((hash >>> 0) / 0xffffffff) * Math.PI * 2;
|
||||
}
|
||||
|
||||
function degreesToRadians(deg: number): number {
|
||||
return (deg * Math.PI) / 180;
|
||||
}
|
||||
|
||||
/** 跑步相对走路的基础频率倍数:真人跑步步频更快(周期 ≈ 0.67s @speed=1,走路 1s)。 */
|
||||
export const RUN_FREQUENCY_MULTIPLIER = 1.5;
|
||||
|
||||
/** 单腿/单臂的相位:左 0、右 π(与左反相)。 */
|
||||
function leftPhase(omegaT: number): number {
|
||||
return omegaT;
|
||||
}
|
||||
function rightPhase(omegaT: number): number {
|
||||
return omegaT + Math.PI;
|
||||
}
|
||||
|
||||
/**
|
||||
* 腿/臂的相位语义(θ 为该肢相位):
|
||||
* - 髋 hip = f(sin θ):θ=π/2 腿摆到最前(脚跟着地),θ=3π/2 最后(蹬离)
|
||||
* - 膝 knee ∝ max(0, cos θ):cos>0 = 摆动期(腿从后往前扫),θ=0 为摆动中期膝峰;
|
||||
* θ=π/2 着地时 cos=0 → 膝伸直 —— 这是自然步态与"正步走"的关键区别
|
||||
* - 摆幅不对称:前摆大、后摆小(真人步态如此)
|
||||
*/
|
||||
function swingHip(forwardDeg: number, backDeg: number, amp: number, theta: number): number {
|
||||
const s = Math.sin(theta);
|
||||
return (s >= 0 ? forwardDeg * s : backDeg * s) * amp;
|
||||
}
|
||||
|
||||
function swingKnee(peakDeg: number, amp: number, theta: number): number {
|
||||
return peakDeg * amp * Math.max(0, Math.cos(theta));
|
||||
}
|
||||
|
||||
/** 跑步膝部曲线(与走路不同:跑姿全程屈膝,不伸直锁死):
|
||||
* - 基础 baseDeg:支撑期也保持微弯(吸收冲击)
|
||||
* - 前摆加弯 frontDeg:腿摆到最前(θ=π/2)= base+front(脚掌着地屈膝)
|
||||
* - 折叠 foldDeg:摆动中期(θ=0)小腿收起达折叠峰 */
|
||||
function runKnee(baseDeg: number, frontDeg: number, foldDeg: number, amp: number, theta: number): number {
|
||||
const front = Math.max(0, Math.sin(theta)) * frontDeg;
|
||||
const fold = Math.max(0, Math.cos(theta)) * foldDeg;
|
||||
return (baseDeg + front + fold) * amp;
|
||||
}
|
||||
|
||||
function swingShoulder(forwardDeg: number, backDeg: number, amp: number, theta: number): number {
|
||||
const s = Math.sin(theta);
|
||||
return (s >= 0 ? forwardDeg * s : backDeg * s) * amp;
|
||||
}
|
||||
|
||||
/** 计算 idle / walk / run 的关节覆盖;none / 禁用 / 异常输入返回空对象。 */
|
||||
export function computeMotionOverrides({ motion, time, characterId }: MotionParams): MotionOverride {
|
||||
const m = normalizeMotion(motion);
|
||||
if (!m.enabled) return {};
|
||||
if (m.kind === "none") return {};
|
||||
if (!Number.isFinite(time)) return {};
|
||||
|
||||
const phase = hashIdToPhase(characterId);
|
||||
const omegaT = time * m.speed * Math.PI * 2 + phase;
|
||||
const L = leftPhase(omegaT);
|
||||
const R = rightPhase(omegaT);
|
||||
|
||||
if (m.kind === "idle") {
|
||||
// 当前 MVP 只保留接口:闲置无明显关节驱动。
|
||||
// 后续阶段会接入轻微呼吸/眨眼。
|
||||
return {};
|
||||
}
|
||||
|
||||
if (m.kind === "walk") {
|
||||
const amp = m.amplitude;
|
||||
return {
|
||||
// 髋:前摆 25° / 后摆 12°(正常步幅),左右反相
|
||||
"leftHip.pitch": degreesToRadians(swingHip(25, 12, amp, L)),
|
||||
"rightHip.pitch": degreesToRadians(swingHip(25, 12, amp, R)),
|
||||
// 膝:摆动中期峰 45°,着地/支撑期伸直
|
||||
"leftKnee.bend": degreesToRadians(swingKnee(45, amp, L)),
|
||||
"rightKnee.bend": degreesToRadians(swingKnee(45, amp, R)),
|
||||
// 臂与对侧腿同相(肩 pitch 正值=前摆,与髋同号;左臂随右腿):前摆 24°、后摆 16°
|
||||
"leftShoulder.pitch": degreesToRadians(swingShoulder(24, 16, amp, R)),
|
||||
"rightShoulder.pitch": degreesToRadians(swingShoulder(24, 16, amp, L)),
|
||||
// 肘微屈并随摆臂开合:中心 12° ± 6°(前摆收紧 18°、后摆打开 6°)
|
||||
"leftElbow.bend": degreesToRadians((12 + 6 * Math.sin(R)) * amp),
|
||||
"rightElbow.bend": degreesToRadians((12 + 6 * Math.sin(L)) * amp),
|
||||
// 躯干与肩线反向微扭,让上半身有跟随感
|
||||
"torso.yaw": degreesToRadians(4 * amp * Math.sin(L)),
|
||||
};
|
||||
}
|
||||
|
||||
if (m.kind === "run") {
|
||||
const amp = m.amplitude;
|
||||
// 跑步步频更快:独立相位(基础频率 × 1.5),speed 仍作乘数
|
||||
const runL = leftPhase(time * m.speed * RUN_FREQUENCY_MULTIPLIER * Math.PI * 2 + phase);
|
||||
const runR = rightPhase(runL);
|
||||
return {
|
||||
// 髋:跑步前摆更大 52° / 后摆 26°
|
||||
"leftHip.pitch": degreesToRadians(swingHip(52, 26, amp, runL)),
|
||||
"rightHip.pitch": degreesToRadians(swingHip(52, 26, amp, runR)),
|
||||
// 膝:跑姿全程屈膝 —— 基础 30°(支撑吸震)、前摆至最前 55°(脚掌着地屈膝)、摆动中期折叠峰 90°
|
||||
"leftKnee.bend": degreesToRadians(runKnee(30, 25, 60, amp, runL)),
|
||||
"rightKnee.bend": degreesToRadians(runKnee(30, 25, 60, amp, runR)),
|
||||
// 臂与对侧腿同相(肩 pitch 正值=前摆):对称摆幅 ±36°(参考校准姿态:左 -36 / 右 +36 交替)
|
||||
"leftShoulder.pitch": degreesToRadians((36 * Math.sin(runR)) * amp),
|
||||
"rightShoulder.pitch": degreesToRadians((36 * Math.sin(runL)) * amp),
|
||||
// 上臂微内旋(前臂朝前)+ 微外展贴身,双臂不张开
|
||||
"leftShoulder.twist": degreesToRadians(8 * amp),
|
||||
"rightShoulder.twist": degreesToRadians(8 * amp),
|
||||
"leftShoulder.spread": degreesToRadians(-8 * amp),
|
||||
"rightShoulder.spread": degreesToRadians(8 * amp),
|
||||
// 肘部跑姿屈肘明显:中心 55° ± 7°(前摆收紧 62°、后摆打开 48°)
|
||||
"leftElbow.bend": degreesToRadians((55 + 7 * Math.sin(runR)) * amp),
|
||||
"rightElbow.bend": degreesToRadians((55 + 7 * Math.sin(runL)) * amp),
|
||||
// 腕部微屈内收:rig 无手指骨骼,用腕部张力近似"握拳"姿态
|
||||
"leftHand.pitch": degreesToRadians(12 * amp),
|
||||
"rightHand.pitch": degreesToRadians(12 * amp),
|
||||
// 躯干反扭更明显
|
||||
"torso.yaw": degreesToRadians(7 * amp * Math.sin(runL)),
|
||||
// 前倾 -14°(快跑水平)+ 步频两倍的上下起伏
|
||||
"body.pitch": degreesToRadians((-14 + 2 * Math.sin(runL * 2)) * amp),
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/** 把动作叠加进 controls(度数域,分层模型)。
|
||||
* 动作是基础层(优先级最高);controls 里的值作为叠加偏移(微调层):
|
||||
* 最终角度 = 动作角度 + controls[key]。动作未驱动的关节仍直接用 controls[key]。
|
||||
* 进动作时 controls 已被重置为站立({}),因此偏移从干净状态开始累积。
|
||||
* computeMotionOverrides 返回弧度;controls 全库统一用度数,这里做换算后相加。
|
||||
* 不做 clamp —— 两条渲染路径(ProceduralMannequin / UE4 rig)各自有关节限位逻辑。
|
||||
* 返回原对象引用当且仅当没有任何覆盖(便于跳过无谓重算)。 */
|
||||
export function applyMotionOverrides(
|
||||
controls: Record<string, number>,
|
||||
params: MotionParams
|
||||
): Record<string, number> {
|
||||
const overrides = computeMotionOverrides(params);
|
||||
if (Object.keys(overrides).length === 0) return controls;
|
||||
|
||||
const next: Record<string, number> = { ...controls };
|
||||
for (const [key, radians] of Object.entries(overrides)) {
|
||||
if (typeof radians !== "number" || !Number.isFinite(radians)) continue;
|
||||
next[key] = (next[key] ?? 0) + (radians * 180) / Math.PI;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 给 UI 用的枚举列表(与 MotionKind 一一对应)。 */
|
||||
export const MOTION_OPTIONS: { value: MotionKind; label: string }[] = [
|
||||
{ value: "none", label: "无" },
|
||||
{ value: "idle", label: "闲置" },
|
||||
{ value: "walk", label: "走路" },
|
||||
{ value: "run", label: "跑步" },
|
||||
];
|
||||
|
||||
/** 走路/跑步驱动的关节键集合(文档性质:这些键的动作值会被 controls 偏移叠加)。 */
|
||||
export const MOTION_DRIVEN_CONTROL_KEYS = new Set<string>([
|
||||
"leftHip.pitch",
|
||||
"rightHip.pitch",
|
||||
"leftKnee.bend",
|
||||
"rightKnee.bend",
|
||||
"leftShoulder.pitch",
|
||||
"rightShoulder.pitch",
|
||||
"leftShoulder.twist",
|
||||
"rightShoulder.twist",
|
||||
"leftShoulder.spread",
|
||||
"rightShoulder.spread",
|
||||
"leftElbow.bend",
|
||||
"rightElbow.bend",
|
||||
"leftHand.pitch",
|
||||
"rightHand.pitch",
|
||||
"torso.yaw",
|
||||
"body.pitch",
|
||||
]);
|
||||
127
src/editor/runtime/playbackTransforms.test.ts
Normal file
127
src/editor/runtime/playbackTransforms.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Object3D, PerspectiveCamera, Vector3 } from "three";
|
||||
import type { CameraTimelineTrack, TimelineTrack } from "../schema/timeline";
|
||||
import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "./playbackTransforms";
|
||||
import { computeExportSize } from "../io/videoExport";
|
||||
import { getViewportCameraQuaternion } from "../canvas/SceneRoot";
|
||||
|
||||
function makeObjectTrack(): TimelineTrack {
|
||||
return {
|
||||
objectId: "obj-1",
|
||||
keyframes: [
|
||||
{
|
||||
id: "kf-1",
|
||||
time: 0,
|
||||
value: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] },
|
||||
},
|
||||
{
|
||||
id: "kf-2",
|
||||
time: 2,
|
||||
value: { position: [4, 6, 8], rotation: [0, 0, 0], scale: [1, 1, 1] },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function makeCameraTrack(): CameraTimelineTrack {
|
||||
return {
|
||||
cameraId: "cam-1",
|
||||
keyframes: [
|
||||
{ id: "ckf-1", time: 0, value: { position: [0, 1, 0], target: [0, 0, 0], fov: 50 } },
|
||||
{ id: "ckf-2", time: 2, value: { position: [2, 1, 0], target: [0, 0, 0], fov: 60 } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it("applies interpolated object transform at the playhead", () => {
|
||||
const scene = new Object3D();
|
||||
const child = new Object3D();
|
||||
child.userData.directorObjectId = "obj-1";
|
||||
scene.add(child);
|
||||
|
||||
applyTimelineTransformsAtTime(scene, [makeObjectTrack()], [], 1, null);
|
||||
|
||||
expect(child.position.toArray()).toEqual([2, 3, 4]); // 中点插值
|
||||
});
|
||||
|
||||
it("keeps editing object untouched (gizmo is dragging it)", () => {
|
||||
const scene = new Object3D();
|
||||
const child = new Object3D();
|
||||
child.position.set(9, 9, 9);
|
||||
child.userData.directorObjectId = "obj-1";
|
||||
scene.add(child);
|
||||
|
||||
applyTimelineTransformsAtTime(scene, [makeObjectTrack()], [], 1, "obj-1");
|
||||
|
||||
expect(child.position.toArray()).toEqual([9, 9, 9]);
|
||||
});
|
||||
|
||||
it("applies camera wireframe position and orientation from the camera track", () => {
|
||||
const scene = new Object3D();
|
||||
const wire = new Object3D();
|
||||
wire.userData.directorCameraId = "cam-1";
|
||||
scene.add(wire);
|
||||
|
||||
applyTimelineTransformsAtTime(scene, [], [makeCameraTrack()], 2, null);
|
||||
|
||||
expect(wire.position.toArray()).toEqual([2, 1, 0]);
|
||||
// 朝向使用 SceneRoot 的 getViewportCameraQuaternion 约定(与预览线框一致)
|
||||
const expectedQuaternion = getViewportCameraQuaternion([2, 1, 0], [0, 0, 0]);
|
||||
expect(wire.quaternion.equals(expectedQuaternion)).toBe(true);
|
||||
});
|
||||
|
||||
it("overrides the render camera in camera view mode", () => {
|
||||
const camera = new PerspectiveCamera(50, 16 / 9);
|
||||
camera.position.set(100, 100, 100);
|
||||
|
||||
const applied = applyActiveCameraViewAtTime({
|
||||
camera,
|
||||
cameraTracks: [makeCameraTrack()],
|
||||
activeCameraId: "cam-1",
|
||||
playhead: 2,
|
||||
viewMode: "camera",
|
||||
editingObjectId: null,
|
||||
});
|
||||
|
||||
expect(applied).toBe(true);
|
||||
expect(camera.position.toArray()).toEqual([2, 1, 0]);
|
||||
expect(camera.fov).toBe(60);
|
||||
});
|
||||
|
||||
it("does not override the render camera in director view mode", () => {
|
||||
const camera = new PerspectiveCamera(50, 16 / 9);
|
||||
camera.position.set(100, 100, 100);
|
||||
|
||||
const applied = applyActiveCameraViewAtTime({
|
||||
camera,
|
||||
cameraTracks: [makeCameraTrack()],
|
||||
activeCameraId: "cam-1",
|
||||
playhead: 2,
|
||||
viewMode: "director",
|
||||
editingObjectId: null,
|
||||
});
|
||||
|
||||
expect(applied).toBe(false);
|
||||
expect(camera.position.x).toBe(100);
|
||||
});
|
||||
|
||||
describe("computeExportSize", () => {
|
||||
it("uses 16:9 short-edge-720 for a 16:9 ratio", () => {
|
||||
expect(computeExportSize("16:9")).toEqual({ width: 1280, height: 720 });
|
||||
});
|
||||
|
||||
it("uses portrait 720x1280 for 9:16", () => {
|
||||
expect(computeExportSize("9:16")).toEqual({ width: 720, height: 1280 });
|
||||
});
|
||||
|
||||
it("falls back to a fixed 16:9 when ratio is auto — never the squashed viewport aspect", () => {
|
||||
// 时间轴拖高压扁视口(如 1200x400)时,auto 也固定导出 16:9,画面不会跟着变扁
|
||||
expect(computeExportSize("auto")).toEqual({ width: 1280, height: 720 });
|
||||
});
|
||||
|
||||
it("keeps even dimensions for the encoder", () => {
|
||||
const { width, height } = computeExportSize("4:3");
|
||||
expect(width % 2).toBe(0);
|
||||
expect(height % 2).toBe(0);
|
||||
});
|
||||
});
|
||||
93
src/editor/runtime/playbackTransforms.ts
Normal file
93
src/editor/runtime/playbackTransforms.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { Object3D, PerspectiveCamera } from "three";
|
||||
import { evaluateCameraTrackAtTime, evaluateTrackAtTime } from "../schema/timeline";
|
||||
import type { CameraTimelineTrack, TimelineTrack } from "../schema/timeline";
|
||||
import { getViewportCameraQuaternion } from "../canvas/SceneRoot";
|
||||
|
||||
/**
|
||||
* 按 playhead 把物体 / 相机线框的关键帧插值结果直接写到场景中的 Three.js 对象。
|
||||
* SceneRoot 的 group 通过 userData 标记身份:
|
||||
* - directorObjectId → 物体(覆盖 position/rotation/scale)
|
||||
* - directorCameraId → 导演视图相机线框(覆盖 position + 朝向 target)
|
||||
* 正在被 gizmo 拖拽的对象(editingObjectId)会被跳过,避免覆盖编辑中的值。
|
||||
* 预览(PlaybackTransformSync 的 useFrame)与视频导出(逐帧离线渲染)共用此逻辑,
|
||||
* 保证「所见即所得」。
|
||||
*/
|
||||
export function applyTimelineTransformsAtTime(
|
||||
scene: Object3D,
|
||||
tracks: TimelineTrack[],
|
||||
cameraTracks: CameraTimelineTrack[],
|
||||
playhead: number,
|
||||
editingObjectId: string | null
|
||||
) {
|
||||
if (tracks.length === 0 && cameraTracks.length === 0) return;
|
||||
|
||||
const trackMap = new Map(tracks.map((track) => [track.objectId, track]));
|
||||
const cameraTrackMap = new Map(cameraTracks.map((track) => [track.cameraId, track]));
|
||||
|
||||
scene.traverse((object) => {
|
||||
const objectId = object.userData?.directorObjectId;
|
||||
if (objectId && objectId !== editingObjectId) {
|
||||
const track = trackMap.get(objectId);
|
||||
if (track && track.keyframes.length > 0) {
|
||||
const value = evaluateTrackAtTime(track, playhead);
|
||||
if (value) {
|
||||
object.position.set(value.position[0], value.position[1], value.position[2]);
|
||||
object.rotation.set(value.rotation[0], value.rotation[1], value.rotation[2]);
|
||||
object.scale.set(value.scale[0], value.scale[1], value.scale[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cameraId = object.userData?.directorCameraId;
|
||||
if (cameraId && cameraId !== editingObjectId) {
|
||||
const track = cameraTrackMap.get(cameraId);
|
||||
if (track && track.keyframes.length > 0) {
|
||||
const value = evaluateCameraTrackAtTime(track, playhead);
|
||||
if (value) {
|
||||
object.position.set(value.position[0], value.position[1], value.position[2]);
|
||||
object.quaternion.copy(getViewportCameraQuaternion(value.position, value.target));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 机位视图下:若当前激活相机有运镜轨道,用插值结果覆盖渲染相机的
|
||||
* position / lookAt(target) / fov,呈现真正的运镜画面。
|
||||
* 返回是否覆盖了相机(导演视角或无轨道时不动)。
|
||||
* 预览(ActiveCameraPlaybackSync 的 useFrame)与视频导出共用此逻辑。
|
||||
*/
|
||||
export function applyActiveCameraViewAtTime({
|
||||
camera,
|
||||
cameraTracks,
|
||||
activeCameraId,
|
||||
playhead,
|
||||
viewMode,
|
||||
editingObjectId,
|
||||
}: {
|
||||
camera: PerspectiveCamera;
|
||||
cameraTracks: CameraTimelineTrack[];
|
||||
activeCameraId: string | null;
|
||||
playhead: number;
|
||||
viewMode: "director" | "camera";
|
||||
editingObjectId: string | null;
|
||||
}): boolean {
|
||||
// 导演视角不覆盖,避免抢走 OrbitControls 控制的导演相机;
|
||||
// 正在通过 gizmo / CameraPanel 编辑该相机时也不覆盖。
|
||||
if (viewMode !== "camera") return false;
|
||||
if (!activeCameraId) return false;
|
||||
if (editingObjectId === activeCameraId) return false;
|
||||
const track = cameraTracks.find((item) => item.cameraId === activeCameraId);
|
||||
if (!track || track.keyframes.length === 0) return false;
|
||||
const value = evaluateCameraTrackAtTime(track, playhead);
|
||||
if (!value) return false;
|
||||
|
||||
camera.position.set(value.position[0], value.position[1], value.position[2]);
|
||||
camera.lookAt(value.target[0], value.target[1], value.target[2]);
|
||||
if (Math.abs(camera.fov - value.fov) > 0.001) {
|
||||
camera.fov = value.fov;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -46,10 +46,26 @@ export interface SceneSettings {
|
||||
groundHeight: number;
|
||||
}
|
||||
|
||||
/** 基础动作类型:none=不应用循环,idle/walk/run 由 ProceduralMannequin 按 time 驱动。 */
|
||||
export type MotionKind = "none" | "idle" | "walk" | "run";
|
||||
|
||||
/** 单个角色的「动作」配置。当前 MVP 只接通 walk,其它类型字段保留以便后续扩。 */
|
||||
export interface CharacterMotionState {
|
||||
kind: MotionKind;
|
||||
/** 0.5 – 2.5,默认 1.0;与 playbackStore.time 相乘决定 ω。 */
|
||||
speed: number;
|
||||
/** 0.5 – 1.5,默认 1.0;整体幅度乘数。 */
|
||||
amplitude: number;
|
||||
/** 是否启用;false 时即使 kind ≠ none 也不驱动关节(方便临时冻结对比 pose)。 */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface CharacterRigState {
|
||||
rigType: CharacterRigType;
|
||||
posePresetId: string | null;
|
||||
controls: Record<string, number>;
|
||||
/** 缺省视为 { kind: "none", speed: 1, amplitude: 1, enabled: true };旧工程无需迁移。 */
|
||||
motion?: CharacterMotionState;
|
||||
}
|
||||
|
||||
export interface DirectorAssetRef {
|
||||
|
||||
102
src/editor/store/directorStore.motion.test.ts
Normal file
102
src/editor/store/directorStore.motion.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createInitialDirectorState, useDirectorStore } from "./directorStore";
|
||||
|
||||
function getCharacter(id: string) {
|
||||
return useDirectorStore.getState().project.objects.find((item) => item.id === id);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useDirectorStore.setState({
|
||||
...useDirectorStore.getState(),
|
||||
...createInitialDirectorState(),
|
||||
});
|
||||
});
|
||||
|
||||
describe("姿势与动作互斥", () => {
|
||||
it("从「无」切到走路:姿势重置为站立基础态", () => {
|
||||
// 先摆一个非站立姿势
|
||||
useDirectorStore.getState().applyPosePreset("char_default_a", "sit");
|
||||
expect(getCharacter("char_default_a")?.characterRig?.posePresetId).toBe("sit");
|
||||
|
||||
useDirectorStore.getState().setMotion("char_default_a", "walk");
|
||||
|
||||
const rig = getCharacter("char_default_a")?.characterRig;
|
||||
expect(rig?.posePresetId).toBe("stand");
|
||||
expect(rig?.controls).toEqual({});
|
||||
expect(rig?.motion?.kind).toBe("walk");
|
||||
});
|
||||
|
||||
it("应用姿势预设:动作自动关闭,但保留速度/幅度调参", () => {
|
||||
useDirectorStore.getState().setMotion("char_default_a", "walk");
|
||||
useDirectorStore.getState().setMotionSpeed("char_default_a", 1.8);
|
||||
useDirectorStore.getState().setMotionAmplitude("char_default_a", 1.3);
|
||||
|
||||
useDirectorStore.getState().applyPosePreset("char_default_a", "t-pose");
|
||||
|
||||
const rig = getCharacter("char_default_a")?.characterRig;
|
||||
expect(rig?.motion?.kind).toBe("none");
|
||||
expect(rig?.motion?.speed).toBe(1.8);
|
||||
expect(rig?.motion?.amplitude).toBe(1.3);
|
||||
expect(rig?.posePresetId).toBe("t-pose");
|
||||
});
|
||||
|
||||
it("动作之间切换(walk→run):姿势数据整体重置,微调不残留", () => {
|
||||
useDirectorStore.getState().setMotion("char_default_a", "walk");
|
||||
useDirectorStore.getState().updatePoseControl("char_default_a", "head.yaw", 30);
|
||||
|
||||
useDirectorStore.getState().setMotion("char_default_a", "run");
|
||||
|
||||
const rig = getCharacter("char_default_a")?.characterRig;
|
||||
expect(rig?.motion?.kind).toBe("run");
|
||||
expect(rig?.posePresetId).toBe("stand");
|
||||
expect(rig?.controls).toEqual({});
|
||||
});
|
||||
|
||||
it("切回「无」:姿势同样重置为站立", () => {
|
||||
useDirectorStore.getState().setMotion("char_default_a", "walk");
|
||||
useDirectorStore.getState().updatePoseControl("char_default_a", "head.yaw", 15);
|
||||
|
||||
useDirectorStore.getState().setMotion("char_default_a", "none");
|
||||
|
||||
const rig = getCharacter("char_default_a")?.characterRig;
|
||||
expect(rig?.motion?.kind).toBe("none");
|
||||
expect(rig?.posePresetId).toBe("stand");
|
||||
expect(rig?.controls).toEqual({});
|
||||
});
|
||||
|
||||
it("重复选择同一动作:不触发重置", () => {
|
||||
useDirectorStore.getState().setMotion("char_default_a", "walk");
|
||||
useDirectorStore.getState().updatePoseControl("char_default_a", "head.yaw", 20);
|
||||
|
||||
useDirectorStore.getState().setMotion("char_default_a", "walk");
|
||||
|
||||
const rig = getCharacter("char_default_a")?.characterRig;
|
||||
expect(rig?.controls["head.yaw"]).toBe(20);
|
||||
});
|
||||
|
||||
it("群众:整组切动作重置为站立;整组摆姿势关动作", () => {
|
||||
useDirectorStore.getState().addCrowdCharacters({ rows: 1, columns: 2, spacing: 1 });
|
||||
useDirectorStore.getState().selectCrowd("crowd_1");
|
||||
useDirectorStore.getState().applyCrowdPosePreset("crowd_1", "sit");
|
||||
|
||||
useDirectorStore.getState().setCrowdMotion("crowd_1", "walk");
|
||||
|
||||
const members = useDirectorStore
|
||||
.getState()
|
||||
.project.objects.filter((item) => item.kind === "character" && item.crowdId === "crowd_1");
|
||||
expect(members).toHaveLength(2);
|
||||
for (const member of members) {
|
||||
expect(member.characterRig?.posePresetId).toBe("stand");
|
||||
expect(member.characterRig?.motion?.kind).toBe("walk");
|
||||
}
|
||||
|
||||
useDirectorStore.getState().applyCrowdPosePreset("crowd_1", "t-pose");
|
||||
const after = useDirectorStore
|
||||
.getState()
|
||||
.project.objects.filter((item) => item.kind === "character" && item.crowdId === "crowd_1");
|
||||
for (const member of after) {
|
||||
expect(member.characterRig?.motion?.kind).toBe("none");
|
||||
expect(member.characterRig?.posePresetId).toBe("t-pose");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -271,6 +271,40 @@ it("deletes the selected list object and linked camera data", () => {
|
||||
expect(state.project.activeCameraId).toBe("cam_1");
|
||||
});
|
||||
|
||||
it("deletes the object's timeline track along with the object", () => {
|
||||
useDirectorStore.setState(createInitialDirectorState());
|
||||
const store = useDirectorStore.getState();
|
||||
store.addKeyframe("char_default_a", 1, {
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
scale: [1, 1, 1],
|
||||
});
|
||||
expect(useDirectorStore.getState().project.timeline.tracks).toHaveLength(1);
|
||||
|
||||
useDirectorStore.getState().selectObject("char_default_a");
|
||||
useDirectorStore.getState().deleteSelectedObject();
|
||||
|
||||
expect(useDirectorStore.getState().project.timeline.tracks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("deletes the linked camera track when a camera object is removed", () => {
|
||||
useDirectorStore.setState(createInitialDirectorState());
|
||||
const cameraObject = useDirectorStore
|
||||
.getState()
|
||||
.project.objects.find((item) => item.kind === "camera" && item.linkedCameraId === "cam_1")!;
|
||||
useDirectorStore.getState().addCameraKeyframe("cam_1", 1, {
|
||||
position: [1, 1, 1],
|
||||
target: [0, 0, 0],
|
||||
fov: 50,
|
||||
});
|
||||
expect(useDirectorStore.getState().project.timeline.cameraTracks).toHaveLength(1);
|
||||
|
||||
useDirectorStore.getState().selectObject(cameraObject.id);
|
||||
useDirectorStore.getState().deleteSelectedObject();
|
||||
|
||||
expect(useDirectorStore.getState().project.timeline.cameraTracks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("supports multi-selecting objects and deleting the selected set", () => {
|
||||
useDirectorStore.setState(createInitialDirectorState());
|
||||
useDirectorStore.getState().addPresetCharacter("female");
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
DirectorProject,
|
||||
DirectorTransform,
|
||||
GeometryPrimitiveType,
|
||||
MotionKind,
|
||||
PanoramaProjectionMode,
|
||||
SceneSettings,
|
||||
ViewMode,
|
||||
@@ -137,6 +138,15 @@ export interface DirectorActions {
|
||||
applyCrowdPosePreset: (crowdId: string, presetId: PosePresetId) => void;
|
||||
updatePoseControl: (id: string, key: string, value: number) => void;
|
||||
updateCrowdPoseControl: (crowdId: string, key: string, value: number) => void;
|
||||
// 基础动作(走路 / 跑步)
|
||||
setMotion: (id: string, kind: MotionKind) => void;
|
||||
setCrowdMotion: (crowdId: string, kind: MotionKind) => void;
|
||||
setMotionSpeed: (id: string, speed: number) => void;
|
||||
setCrowdMotionSpeed: (crowdId: string, speed: number) => void;
|
||||
setMotionAmplitude: (id: string, amplitude: number) => void;
|
||||
setCrowdMotionAmplitude: (crowdId: string, amplitude: number) => void;
|
||||
setMotionEnabled: (id: string, enabled: boolean) => void;
|
||||
setCrowdMotionEnabled: (crowdId: string, enabled: boolean) => void;
|
||||
setActiveCamera: (cameraId: string) => void;
|
||||
addCameraCaptures: (cameraId: string | null | undefined, dataUrls: string[]) => void;
|
||||
updateCamera: (
|
||||
@@ -573,6 +583,55 @@ function updateObjectById(
|
||||
return objects.map((item) => (item.id === id ? updater(item) : item));
|
||||
}
|
||||
|
||||
/** 把 motion 字段的 patch 应用到单个 character;若没有 rig 则保持不变。 */
|
||||
function applyMotionPatchToCharacter(
|
||||
character: DirectorObject,
|
||||
patch: Partial<{ kind: MotionKind; speed: number; amplitude: number; enabled: boolean }>
|
||||
): DirectorObject {
|
||||
if (!character.characterRig) return character;
|
||||
const current = character.characterRig.motion ?? {
|
||||
kind: "none" as MotionKind,
|
||||
speed: 1,
|
||||
amplitude: 1,
|
||||
enabled: true,
|
||||
};
|
||||
return {
|
||||
...character,
|
||||
characterRig: {
|
||||
...character.characterRig,
|
||||
motion: {
|
||||
kind: patch.kind ?? current.kind,
|
||||
speed: typeof patch.speed === "number" ? patch.speed : current.speed,
|
||||
amplitude: typeof patch.amplitude === "number" ? patch.amplitude : current.amplitude,
|
||||
enabled: typeof patch.enabled === "boolean" ? patch.enabled : current.enabled,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 姿势与动作互斥(选择层面):
|
||||
* - 任何动作切换(无→walk、walk→run、run→无…)都把姿势数据整体重置为
|
||||
* 动作默认状态(站立基础态),避免上一动作/姿势的偏移残留到下一动作
|
||||
* - 重复选择同一动作不触发重置 */
|
||||
function applyMotionSelectionToCharacter(character: DirectorObject, kind: MotionKind): DirectorObject {
|
||||
if (!character.characterRig) return character;
|
||||
const current = character.characterRig.motion ?? {
|
||||
kind: "none" as MotionKind,
|
||||
speed: 1,
|
||||
amplitude: 1,
|
||||
enabled: true,
|
||||
};
|
||||
const nextRig: NonNullable<DirectorObject["characterRig"]> = {
|
||||
...character.characterRig,
|
||||
motion: { ...current, kind },
|
||||
};
|
||||
if (kind !== current.kind) {
|
||||
nextRig.posePresetId = "stand";
|
||||
nextRig.controls = {};
|
||||
}
|
||||
return { ...character, characterRig: nextRig };
|
||||
}
|
||||
|
||||
function getNextCharacterColor(objects: DirectorObject[]) {
|
||||
const usedColors = new Set(objects.filter((item) => item.kind === "character").map((item) => item.color));
|
||||
const unusedColor = CHARACTER_COLOR_PALETTE.find((color) => !usedColors.has(color));
|
||||
@@ -1838,6 +1897,17 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||||
)
|
||||
);
|
||||
|
||||
// 同步清理被删对象的关键帧轨道:物体轨道按 objectId、相机轨道按 linkedCameraId
|
||||
const nextTracks = state.project.timeline.tracks.filter(
|
||||
(track) => !selectedObjectIdSet.has(track.objectId)
|
||||
);
|
||||
const nextCameraTracks = state.project.timeline.cameraTracks.filter(
|
||||
(track) => !linkedCameraIds.has(track.cameraId)
|
||||
);
|
||||
const timelineUnchanged =
|
||||
nextTracks.length === state.project.timeline.tracks.length &&
|
||||
nextCameraTracks.length === state.project.timeline.cameraTracks.length;
|
||||
|
||||
return {
|
||||
...state,
|
||||
selectedObjectId: null,
|
||||
@@ -1850,6 +1920,13 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||||
objects: nextObjects,
|
||||
cameras: nextFocusedCameras,
|
||||
activeCameraId: nextActiveCameraId,
|
||||
timeline: timelineUnchanged
|
||||
? state.project.timeline
|
||||
: {
|
||||
...state.project.timeline,
|
||||
tracks: nextTracks,
|
||||
cameraTracks: nextCameraTracks,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
@@ -1885,11 +1962,15 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||||
...state.project,
|
||||
objects: updateObjectById(state.project.objects, id, (item) => ({
|
||||
...item,
|
||||
// 姿势与动作互斥:摆造型自动关掉动作循环(保留速度/幅度等调参)
|
||||
characterRig: item.characterRig
|
||||
? {
|
||||
...item.characterRig,
|
||||
posePresetId: presetId,
|
||||
controls: preset ? { ...preset.controls } : item.characterRig.controls,
|
||||
motion: item.characterRig.motion
|
||||
? { ...item.characterRig.motion, kind: "none" }
|
||||
: item.characterRig.motion,
|
||||
}
|
||||
: item.characterRig,
|
||||
})),
|
||||
@@ -1908,11 +1989,15 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||||
item.kind === "character" && item.crowdId === crowdId
|
||||
? {
|
||||
...item,
|
||||
// 姿势与动作互斥:摆造型自动关掉动作循环(保留速度/幅度等调参)
|
||||
characterRig: item.characterRig
|
||||
? {
|
||||
...item.characterRig,
|
||||
posePresetId: presetId,
|
||||
controls: preset ? { ...preset.controls } : item.characterRig.controls,
|
||||
motion: item.characterRig.motion
|
||||
? { ...item.characterRig.motion, kind: "none" }
|
||||
: item.characterRig.motion,
|
||||
}
|
||||
: item.characterRig,
|
||||
}
|
||||
@@ -1963,6 +2048,86 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||||
),
|
||||
},
|
||||
})),
|
||||
setMotion: (id, kind) =>
|
||||
commitMutation((state) => ({
|
||||
...state,
|
||||
project: {
|
||||
...state.project,
|
||||
objects: updateObjectById(state.project.objects, id, (item) =>
|
||||
item.kind === "character" ? applyMotionSelectionToCharacter(item, kind) : item
|
||||
),
|
||||
},
|
||||
})),
|
||||
setCrowdMotion: (crowdId, kind) =>
|
||||
commitMutation((state) => ({
|
||||
...state,
|
||||
project: {
|
||||
...state.project,
|
||||
objects: state.project.objects.map((item) =>
|
||||
item.kind === "character" && item.crowdId === crowdId ? applyMotionSelectionToCharacter(item, kind) : item
|
||||
),
|
||||
},
|
||||
})),
|
||||
setMotionSpeed: (id, speed) =>
|
||||
commitMutation((state) => ({
|
||||
...state,
|
||||
project: {
|
||||
...state.project,
|
||||
objects: updateObjectById(state.project.objects, id, (item) =>
|
||||
item.kind === "character" ? applyMotionPatchToCharacter(item, { speed }) : item
|
||||
),
|
||||
},
|
||||
})),
|
||||
setCrowdMotionSpeed: (crowdId, speed) =>
|
||||
commitMutation((state) => ({
|
||||
...state,
|
||||
project: {
|
||||
...state.project,
|
||||
objects: state.project.objects.map((item) =>
|
||||
item.kind === "character" && item.crowdId === crowdId ? applyMotionPatchToCharacter(item, { speed }) : item
|
||||
),
|
||||
},
|
||||
})),
|
||||
setMotionAmplitude: (id, amplitude) =>
|
||||
commitMutation((state) => ({
|
||||
...state,
|
||||
project: {
|
||||
...state.project,
|
||||
objects: updateObjectById(state.project.objects, id, (item) =>
|
||||
item.kind === "character" ? applyMotionPatchToCharacter(item, { amplitude }) : item
|
||||
),
|
||||
},
|
||||
})),
|
||||
setCrowdMotionAmplitude: (crowdId, amplitude) =>
|
||||
commitMutation((state) => ({
|
||||
...state,
|
||||
project: {
|
||||
...state.project,
|
||||
objects: state.project.objects.map((item) =>
|
||||
item.kind === "character" && item.crowdId === crowdId ? applyMotionPatchToCharacter(item, { amplitude }) : item
|
||||
),
|
||||
},
|
||||
})),
|
||||
setMotionEnabled: (id, enabled) =>
|
||||
commitMutation((state) => ({
|
||||
...state,
|
||||
project: {
|
||||
...state.project,
|
||||
objects: updateObjectById(state.project.objects, id, (item) =>
|
||||
item.kind === "character" ? applyMotionPatchToCharacter(item, { enabled }) : item
|
||||
),
|
||||
},
|
||||
})),
|
||||
setCrowdMotionEnabled: (crowdId, enabled) =>
|
||||
commitMutation((state) => ({
|
||||
...state,
|
||||
project: {
|
||||
...state.project,
|
||||
objects: state.project.objects.map((item) =>
|
||||
item.kind === "character" && item.crowdId === crowdId ? applyMotionPatchToCharacter(item, { enabled }) : item
|
||||
),
|
||||
},
|
||||
})),
|
||||
setActiveCamera: (cameraId) =>
|
||||
commitUiMutation((state) => {
|
||||
const selectedObjectId =
|
||||
|
||||
@@ -619,7 +619,8 @@ textarea:focus-visible {
|
||||
|
||||
.right-sidebar .right-inspector-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
/* auto-fit:2 个 tab(属性/拍摄记录)排 2 列,3 个 tab(属性/姿势/动作)排 3 列,同行不换行 */
|
||||
grid-template-columns: repeat(auto-fit, minmax(64px, 1fr));
|
||||
align-self: stretch;
|
||||
gap: 0;
|
||||
width: calc(100% + 40px);
|
||||
@@ -2555,6 +2556,54 @@ textarea:focus-visible {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* 动作类型:单选按钮组(快速点选,不用下拉) */
|
||||
.motion-kind-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
width: var(--right-sidebar-content-width);
|
||||
max-width: 100%;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.motion-kind-field legend {
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
color: rgb(var(--text-muted-rgb));
|
||||
}
|
||||
.motion-kind-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.motion-kind-option {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 32px;
|
||||
border: 1px solid rgb(var(--border-rgb));
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: rgb(var(--text-muted-rgb));
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.motion-kind-option input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.motion-kind-option:has(input:focus-visible) {
|
||||
outline: 2px solid rgb(var(--accent-rgb));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.motion-kind-option.is-active {
|
||||
border-color: rgb(var(--accent-rgb) / 0.32);
|
||||
color: rgb(var(--accent-rgb));
|
||||
background: rgb(var(--accent-rgb) / 0.08);
|
||||
}
|
||||
|
||||
.preset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 57px);
|
||||
@@ -2607,6 +2656,45 @@ textarea:focus-visible {
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
/* 姿势调节两级结构:一级 = 身体区域(可折叠 details/summary),二级 = .pose-group 部位滑杆 */
|
||||
.pose-sections {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
width: var(--right-sidebar-content-width);
|
||||
}
|
||||
.pose-section > summary {
|
||||
position: relative;
|
||||
padding: 5px 8px 5px 22px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
}
|
||||
.pose-section > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.pose-section > summary::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
top: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid rgb(var(--text-muted-rgb));
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
transform: translateY(-50%);
|
||||
transition: transform 0.12s;
|
||||
}
|
||||
.pose-section[open] > summary::before {
|
||||
transform: translateY(-50%) rotate(90deg);
|
||||
}
|
||||
.pose-section > summary:hover {
|
||||
background: rgb(var(--surface-hover-rgb));
|
||||
}
|
||||
|
||||
.pose-group {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -3269,7 +3357,40 @@ textarea:focus-visible {
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
flex-shrink: 0;
|
||||
height: 220px;
|
||||
height: 300px; /* 组件会用 inline style 覆盖(拖拽/持久化的高度),这里只是默认值 */
|
||||
max-height: 420px; /* 上限固定:关键帧轨道再多也在面板内滚动(.timeline-tracks overflow-y:auto) */
|
||||
}
|
||||
.timeline-resizer {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 10px;
|
||||
margin: -8px -12px 0; /* 抵消面板 padding,让手柄贴顶并贯通整个宽度 */
|
||||
cursor: row-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
.timeline-resizer::before {
|
||||
content: "";
|
||||
width: 56px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgb(var(--text-dim-rgb));
|
||||
opacity: 0.5;
|
||||
transition: background 0.12s, opacity 0.12s;
|
||||
}
|
||||
.timeline-resizer:hover::before,
|
||||
.timeline-resizer.is-active::before {
|
||||
background: rgb(var(--accent-rgb));
|
||||
opacity: 1;
|
||||
}
|
||||
body.is-resizing-timeline {
|
||||
cursor: row-resize;
|
||||
user-select: none;
|
||||
}
|
||||
/* 视频导出期间:drawing buffer 切到固定导出分辨率,CSS 铺满会拉伸变形 → contain 保持比例 */
|
||||
.director-canvas canvas.is-video-exporting {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* 侧栏展开时,时间轴左右避让浮动的左/右侧栏(侧栏 position:absolute 浮在画布上) */
|
||||
@@ -3306,12 +3427,15 @@ textarea:focus-visible {
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding-top: 6px; /* 给关键帧删除按钮的顶部溢出留空间,避免第一行被裁 */
|
||||
overflow-x: hidden; /* 0%/100% 处的关键帧方块/删除按钮会微微超出轨道边缘,不产生横向滚动条 */
|
||||
overflow-y: auto;
|
||||
}
|
||||
.timeline-track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 14px; /* 左右留白:贴边关键帧完整可见可点,不再溢出产生横向滚动条 */
|
||||
}
|
||||
.timeline-track-label {
|
||||
width: 110px;
|
||||
|
||||
@@ -73,7 +73,7 @@ it("matches the provided right inspector layout dimensions and field styling", (
|
||||
expect(css).toMatch(/\.right-inspector\s*\{[\s\S]*?padding:\s*20px;[\s\S]*?gap:\s*0;/);
|
||||
expect(css).toMatch(/\.right-sidebar\s*\.right-inspector\s*\{[\s\S]*?gap:\s*0;[\s\S]*?background:\s*rgb\(var\(--panel-rgb\)\);/);
|
||||
expect(css).toMatch(/\.right-inspector-title\s*\{[\s\S]*?font-size:\s*16px;[\s\S]*?line-height:\s*22px;/);
|
||||
expect(css).toMatch(/\.right-inspector-tabs\s*\{[\s\S]*?display:\s*grid;[\s\S]*?grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\);[\s\S]*?gap:\s*0;[\s\S]*?height:\s*40px;[\s\S]*?margin:\s*10px -20px 0;[\s\S]*?border-bottom:\s*1px solid rgb\(var\(--border-rgb\) \/ 0\.24\);/);
|
||||
expect(css).toMatch(/\.right-inspector-tabs\s*\{[\s\S]*?display:\s*grid;[\s\S]*?grid-template-columns:\s*repeat\(auto-fit,\s*minmax\(64px,\s*1fr\)\);[\s\S]*?gap:\s*0;[\s\S]*?height:\s*40px;[\s\S]*?margin:\s*10px -20px 0;[\s\S]*?border-bottom:\s*1px solid rgb\(var\(--border-rgb\) \/ 0\.24\);/);
|
||||
expect(css).toMatch(/\.right-sidebar\s*\.right-inspector-tabs\s*button\s*\{[\s\S]*?display:\s*grid;[\s\S]*?place-items:\s*center;[\s\S]*?width:\s*100%;[\s\S]*?min-width:\s*24px;[\s\S]*?height:\s*40px;[\s\S]*?font-size:\s*12px;[\s\S]*?line-height:\s*17px;[\s\S]*?white-space:\s*nowrap;/);
|
||||
expect(css).toMatch(/\.right-sidebar\s*\.right-inspector-tabs\s*button\[aria-pressed="true"\]::after\s*\{[\s\S]*?width:\s*28px;[\s\S]*?height:\s*3px;[\s\S]*?border-radius:\s*0;/);
|
||||
expect(css).toMatch(/\.right-inspector-content\s*\{[\s\S]*?gap:\s*20px;[\s\S]*?width:\s*var\(--right-sidebar-content-width\);[\s\S]*?padding-bottom:\s*20px;[\s\S]*?margin-top:\s*25px;/);
|
||||
|
||||
Reference in New Issue
Block a user