视频导出、时间轴与动作姿势模块

- 视频导出:WebCodecs 逐帧快速导出 MP4(固定分辨率不随视口布局变形),MediaRecorder 兜底
- 时间轴:面板可拖动调高(持久化、固定上限)、关键帧轨道区内滚动、无横向滚动条
- 动作模块:走路/跑步步态曲线(对侧摆臂修复、跑步独立步频、跑姿屈膝曲线、参考校准参数)
- 姿势调节:两级折叠分组、动作偏移联动
- 删除对象时同步清理时间轴轨道

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
LXXandClaude Fable 5 committed 2026-09-02 10:36:03 +08:00
1 parent 3b053c714c
commit 59e68e444a
29 files changed
+3564 -166

No files matched your search

+14
View File
@@ -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}>
+4 -38
View File
@@ -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;
+3
View File
@@ -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 ? (