+
+
+ {renderCollapseToggle(trackKey, trackName, "运镜轨道", collapsed)}
{trackName}
{track.keyframes.length}
- {track.keyframes.map((kf) => {
- const leftPercent = duration === 0 ? 0 : (kf.time / duration) * 100;
- const isSelected =
- selectedKeyframe?.kind === "camera" &&
- selectedKeyframe.targetId === track.cameraId &&
- selectedKeyframe.keyframeId === kf.id;
- return (
-
handleSelectCameraKeyframe(track.cameraId, kf.id, kf.time, kf.value)}
- >
-
-
+ );
+ })
+ )}
);
})}
+
+ {characterObjects.map((character) => {
+ const trajectoryPoints = character.trajectoryPoints ?? [];
+ const motion = character.characterRig?.motion;
+ const motionLabel = MOTION_LABELS[motion?.kind ?? "none"];
+ const motionDisabled = motion?.enabled === false;
+ const motionTitle = motionDisabled
+ ? `动作轨道:${motionLabel}(当前已关闭)`
+ : `动作轨道:${motionLabel}`;
+ const trackKey = getTimelineTrackKey("character", character.id);
+ const collapsed = timelineCollapsedTracks.includes(trackKey);
+ const dragProps = getTrackDragProps(trackKey);
+
+ return (
+
+ {/* 分组抬头:折叠开关 + 角色名 + 轨迹点数(整组可拖动排序) */}
+
+
+ {renderCollapseToggle(trackKey, character.name, "角色轨道", collapsed)}
+
+
{character.name}
+
{trajectoryPoints.length}
+
+ {/* 折叠后抬头行里给一句摘要,避免整条轨道变成空白 */}
+
+ {collapsed ? (
+
+ {motionLabel} · {trajectoryPoints.length} 个轨迹点
+
+ ) : null}
+
+
+
+ {collapsed ? null : (
+ <>
+ {/* 动作轨道:角色当前的基础动作(待机 / 行走 / 跑步) */}
+
+
动作
+
+
+ {motionLabel}
+ {motionDisabled ? "(已关闭)" : ""}
+
+
+
+
+ {/* 人物轨迹:轨迹点即关键帧,0s 从角色起始位置出发,末点落在时间轴结束时刻 */}
+
+
轨迹
+
+ {trajectoryPoints.map((point, index) => {
+ const pointTime = getTrajectoryPointTime(index, trajectoryPoints.length, duration);
+ const leftPercent = duration === 0 ? 0 : (pointTime / duration) * 100;
+ const isSelected =
+ selectedTrajectoryPoint?.objectId === character.id &&
+ selectedTrajectoryPoint.pointId === point.id;
+
+ return (
+
handleSelectTrajectoryKeyframe(character.id, point.id, pointTime)}
+ >
+
+ {
+ event.stopPropagation();
+ removeTrajectoryPoint(character.id, point.id);
+ }}
+ >
+ ×
+
+
+ );
+ })}
+
+
+ >
+ )}
+
+ );
+ })}
+
+ {/* 拖拽落点线:绝对定位在轨道区上,靠 transform 移动(详见 showDropIndicator) */}
+
diff --git a/src/editor/runtime/playbackStore.test.ts b/src/editor/runtime/playbackStore.test.ts
index 78e7825..1167cb9 100644
--- a/src/editor/runtime/playbackStore.test.ts
+++ b/src/editor/runtime/playbackStore.test.ts
@@ -2,7 +2,7 @@ import { beforeEach, it, expect } from "vitest";
import { usePlaybackStore } from "./playbackStore";
beforeEach(() => {
- usePlaybackStore.setState({ isPlaying: false, playhead: 0, duration: 5, editingObjectId: null, stopAtEnd: false });
+ usePlaybackStore.setState({ isPlaying: false, playhead: 0, duration: 5, editingObjectId: null });
});
it("togglePlaying flips isPlaying", () => {
@@ -24,15 +24,7 @@ it("advance does nothing when not playing", () => {
expect(usePlaybackStore.getState().playhead).toBe(0);
});
-it("advance moves playhead forward and loops back to 0 at the end", () => {
- usePlaybackStore.getState().setPlaying(true);
- usePlaybackStore.getState().setPlayhead(4.8);
- usePlaybackStore.getState().advance(0.5);
- expect(usePlaybackStore.getState().playhead).toBe(0);
-});
-
-it("advance stops at the end (no loop) when stopAtEnd is true", () => {
- usePlaybackStore.getState().setStopAtEnd(true);
+it("advance stops at the end instead of looping back to 0", () => {
usePlaybackStore.getState().setPlaying(true);
usePlaybackStore.getState().setPlayhead(4.8);
usePlaybackStore.getState().advance(0.5);
@@ -42,6 +34,25 @@ it("advance stops at the end (no loop) when stopAtEnd is true", () => {
expect(state.isPlaying).toBe(false);
});
+it("togglePlaying restarts from the beginning after the timeline finished", () => {
+ usePlaybackStore.getState().setPlayhead(5);
+ usePlaybackStore.getState().togglePlaying();
+
+ const state = usePlaybackStore.getState();
+ expect(state.isPlaying).toBe(true);
+ expect(state.playhead).toBe(0);
+});
+
+it("stop halts playback and rewinds to the start", () => {
+ usePlaybackStore.getState().setPlaying(true);
+ usePlaybackStore.getState().setPlayhead(3);
+ usePlaybackStore.getState().stop();
+
+ const state = usePlaybackStore.getState();
+ expect(state.isPlaying).toBe(false);
+ expect(state.playhead).toBe(0);
+});
+
it("setDuration keeps a positive minimum", () => {
usePlaybackStore.getState().setDuration(0);
expect(usePlaybackStore.getState().duration).toBeGreaterThanOrEqual(0.1);
diff --git a/src/editor/runtime/playbackStore.ts b/src/editor/runtime/playbackStore.ts
index ba3aba1..67a70d8 100644
--- a/src/editor/runtime/playbackStore.ts
+++ b/src/editor/runtime/playbackStore.ts
@@ -8,15 +8,15 @@ export interface PlaybackState {
/** 正在被 gizmo 拖拽的对象 id(DirectorObject.id 或 DirectorCameraShot.id)。
* PlaybackTransformSync 会跳过它,避免把 gizmo 改的值立刻覆盖回去。 */
editingObjectId: string | null;
- /** 录制视频时置 true:播放到末尾就停住(不循环回 0),便于 MediaRecorder 干净收尾。 */
- stopAtEnd: boolean;
setPlaying: (playing: boolean) => void;
+ /** 播放 / 暂停切换:暂停后再次播放会从当前位置继续;已经在末尾时从头重播(单次播放)。 */
togglePlaying: () => void;
+ /** 停止:终止播放并回到起点(playhead = 0)。 */
+ stop: () => void;
setPlayhead: (time: number) => void;
setDuration: (duration: number) => void;
setEditingObjectId: (id: string | null) => void;
- setStopAtEnd: (stop: boolean) => void;
- /** 供 useFrame 调用:推进 playhead;到末尾时按 stopAtEnd 决定停止还是循环 */
+ /** 供 useFrame 调用:推进 playhead;到末尾时停住(单次播放,不循环回 0)。 */
advance: (deltaSeconds: number) => void;
}
@@ -29,23 +29,29 @@ export const usePlaybackStore = create
((set, get) => ({
playhead: 0,
duration: DEFAULT_TIMELINE_DURATION,
editingObjectId: null,
- stopAtEnd: false,
setPlaying: (isPlaying) => set({ isPlaying }),
- togglePlaying: () => set((state) => ({ isPlaying: !state.isPlaying })),
+ togglePlaying: () =>
+ set((state) => {
+ if (state.isPlaying) return { isPlaying: false };
+ // 单次播放:播到末尾后再点播放,从头重播,而不是停在末尾没反应
+ return {
+ isPlaying: true,
+ playhead: state.playhead >= state.duration ? 0 : state.playhead,
+ };
+ }),
+ stop: () => set({ isPlaying: false, playhead: 0, editingObjectId: null }),
// scrub 指针 = 退出单对象编辑态,清掉 editingObjectId,让所有对象回到 playhead 插值
setPlayhead: (playhead) =>
set((state) => ({ playhead: clamp(playhead, 0, state.duration), editingObjectId: null })),
setDuration: (duration) => set({ duration: Math.max(0.1, duration) }),
setEditingObjectId: (editingObjectId) => set({ editingObjectId }),
- setStopAtEnd: (stopAtEnd) => set({ stopAtEnd }),
advance: (deltaSeconds) => {
if (!get().isPlaying) return;
set((state) => {
const next = state.playhead + deltaSeconds;
if (next >= state.duration) {
- return state.stopAtEnd
- ? { playhead: state.duration, isPlaying: false }
- : { playhead: 0 };
+ // 单次播放:到末尾停在末帧并结束播放(不再循环回 0)
+ return { playhead: state.duration, isPlaying: false };
}
return { playhead: next };
});
diff --git a/src/editor/runtime/playbackTransforms.test.ts b/src/editor/runtime/playbackTransforms.test.ts
index 67fdc15..7640414 100644
--- a/src/editor/runtime/playbackTransforms.test.ts
+++ b/src/editor/runtime/playbackTransforms.test.ts
@@ -1,10 +1,26 @@
import { describe, it, expect } from "vitest";
import { Object3D, PerspectiveCamera, Vector3 } from "three";
+import type { DirectorObject } from "../schema/directorProject";
import type { CameraTimelineTrack, TimelineTrack } from "../schema/timeline";
import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "./playbackTransforms";
import { computeExportSize } from "../io/videoExport";
import { getViewportCameraQuaternion } from "../canvas/SceneRoot";
+function makeTrajectoryCharacter(): DirectorObject {
+ return {
+ id: "obj-1",
+ name: "角色01",
+ kind: "character",
+ visible: true,
+ locked: false,
+ transform: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] },
+ trajectoryPoints: [
+ { id: "tp_1", position: [4, 0, 0], rotation: [0, 90, 0] },
+ { id: "tp_2", position: [8, 0, 0], rotation: [0, 180, 0] },
+ ],
+ };
+}
+
function makeObjectTrack(): TimelineTrack {
return {
objectId: "obj-1",
@@ -56,6 +72,68 @@ it("keeps editing object untouched (gizmo is dragging it)", () => {
expect(child.position.toArray()).toEqual([9, 9, 9]);
});
+it("walks a character with trajectory points along its path instead of its keyframes", () => {
+ const scene = new Object3D();
+ const child = new Object3D();
+ child.userData.directorObjectId = "obj-1";
+ scene.add(child);
+
+ // 时间轴 4s、两个轨迹点 → 轨迹点分别落在 2s / 4s
+ applyTimelineTransformsAtTime(
+ scene,
+ [makeObjectTrack()],
+ [],
+ 2,
+ null,
+ { objects: [makeTrajectoryCharacter()], duration: 4 }
+ );
+
+ // 位置来自轨迹(第一个轨迹点),而不是关键帧的 [2,3,4]
+ expect(child.position.toArray()).toEqual([4, 0, 0]);
+ // 姿态 = 第一个轨迹点设定的 90° 水平角
+ expect(child.rotation.y).toBeCloseTo(Math.PI / 2, 5);
+});
+
+it("lets keyframes still drive the scale of a walking character", () => {
+ const scene = new Object3D();
+ const child = new Object3D();
+ child.userData.directorObjectId = "obj-1";
+ scene.add(child);
+ const scaledTrack: TimelineTrack = {
+ 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: 4, value: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [3, 3, 3] } },
+ ],
+ };
+
+ applyTimelineTransformsAtTime(
+ scene,
+ [scaledTrack],
+ [],
+ 2,
+ null,
+ { objects: [makeTrajectoryCharacter()], duration: 4 }
+ );
+
+ expect(child.scale.toArray()).toEqual([2, 2, 2]);
+});
+
+it("leaves a character with trajectory points untouched while its gizmo is dragging", () => {
+ const scene = new Object3D();
+ const child = new Object3D();
+ child.position.set(9, 9, 9);
+ child.userData.directorObjectId = "obj-1";
+ scene.add(child);
+
+ applyTimelineTransformsAtTime(scene, [], [], 2, "obj-1", {
+ objects: [makeTrajectoryCharacter()],
+ duration: 4,
+ });
+
+ 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();
diff --git a/src/editor/runtime/playbackTransforms.ts b/src/editor/runtime/playbackTransforms.ts
index addd2b3..0f0a84d 100644
--- a/src/editor/runtime/playbackTransforms.ts
+++ b/src/editor/runtime/playbackTransforms.ts
@@ -1,9 +1,17 @@
import type { Object3D, PerspectiveCamera } from "three";
import { degreesRotationToRadians } from "../schema/directorProject";
+import type { DirectorObject } from "../schema/directorProject";
import { evaluateCameraTrackAtTime, evaluateTrackAtTime } from "../schema/timeline";
import type { CameraTimelineTrack, TimelineTrack } from "../schema/timeline";
+import { evaluateTrajectoryMotionAtTime, hasTrajectoryMotion } from "../schema/trajectoryMotion";
import { getViewportCameraQuaternion } from "../canvas/SceneRoot";
+/** 人物轨迹驱动所需的工程上下文:对象列表 + 时间轴时长。 */
+export interface TimelinePlaybackContext {
+ objects?: DirectorObject[];
+ duration?: number;
+}
+
/**
* 按 playhead 把物体 / 相机线框的关键帧插值结果直接写到场景中的 Three.js 对象。
* SceneRoot 的 group 通过 userData 标记身份:
@@ -12,24 +20,51 @@ import { getViewportCameraQuaternion } from "../canvas/SceneRoot";
* 正在被 gizmo 拖拽的对象(editingObjectId)会被跳过,避免覆盖编辑中的值。
* 预览(PlaybackTransformSync 的 useFrame)与视频导出(逐帧离线渲染)共用此逻辑,
* 保证「所见即所得」。
+ *
+ * 带运动轨迹的角色(trajectoryPoints 非空)优先由「人物轨迹」驱动:
+ * 位置沿角色起始位置 → 各轨迹点的样条推进,朝向按三个角度平滑滚动;此时关键帧只提供缩放,
+ * 避免位移被两套数据互相打架。
*/
export function applyTimelineTransformsAtTime(
scene: Object3D,
tracks: TimelineTrack[],
cameraTracks: CameraTimelineTrack[],
playhead: number,
- editingObjectId: string | null
+ editingObjectId: string | null,
+ context: TimelinePlaybackContext = {}
) {
- if (tracks.length === 0 && cameraTracks.length === 0) return;
+ const objects = context.objects ?? [];
+ const duration = context.duration ?? 0;
+ const trajectoryOwners = objects.filter((item) => hasTrajectoryMotion(item, duration));
+
+ if (tracks.length === 0 && cameraTracks.length === 0 && trajectoryOwners.length === 0) return;
const trackMap = new Map(tracks.map((track) => [track.objectId, track]));
const cameraTrackMap = new Map(cameraTracks.map((track) => [track.cameraId, track]));
+ const trajectoryMap = new Map(trajectoryOwners.map((item) => [item.id, item]));
scene.traverse((object) => {
const objectId = object.userData?.directorObjectId;
if (objectId && objectId !== editingObjectId) {
const track = trackMap.get(objectId);
- if (track && track.keyframes.length > 0) {
+ const trajectoryOwner = trajectoryMap.get(objectId);
+ const trajectoryMotion = trajectoryOwner
+ ? evaluateTrajectoryMotionAtTime(trajectoryOwner, duration, playhead)
+ : null;
+
+ if (trajectoryMotion) {
+ object.position.set(
+ trajectoryMotion.position[0],
+ trajectoryMotion.position[1],
+ trajectoryMotion.position[2]
+ );
+ // 轨迹朝向同样是度数,写进 Three.js 前转弧度
+ object.rotation.set(...degreesRotationToRadians(trajectoryMotion.rotation));
+ if (track && track.keyframes.length > 0) {
+ const value = evaluateTrackAtTime(track, playhead);
+ if (value) object.scale.set(value.scale[0], value.scale[1], value.scale[2]);
+ }
+ } else 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]);
diff --git a/src/editor/schema/trajectoryMotion.test.ts b/src/editor/schema/trajectoryMotion.test.ts
new file mode 100644
index 0000000..ea7a82a
--- /dev/null
+++ b/src/editor/schema/trajectoryMotion.test.ts
@@ -0,0 +1,147 @@
+import { Euler, Quaternion } from "three";
+import { expect, it } from "vitest";
+import { degreesToRadians, radiansToDegrees } from "./directorProject";
+import type { DirectorObject } from "./directorProject";
+import {
+ evaluateTrajectoryMotionAtTime,
+ getTrajectoryPointTime,
+ hasTrajectoryMotion,
+} from "./trajectoryMotion";
+
+function createCharacter(
+ trajectoryPoints: Array<{ id: string; position: [number, number, number]; rotation: [number, number, number] }>,
+ transform?: Partial
+): DirectorObject {
+ return {
+ id: "char_1",
+ name: "角色01",
+ kind: "character",
+ visible: true,
+ locked: false,
+ transform: {
+ position: transform?.position ?? [0, 0, 0],
+ rotation: transform?.rotation ?? [0, 0, 0],
+ scale: [1, 1, 1],
+ },
+ trajectoryPoints,
+ };
+}
+
+/** 两个「度数欧拉角」表示的姿态之间的夹角(度)。同一姿态可能有多种等价欧拉表示,所以比较姿态本身。 */
+function orientationDeltaDegrees(a: [number, number, number], b: [number, number, number]) {
+ const qa = new Quaternion().setFromEuler(
+ new Euler(degreesToRadians(a[0]), degreesToRadians(a[1]), degreesToRadians(a[2]))
+ );
+ const qb = new Quaternion().setFromEuler(
+ new Euler(degreesToRadians(b[0]), degreesToRadians(b[1]), degreesToRadians(b[2]))
+ );
+
+ return radiansToDegrees(qa.angleTo(qb));
+}
+
+it("spreads trajectory points evenly and lands the last one on the timeline end", () => {
+ // 2 个点 → 2.5s / 5s;3 个点 → 2s / 4s / 6s
+ expect([0, 1].map((index) => getTrajectoryPointTime(index, 2, 5))).toEqual([2.5, 5]);
+ expect([0, 1, 2].map((index) => getTrajectoryPointTime(index, 3, 6))).toEqual([2, 4, 6]);
+ expect(getTrajectoryPointTime(0, 0, 5)).toBe(0);
+ expect(getTrajectoryPointTime(0, 2, 0)).toBe(0);
+});
+
+it("hasTrajectoryMotion only accepts characters that actually own trajectory points", () => {
+ expect(hasTrajectoryMotion(createCharacter([]), 5)).toBe(false);
+ expect(hasTrajectoryMotion(createCharacter([{ id: "tp_1", position: [1, 0, 0], rotation: [0, 0, 0] }]), 5)).toBe(true);
+ expect(hasTrajectoryMotion(createCharacter([{ id: "tp_1", position: [1, 0, 0], rotation: [0, 0, 0] }]), 0)).toBe(false);
+});
+
+it("starts walking from the character's own position and rotation at 0s", () => {
+ const character = createCharacter(
+ [
+ { id: "tp_1", position: [4, 0, 0], rotation: [0, 90, 0] },
+ { id: "tp_2", position: [8, 0, 4], rotation: [0, 180, 0] },
+ ],
+ { position: [1, 0, -1], rotation: [0, 30, 0] }
+ );
+
+ const sample = evaluateTrajectoryMotionAtTime(character, 5, 0);
+
+ expect(sample?.position[0]).toBeCloseTo(1, 6);
+ expect(sample?.position[2]).toBeCloseTo(-1, 6);
+ expect(sample?.rotation[1]).toBeCloseTo(30, 6);
+});
+
+it("passes exactly through every trajectory point at its keyframe time", () => {
+ const character = createCharacter([
+ { id: "tp_1", position: [4, 0, 0], rotation: [0, 90, 0] },
+ { id: "tp_2", position: [8, 0, 4], rotation: [30, 180, 20] },
+ ]);
+ const duration = 5;
+
+ character.trajectoryPoints!.forEach((point, index) => {
+ const sample = evaluateTrajectoryMotionAtTime(character, duration, getTrajectoryPointTime(index, 2, duration));
+
+ expect(sample?.position[0]).toBeCloseTo(point.position[0], 5);
+ expect(sample?.position[1]).toBeCloseTo(point.position[1], 5);
+ expect(sample?.position[2]).toBeCloseTo(point.position[2], 5);
+ // 姿态一致即可:四元数分解回欧拉角时可能存在等价的不同表示
+ expect(orientationDeltaDegrees(sample!.rotation, point.rotation)).toBeCloseTo(0, 3);
+ });
+});
+
+it("ends the walk on the last trajectory point at the timeline end", () => {
+ const character = createCharacter([
+ { id: "tp_1", position: [4, 0, 0], rotation: [0, 90, 0] },
+ { id: "tp_2", position: [8, 0, 4], rotation: [0, 180, 0] },
+ ]);
+
+ const sample = evaluateTrajectoryMotionAtTime(character, 5, 5);
+
+ expect(sample?.position[0]).toBeCloseTo(8, 5);
+ expect(sample?.position[2]).toBeCloseTo(4, 5);
+ expect(orientationDeltaDegrees(sample!.rotation, [0, 180, 0])).toBeCloseTo(0, 3);
+});
+
+it("rolls the three angles smoothly between two trajectory points", () => {
+ const character = createCharacter([
+ { id: "tp_1", position: [4, 0, 0], rotation: [0, 90, 0] },
+ { id: "tp_2", position: [8, 0, 0], rotation: [0, 180, 0] },
+ ]);
+
+ // 5s 时间轴上第一个点落在 2.5s:半段(1.25s)应是起始朝向 → 90° 的中点 45°
+ const sample = evaluateTrajectoryMotionAtTime(character, 5, 1.25);
+
+ expect(sample?.rotation[0]).toBeCloseTo(0, 4);
+ expect(sample?.rotation[1]).toBeCloseTo(45, 4);
+ expect(sample?.rotation[2]).toBeCloseTo(0, 4);
+ // 位置也走在起点与第一个点之间(直线段中点)
+ expect(sample?.position[0]).toBeCloseTo(2, 5);
+});
+
+it("rolls pitch and roll together with the horizontal angle instead of snapping", () => {
+ const character = createCharacter([{ id: "tp_1", position: [4, 0, 0], rotation: [60, 0, 0] }]);
+
+ const midway = evaluateTrajectoryMotionAtTime(character, 4, 2)!;
+ const halfwayDelta = orientationDeltaDegrees(midway.rotation, [0, 0, 0]);
+
+ // 段中是「起始姿态 → 轨迹点姿态」的中间态,既不是起点也不是终点
+ expect(halfwayDelta).toBeGreaterThan(5);
+ expect(halfwayDelta).toBeLessThan(55);
+});
+
+it("keeps a single trajectory point as the whole walk", () => {
+ const character = createCharacter([{ id: "tp_1", position: [6, 0, 0], rotation: [0, 270, 0] }]);
+
+ expect(evaluateTrajectoryMotionAtTime(character, 4, 0)?.position[0]).toBeCloseTo(0, 5);
+ expect(evaluateTrajectoryMotionAtTime(character, 4, 4)?.position[0]).toBeCloseTo(6, 5);
+ // 270° ≡ -90°(整圈),姿态一致即可
+ expect(orientationDeltaDegrees(evaluateTrajectoryMotionAtTime(character, 4, 4)!.rotation, [0, 270, 0])).toBeCloseTo(
+ 0,
+ 3
+ );
+});
+
+it("returns null when there is nothing to walk or the duration is unusable", () => {
+ expect(evaluateTrajectoryMotionAtTime(createCharacter([]), 5, 1)).toBeNull();
+ expect(
+ evaluateTrajectoryMotionAtTime(createCharacter([{ id: "tp_1", position: [1, 0, 0], rotation: [0, 0, 0] }]), 0, 1)
+ ).toBeNull();
+});
diff --git a/src/editor/schema/trajectoryMotion.ts b/src/editor/schema/trajectoryMotion.ts
new file mode 100644
index 0000000..b9a8f7c
--- /dev/null
+++ b/src/editor/schema/trajectoryMotion.ts
@@ -0,0 +1,123 @@
+import { CatmullRomCurve3, Euler, Quaternion, Vector3 } from "three";
+import { degreesToRadians, radiansToDegrees } from "./directorProject";
+import type { DirectorObject } from "./directorProject";
+import type { TransformTuple } from "./timeline";
+
+/**
+ * 人物轨迹随时间采样出来的一个「走位 + 朝向」结果。
+ * position 是场景坐标系里的位置;rotation 是度数,顺序与 DirectorTransform 一致:
+ * [俯仰角 pitch(X), 水平角 yaw(Y), 横滚角 roll(Z)]。
+ */
+export interface TrajectoryMotionSample {
+ position: TransformTuple;
+ rotation: TransformTuple;
+}
+
+/**
+ * 轨迹点的关键帧时间(pointIndex 从 0 开始)。
+ *
+ * N 个轨迹点把时间轴等分成 N 段:第 i 个点(1-based)落在 duration * i / N,
+ * 于是最后一个轨迹点正好落在时间轴结束时刻。0s 不是轨迹点,而是角色自身的起始位置 / 起始朝向。
+ */
+export function getTrajectoryPointTime(pointIndex: number, pointCount: number, duration: number): number {
+ if (pointCount <= 0 || !Number.isFinite(duration) || duration <= 0) return 0;
+
+ const safeIndex = Math.min(Math.max(pointIndex, 0), pointCount - 1);
+ return (duration * (safeIndex + 1)) / pointCount;
+}
+
+/** 轨迹点是否有时长可用的采样:有点且时间轴时长有效才参与驱动。 */
+export function hasTrajectoryMotion(owner: DirectorObject, duration = Number.POSITIVE_INFINITY): boolean {
+ if ((owner.trajectoryPoints ?? []).length === 0) return false;
+ return Number.isFinite(duration) ? duration > 0 : true;
+}
+
+const _qa = new Quaternion();
+const _qb = new Quaternion();
+const _qr = new Quaternion();
+const _euler = new Euler();
+const _point = new Vector3();
+
+/**
+ * 轨迹转向插值:位置是线性的,朝向用四元数球面插值(slerp)——三个角(水平 / 俯仰 / 横滚)
+ * 一起平滑「滚动」到目标状态,不会出现欧拉角逐轴插值那种中途姿态怪异的问题。
+ * 输入输出都是度数。
+ */
+function slerpRotationDegrees(a: TransformTuple, b: TransformTuple, t: number): TransformTuple {
+ if (a[0] === b[0] && a[1] === b[1] && a[2] === b[2]) return [a[0], a[1], a[2]];
+
+ _qa.setFromEuler(_euler.set(degreesToRadians(a[0]), degreesToRadians(a[1]), degreesToRadians(a[2])));
+ _qb.setFromEuler(_euler.set(degreesToRadians(b[0]), degreesToRadians(b[1]), degreesToRadians(b[2])));
+ _qr.slerpQuaternions(_qa, _qb, t);
+ _euler.setFromQuaternion(_qr);
+
+ return [radiansToDegrees(_euler.x), radiansToDegrees(_euler.y), radiansToDegrees(_euler.z)];
+}
+
+/**
+ * 路径控制点:角色「起始位置」+ 依次的每个轨迹点。
+ * 用的是轨迹语义坐标(不含画布上为了贴地而抬高的视觉偏移),角色按这份坐标走位。
+ */
+export function getTrajectoryCurvePoints(owner: DirectorObject): Vector3[] {
+ const points = owner.trajectoryPoints ?? [];
+ if (points.length === 0) return [];
+
+ return [
+ new Vector3(owner.transform.position[0], owner.transform.position[1], owner.transform.position[2]),
+ ...points.map((point) => new Vector3(point.position[0], point.position[1], point.position[2])),
+ ];
+}
+
+/**
+ * 平滑路径:穿过每个路点的 Catmull-Rom 样条(centripetal 型,视觉上即贝塞尔式的圆顺曲线),
+ * 不闭合。控制点数不足 2 个时没有路径可走,返回 null。
+ */
+export function createTrajectoryCurve(curvePoints: Vector3[]): CatmullRomCurve3 | null {
+ if (curvePoints.length < 2) return null;
+ return new CatmullRomCurve3(curvePoints, false, "centripetal");
+}
+
+/**
+ * 求某一时刻角色沿轨迹的位置与朝向:
+ * - 0s:角色自身的起始位置与起始朝向(transform);
+ * - 第 i 个轨迹点(1-based):duration * i / N,朝向 = 该点设定值;
+ * - 段内:位置沿样条从上一个关键位置平滑推进到下一个,三个角度用四元数 slerp 平滑滚动;
+ * - 最后一个轨迹点落在时间轴结束时刻(duration)。
+ *
+ * 时间 → 样条参数是等比例映射:第 i 个点恰好落在参数 i/N 上,所以关键点会被精确命中,
+ * 不会因为曲线长度不同而提前/滞后。
+ */
+export function evaluateTrajectoryMotionAtTime(
+ owner: DirectorObject,
+ duration: number,
+ time: number
+): TrajectoryMotionSample | null {
+ const points = owner.trajectoryPoints ?? [];
+ if (points.length === 0 || !Number.isFinite(duration) || duration <= 0) return null;
+
+ const curve = createTrajectoryCurve(getTrajectoryCurvePoints(owner));
+ if (!curve) return null;
+
+ const ratio = Math.min(1, Math.max(0, time / duration));
+ const position = curve.getPoint(ratio, _point);
+
+ // 朝向关键帧:[角色起始朝向, ...每个轨迹点的朝向] 共 N+1 个,对应 N 段
+ const segmentCount = points.length;
+ const scaled = ratio * segmentCount;
+ const segmentIndex = Math.min(segmentCount - 1, Math.max(0, Math.floor(scaled)));
+ const localT = Math.min(1, Math.max(0, scaled - segmentIndex));
+ const fromRotation: TransformTuple =
+ segmentIndex === 0
+ ? [owner.transform.rotation[0], owner.transform.rotation[1], owner.transform.rotation[2]]
+ : [
+ points[segmentIndex - 1].rotation[0],
+ points[segmentIndex - 1].rotation[1],
+ points[segmentIndex - 1].rotation[2],
+ ];
+ const toRotation = points[segmentIndex].rotation;
+
+ return {
+ position: [position.x, position.y, position.z],
+ rotation: slerpRotationDegrees(fromRotation, toRotation, localT),
+ };
+}
diff --git a/src/editor/store/directorStore.test.ts b/src/editor/store/directorStore.test.ts
index 9af5b37..2917f82 100644
--- a/src/editor/store/directorStore.test.ts
+++ b/src/editor/store/directorStore.test.ts
@@ -796,6 +796,22 @@ it("copies and pastes the current selection as new scene objects", () => {
expect(state.selectedObjectIds).toEqual(pastedCharacter ? [pastedCharacter.id] : []);
});
+it("defers localStorage writes while a drag batch is in progress", () => {
+ const setItemSpy = vi.spyOn(localStorage, "setItem");
+
+ useDirectorStore.getState().beginUndoBatch();
+ useDirectorStore.getState().updateObjectTransform("char_default_a", { position: [1, 0, 0] });
+ useDirectorStore.getState().updateObjectTransform("char_default_a", { position: [2, 0, 0] });
+
+ // 拖拽中不做「整工程序列化 + 同步落盘」:带 base64 资源的工程会直接卡死指针跟随
+ expect(setItemSpy).not.toHaveBeenCalled();
+
+ useDirectorStore.getState().endUndoBatch();
+
+ // 拖拽结束后补写一次最终结果
+ expect(setItemSpy).toHaveBeenCalled();
+});
+
it("undoes the latest scene mutation", () => {
useDirectorStore.getState().addPresetCharacter("female");
diff --git a/src/editor/store/directorStore.ts b/src/editor/store/directorStore.ts
index 8fdfa12..85de2f3 100644
--- a/src/editor/store/directorStore.ts
+++ b/src/editor/store/directorStore.ts
@@ -90,6 +90,11 @@ export interface DirectorUiState {
viewportPanelsCollapsed: boolean;
/** 悬浮时间轴(录制面板)是否显示,由视口快捷工具条的「录制」按钮切换 */
timelineVisible: boolean;
+ /** 用户自定义的时间轴轨道顺序(轨道 key 列表)。
+ * 只记录「被调整过」的轨道;未出现在列表里的新轨道按默认顺序排在后面。 */
+ timelineTrackOrder: string[];
+ /** 已折叠的时间轴轨道 key(角色轨道折叠后只留抬头行) */
+ timelineCollapsedTracks: string[];
}
export interface DirectorState extends DirectorUiState {
@@ -119,6 +124,10 @@ export interface DirectorActions {
setViewportPanelsCollapsed: (collapsed: boolean) => void;
toggleTimelineVisible: () => void;
setTimelineVisible: (visible: boolean) => void;
+ /** 覆盖时间轴轨道顺序(传入解析后的完整顺序) */
+ setTimelineTrackOrder: (order: string[]) => void;
+ /** 折叠 / 展开某个时间轴轨道 */
+ toggleTimelineTrackCollapsed: (trackKey: string) => void;
selectObject: (id: string | null) => void;
selectCrowd: (crowdId: string | null) => void;
toggleObjectSelection: (id: string) => void;
@@ -257,6 +266,8 @@ const DEFAULT_UI_STATE: DirectorUiState = {
viewportRuleOfThirdsEnabled: false,
viewportPanelsCollapsed: false,
timelineVisible: true,
+ timelineTrackOrder: [],
+ timelineCollapsedTracks: [],
};
function normalizeDirectorScenePersistenceScopeId(scopeId: string | null | undefined) {
@@ -392,6 +403,11 @@ function readPersistedTrajectoryPointRef(value: unknown): DirectorTrajectoryPoin
: null;
}
+/** 读取持久化里的字符串数组(时间轴轨道顺序 / 折叠状态),非法值一律回落到空数组 */
+function readPersistedStringArray(value: unknown): string[] {
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
+}
+
function removePersistedLocalModelAsset(assetId: string) {
writePersistedLocalModelAssets(readPersistedLocalModelAssets().filter((asset) => asset.id !== assetId));
}
@@ -494,6 +510,8 @@ function extractPersistedDirectorState(state: DirectorRuntimeState): DirectorSta
viewportRuleOfThirdsEnabled: state.viewportRuleOfThirdsEnabled,
viewportPanelsCollapsed: state.viewportPanelsCollapsed,
timelineVisible: state.timelineVisible,
+ timelineTrackOrder: state.timelineTrackOrder,
+ timelineCollapsedTracks: state.timelineCollapsedTracks,
project: state.project,
});
}
@@ -554,6 +572,8 @@ function readPersistedDirectorState(options: DirectorStateOptions = {}): Directo
viewportRuleOfThirdsEnabled: Boolean(state.viewportRuleOfThirdsEnabled),
viewportPanelsCollapsed: Boolean(state.viewportPanelsCollapsed),
timelineVisible: state.timelineVisible === false ? false : true,
+ timelineTrackOrder: readPersistedStringArray(state.timelineTrackOrder),
+ timelineCollapsedTracks: readPersistedStringArray(state.timelineCollapsedTracks),
project: withPersistedLocalAssets(
migrateDirectorProject(cloneJsonValue(state.project)),
options.includePersistedLocalAssets
@@ -1317,6 +1337,17 @@ export const useDirectorStore = create((set, get) => {
createInitialDirectorState({ includePersistedLocalAssets: true, includePersistedScene: true })
);
+ /** 拖拽(撤销批次)期间的持久化防抖间隔:连续拖拽时完全不序列化,停手 150ms 后落盘一次 */
+ const UNDO_BATCH_PERSIST_DEBOUNCE_MS = 150;
+ let undoBatchPersistTimer: ReturnType | null = null;
+
+ function cancelPendingUndoBatchPersist() {
+ if (undoBatchPersistTimer === null) return;
+
+ clearTimeout(undoBatchPersistTimer);
+ undoBatchPersistTimer = null;
+ }
+
function commitMutation(
updater: (state: DirectorRuntimeState) => DirectorRuntimeState,
options: { trackUndo?: boolean; persist?: boolean } = {}
@@ -1325,10 +1356,14 @@ export const useDirectorStore = create((set, get) => {
set((state) => {
const currentState = state as DirectorRuntimeState;
- const previousSnapshot = createUndoStackEntry(currentState);
+ // 撤销批次内(gizmo / 轨迹点拖拽中)不会再有新的撤销快照 —— 批次开始时的快照已经够回退。
+ // 因此这里跳过「整工程深克隆 + 全量 JSON 比较」:拖拽时每次指针移动都做一遍的话,
+ // 工程里只要带了 base64 全景图就会直接卡死(实测单次写入 39ms @2MB 资源)。
+ const isInsideUndoBatch = trackUndo && currentState.undoBatchDepth > 0;
+ const previousSnapshot = isInsideUndoBatch ? null : createUndoStackEntry(currentState);
const nextState = updater(currentState);
- const nextSnapshot = extractPersistedDirectorState(nextState);
- const didChange = !isSameDirectorState(previousSnapshot, nextSnapshot);
+ const didChange =
+ previousSnapshot === null || !isSameDirectorState(previousSnapshot, extractPersistedDirectorState(nextState));
if (!didChange) {
return {
@@ -1340,27 +1375,37 @@ export const useDirectorStore = create((set, get) => {
};
}
- const shouldCaptureUndoBatchSnapshot =
- trackUndo && currentState.undoBatchDepth > 0 && currentState.undoBatchSnapshot === null;
+ const shouldCaptureUndoBatchSnapshot = isInsideUndoBatch && currentState.undoBatchSnapshot === null;
const nextUndoStack =
- trackUndo && currentState.undoBatchDepth === 0
+ previousSnapshot && currentState.undoBatchDepth === 0
? trimUndoStack([...currentState.undoStack, previousSnapshot])
: nextState.undoStack;
const runtimeState: DirectorRuntimeState = {
...nextState,
undoStack: nextUndoStack,
- undoBatchSnapshot: shouldCaptureUndoBatchSnapshot ? previousSnapshot : nextState.undoBatchSnapshot,
- undoBatchHasTrackedChanges:
- trackUndo && currentState.undoBatchDepth > 0 ? true : nextState.undoBatchHasTrackedChanges,
+ undoBatchSnapshot: shouldCaptureUndoBatchSnapshot
+ ? createUndoStackEntry(currentState)
+ : nextState.undoBatchSnapshot,
+ undoBatchHasTrackedChanges: isInsideUndoBatch ? true : nextState.undoBatchHasTrackedChanges,
};
if (persist) {
- writePersistedDirectorState(extractPersistedDirectorState(runtimeState));
+ if (isInsideUndoBatch) {
+ // 拖拽中不落盘:整工程序列化 + 同步写 localStorage 会直接卡住指针跟随,
+ // 改成停手 150ms 后写一次,endUndoBatch 时还会立刻补写最终结果。
+ cancelPendingUndoBatchPersist();
+ undoBatchPersistTimer = setTimeout(() => {
+ undoBatchPersistTimer = null;
+ writePersistedDirectorState(extractPersistedDirectorState(get() as DirectorRuntimeState));
+ }, UNDO_BATCH_PERSIST_DEBOUNCE_MS);
+ } else {
+ writePersistedDirectorState(extractPersistedDirectorState(runtimeState));
+ }
}
return runtimeState;
- });
- }
+ });
+ }
function commitUiMutation(updater: (state: DirectorRuntimeState) => DirectorRuntimeState) {
commitMutation(updater, { trackUndo: false, persist: true });
@@ -1381,6 +1426,8 @@ export const useDirectorStore = create((set, get) => {
});
},
endUndoBatch: () => {
+ const depthBefore = (get() as DirectorRuntimeState).undoBatchDepth;
+
set((state) => {
const currentState = state as DirectorRuntimeState;
if (currentState.undoBatchDepth === 0) return currentState;
@@ -1409,6 +1456,12 @@ export const useDirectorStore = create((set, get) => {
undoBatchHasTrackedChanges: false,
};
});
+
+ // 批次内的落盘是防抖的,收尾时立刻补写一次,保证刷新页面拿到的是拖拽后的最终结果
+ if (depthBefore > 0) {
+ cancelPendingUndoBatchPersist();
+ writePersistedDirectorState(extractPersistedDirectorState(get() as DirectorRuntimeState));
+ }
},
setTransformMode: (mode) =>
commitUiMutation((state) => ({
@@ -1445,6 +1498,18 @@ export const useDirectorStore = create((set, get) => {
...state,
timelineVisible: visible,
})),
+ setTimelineTrackOrder: (order) =>
+ commitUiMutation((state) => ({
+ ...state,
+ timelineTrackOrder: order.filter((key): key is string => typeof key === "string"),
+ })),
+ toggleTimelineTrackCollapsed: (trackKey) =>
+ commitUiMutation((state) => ({
+ ...state,
+ timelineCollapsedTracks: state.timelineCollapsedTracks.includes(trackKey)
+ ? state.timelineCollapsedTracks.filter((key) => key !== trackKey)
+ : [...state.timelineCollapsedTracks, trackKey],
+ })),
setViewMode: (mode) =>
commitUiMutation((state) => ({
...state,
diff --git a/src/main.tsx b/src/main.tsx
index f46c379..7e42d70 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,6 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
+import { installThreeConsoleFilter } from "./threeConsole";
+
+// 渲染前装好 three.js 控制台过滤器:R3F 挂载