feat(timeline): 支持角色轨迹关键帧与轨道拖拽排序

This commit is contained in:
2026-09-17 14:47:29 +08:00
parent 0794486857
commit bab92c05b8
18 changed files with 1862 additions and 135 deletions

View File

@@ -12,12 +12,17 @@ export function PlaybackTransformSync() {
const scene = useThree((state) => state.scene);
const tracks = useDirectorStore((state) => state.project.timeline.tracks);
const cameraTracks = useDirectorStore((state) => state.project.timeline.cameraTracks);
const objects = useDirectorStore((state) => state.project.objects);
const playhead = usePlaybackStore((state) => state.playhead);
const duration = usePlaybackStore((state) => state.duration);
const editingObjectId = usePlaybackStore((state) => state.editingObjectId);
useFrame(() => {
// 播放、scrub 时都按 playhead 插值覆盖;editingObjectId 的跳过逻辑在共享函数里
applyTimelineTransformsAtTime(scene, tracks, cameraTracks, playhead, editingObjectId);
applyTimelineTransformsAtTime(scene, tracks, cameraTracks, playhead, editingObjectId, {
objects,
duration,
});
});
return null;

View File

@@ -4,6 +4,7 @@ import { afterEach, beforeEach, vi } from "vitest";
import { VIEWPORT_CAMERA_VISUAL_SCALE } from "../schema/cameraGeometry";
import { degreesRotationToRadians } from "../schema/directorProject";
import { createInitialDirectorState, useDirectorStore } from "../store/directorStore";
import { usePlaybackStore } from "../runtime/playbackStore";
import { getImportedModelNormalization, getTrajectoryGroundPoint, SceneRoot } from "./SceneRoot";
import { AXIS_COLORS } from "./sceneAxes";
import { GROUND_GRID_ELEVATION, GROUND_PLANE_SIZE } from "./sceneGround";
@@ -162,6 +163,7 @@ vi.mock("../runtime/CharacterModel", async () => {
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
mockCharacterModelShouldSuspend.current = false;
usePlaybackStore.setState({ isPlaying: false, playhead: 0, duration: 5, editingObjectId: null });
const base = createInitialDirectorState();
useDirectorStore.setState({
...useDirectorStore.getState(),
@@ -678,6 +680,17 @@ it("renders solid trajectory spheres and a smooth curve through the waypoints",
expect(container.querySelectorAll('mesh[name^="trajectory-point-"]')).toHaveLength(2);
});
it("puts a walking character at its trajectory sample for the current playhead", () => {
useDirectorStore.getState().addTrajectoryPoint("char_default_a", [8, 0, 0]);
// 单点轨迹:0s 在角色起始位置,时间轴末尾(5s)到达该轨迹点
usePlaybackStore.setState({ isPlaying: false, playhead: 5, duration: 5, editingObjectId: null });
render(<SceneRoot />);
const characterGroup = screen.getByTestId("mock-character-model").closest("group");
expect(characterGroup).toHaveAttribute("position", "8,0,0");
});
it("shows a translate gizmo on the selected trajectory point so it can be dragged", () => {
useDirectorStore.getState().addTrajectoryPoint("char_default_a", [1, 0, 2]);
const pointId = useDirectorStore

View File

@@ -30,6 +30,7 @@ import {
VIEWPORT_CAMERA_VISUAL_SCALE,
} from "../schema/cameraGeometry";
import { VIEWPORT_OBJECT_LABEL_VERTICAL_GAP } from "../schema/viewportLabels";
import { evaluateTrajectoryMotionAtTime, getTrajectoryPointTime } from "../schema/trajectoryMotion";
import type { TransformMode } from "../store/directorStore";
import { useDirectorStore } from "../store/directorStore";
import { usePlaybackStore } from "../runtime/playbackStore";
@@ -498,14 +499,20 @@ function ObjectSceneNode({
const updateObjectTransform = useDirectorStore((state) => state.updateObjectTransform);
const isPlaying = usePlaybackStore((state) => state.isPlaying);
const motionTime = usePlaybackStore((state) => state.playhead);
// 当前选中的轨迹点若属于本角色,则把角色「预览」到该路点的位置与朝向上,
// 让用户直接看到角色在某个轨迹点的姿态(水平角/俯仰角/横滚角)。
// 播放时角色要沿路径动画,故预览只在非播放时生效(拖拽 gizmo 也已排除播放态)。
const playbackDuration = usePlaybackStore((state) => state.duration);
// 当前选中的轨迹点若属于本角色,则抑制角色自身的变换 gizmo(拖拽交给轨迹点 gizmo)。
const selectedTrajectoryPoint = useDirectorStore((state) => state.selectedTrajectoryPoint);
const activeTrajectoryPoint =
!isPlaying && selectedTrajectoryPoint?.objectId === item.id
? item.trajectoryPoints?.find((point) => point.id === selectedTrajectoryPoint.pointId)
: undefined;
// 带轨迹的角色:位置 / 朝向由「人物轨迹 + 播放头」决定。
// 这里把同一个结果也算进 props,和 PlaybackTransformSync 每帧的写入保持一致,
// 否则 React 每次重渲染都会把角色拉回起始姿态,和轨迹走位互相打架(表现为抖动/闪回)。
const trajectoryMotion = useMemo(
() => evaluateTrajectoryMotionAtTime(item, playbackDuration, motionTime),
[item, playbackDuration, motionTime]
);
const isImportedModel = asset?.sourceType === "model";
const characterLabelKey = `${item.id}:${item.bodyType ?? ""}:${item.characterRig?.rigType ?? ""}`;
const fallbackCharacterLabelY =
@@ -549,10 +556,10 @@ function ObjectSceneNode({
const node = (
<group
ref={groupRef}
position={activeTrajectoryPoint ? activeTrajectoryPoint.position : item.transform.position}
position={trajectoryMotion ? trajectoryMotion.position : item.transform.position}
// 数据模型里旋转是度数,应用到 Three.js 的 rotation 属性(弧度)前要转换,
// 否则 360° 会被当成 360 弧度 ≈ 57 圈。
rotation={degreesRotationToRadians(activeTrajectoryPoint ? activeTrajectoryPoint.rotation : item.transform.rotation)}
rotation={degreesRotationToRadians(trajectoryMotion ? trajectoryMotion.rotation : item.transform.rotation)}
scale={item.transform.scale}
userData={{ directorObjectId: item.id }}
onClick={(event) => {
@@ -867,6 +874,9 @@ function TrajectoryPath({
const selectTrajectoryPoint = useDirectorStore((state) => state.selectTrajectoryPoint);
const updateTrajectoryPoint = useDirectorStore((state) => state.updateTrajectoryPoint);
const isPlaying = usePlaybackStore((state) => state.isPlaying);
const setPlaying = usePlaybackStore((state) => state.setPlaying);
const setPlayhead = usePlaybackStore((state) => state.setPlayhead);
const playbackDuration = usePlaybackStore((state) => state.duration);
const translationSnap = useDirectorStore((state) => (state.project.scene.snapToGrid ? 1 : null));
const selectedGroupRef = useRef<Group>(null!);
const points = owner.trajectoryPoints ?? [];
@@ -903,6 +913,14 @@ function TrajectoryPath({
function selectPoint(pointId: string, event: ThreeEvent<MouseEvent>) {
event.stopPropagation();
selectTrajectoryPoint(owner.id, pointId);
// 轨迹点本身就是时间轴上的关键帧:选中即把指针定位到该点时间,
// 角色按「沿轨迹行走」的插值正好预览到这一点的位置与朝向(0s = 角色起始位置)。
const pointIndex = points.findIndex((point) => point.id === pointId);
if (pointIndex < 0) return;
setPlaying(false);
setPlayhead(getTrajectoryPointTime(pointIndex, points.length, playbackDuration));
}
/**

View File

@@ -176,7 +176,8 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
directorStore.project.timeline.tracks,
directorStore.project.timeline.cameraTracks,
time,
usePlaybackStore.getState().editingObjectId
usePlaybackStore.getState().editingObjectId,
{ objects: directorStore.project.objects, duration }
);
applyActiveCameraViewAtTime({
camera: frameCamera,
@@ -248,11 +249,9 @@ async function exportVideoWithMediaRecorder(view: VideoExportView): Promise<void
const directorStore = useDirectorStore.getState();
const duration = directorStore.project.timeline.duration;
// 切视角 + 复位指针 + 录制模式(到末尾停住不循环)
// 切视角 + 复位指针(时间轴本身就是单次播放,到末尾会自己停住)
directorStore.setViewMode(view);
const playback = usePlaybackStore.getState();
playback.setStopAtEnd(true);
playback.setPlayhead(0);
usePlaybackStore.getState().setPlayhead(0);
const stopped = new Promise<void>((resolve) => {
recorder.onstop = () => resolve();
@@ -263,7 +262,7 @@ async function exportVideoWithMediaRecorder(view: VideoExportView): Promise<void
// 等两帧让视角切换 + 首帧渲染,避免录到上一视角的残影
await waitNextFrame();
// 监听播放结束(stopAtEnd → isPlaying 置 false)→ 停止录制
// 监听播放结束(播到末帧 → isPlaying 置 false)→ 停止录制
const unsubscribe = usePlaybackStore.subscribe((state) => {
if (!state.isPlaying) {
try {
@@ -289,7 +288,6 @@ async function exportVideoWithMediaRecorder(view: VideoExportView): Promise<void
clearTimeout(fallbackTimer);
unsubscribe();
usePlaybackStore.getState().setStopAtEnd(false);
usePlaybackStore.getState().setPlaying(false);
const viewLabel = view === "director" ? "上帝视角" : "机位视角";

View File

@@ -1,8 +1,48 @@
import { beforeEach, it, expect, vi } from "vitest";
import { afterEach, beforeEach, it, expect, vi } from "vitest";
import { act, render, screen, fireEvent, within } from "@testing-library/react";
import { useDirectorStore, createDefaultDirectorProject } from "../store/directorStore";
import { usePlaybackStore } from "../runtime/playbackStore";
import { getTimelineRulerTicks, TimelinePanel } from "./TimelinePanel";
import {
getTimelineFrame,
getTimelineRulerMinorTicks,
getTimelineRulerStep,
getTimelineRulerTicks,
reorderTimelineTrackKeys,
resolveTimelineTrackOrder,
TIMELINE_FPS,
TimelinePanel,
} from "./TimelinePanel";
function getDefaultCharacter() {
return useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!;
}
/**
* jsdom 不做真实布局(所有 getBoundingClientRect 都是 0),
* 轨道拖拽要靠行几何算落点,这里按 data-track-key 造一份给定布局。
*/
function stubTrackRowLayout(layout: Record<string, { top: number; height: number }>) {
const original = Element.prototype.getBoundingClientRect;
return vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) {
const key = this instanceof HTMLElement ? this.dataset.trackKey : undefined;
const entry = key ? layout[key] : undefined;
if (!entry) return original.call(this);
const bottom = entry.top + entry.height;
return {
x: 0,
y: entry.top,
top: entry.top,
bottom,
left: 0,
right: 800,
width: 800,
height: entry.height,
toJSON: () => ({}),
} as DOMRect;
});
}
// 导出是浏览器能力(WebCodecs / MediaRecorder),单测里只关心调用的是哪个视角
const mockExportVideo = vi.hoisted(() => vi.fn());
@@ -13,14 +53,69 @@ vi.mock("../io/videoExport", () => ({
beforeEach(() => {
useDirectorStore.getState().replaceProject(createDefaultDirectorProject() as never);
// 轨道顺序 / 折叠状态是纯 UI 状态,replaceProject 不会重置,这里显式清掉避免用例互相影响
useDirectorStore.setState({ timelineTrackOrder: [], timelineCollapsedTracks: [] });
usePlaybackStore.setState({ isPlaying: false, playhead: 0, duration: 5 });
mockExportVideo.mockReset();
});
it("renders play button and time readout", () => {
render(<TimelinePanel />);
afterEach(() => {
vi.restoreAllMocks();
});
it("renders play button and the frame/time readout with the total duration on its own", () => {
const { container } = render(<TimelinePanel />);
expect(screen.getByRole("button", { name: "播放" })).toBeInTheDocument();
expect(screen.getByText(/0\.0s \/ 5\.0s/)).toBeInTheDocument();
// 读数 = 帧号 / 时间
expect(container.querySelector(".timeline-time-readout")?.textContent).toBe("F0/0s");
// 总时间单独显示在时长输入框里
expect(screen.getByLabelText("时间轴总时长")).toHaveValue(5);
expect(container.querySelector(".timeline-duration-caption")?.textContent).toBe("总时长");
});
it("shows the playhead as a frame number next to the time", () => {
usePlaybackStore.getState().setPlayhead(1.5);
const { container } = render(<TimelinePanel />);
// 1.5s × 30fps = 45 帧
expect(container.querySelector(".timeline-time-readout")?.textContent).toBe("F45/1.5s");
});
it("gets the frame number from the exported frame rate", () => {
expect(getTimelineFrame(0)).toBe(0);
expect(getTimelineFrame(1)).toBe(TIMELINE_FPS);
expect(getTimelineFrame(0.5)).toBe(TIMELINE_FPS / 2);
expect(getTimelineFrame(-3)).toBe(0);
});
it("draws 0.1s minor ticks between the major ticks", () => {
// 5s 时间轴:主刻度每秒一条,细刻度每 0.1s 一条 → 49 个候选位置去掉 4 个整秒位置
const ticks = getTimelineRulerMinorTicks(5, getTimelineRulerStep(5));
expect(ticks).toHaveLength(45);
expect(ticks[0].value).toBeCloseTo(0.1, 4);
expect(ticks[0].percent).toBeCloseTo(2, 4);
// 与主刻度重合的位置交给主刻度,不重复画
expect(ticks.some((tick) => tick.value === 1)).toBe(false);
expect(ticks.some((tick) => tick.value === 0.5)).toBe(true);
});
it("skips 0.1s minor ticks when they would be unreadable", () => {
// 主刻度本身已经细到 0.1s
expect(getTimelineRulerMinorTicks(1, 0.1)).toEqual([]);
// 时间轴太长:细刻度会糊成一片,直接不画
expect(getTimelineRulerMinorTicks(30, getTimelineRulerStep(30))).toEqual([]);
expect(getTimelineRulerMinorTicks(0, 1)).toEqual([]);
});
it("renders the minor ticks on the ruler alongside the labeled major ticks", () => {
const { container } = render(<TimelinePanel />);
expect(container.querySelectorAll(".timeline-ruler-minor-tick")).toHaveLength(45);
// 主刻度数量不受影响
expect(container.querySelectorAll(".timeline-ruler-tick")).toHaveLength(6);
});
it("toggles to pause when play button clicked", () => {
@@ -121,6 +216,268 @@ it("renders a second-based ruler and publishes the tick spacing for the grid lin
expect(grid?.style.getPropertyValue("--timeline-tick-percent")).toBe("20%");
});
it("stops playback and rewinds to the start from the transport controls", () => {
usePlaybackStore.getState().setPlaying(true);
usePlaybackStore.getState().setPlayhead(3);
render(<TimelinePanel />);
fireEvent.click(screen.getByRole("button", { name: "停止" }));
expect(usePlaybackStore.getState().isPlaying).toBe(false);
expect(usePlaybackStore.getState().playhead).toBe(0);
});
it("adds a character track with motion and trajectory sub-tracks", () => {
const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!;
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]);
const { container } = render(<TimelinePanel />);
expect(container.querySelector(".timeline-character-track")).toBeInTheDocument();
expect(screen.getByText("动作")).toBeInTheDocument();
expect(screen.getByText("轨迹")).toBeInTheDocument();
expect(screen.getByText("无动作")).toBeInTheDocument();
// 两个轨迹点把 5s 时间轴等分成 2 段 → 关键帧落在 2.5s / 5s
expect(screen.getByRole("button", { name: "轨迹关键帧 2.5s" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "轨迹关键帧 5.0s" })).toBeInTheDocument();
});
it("shows the character motion kind on the motion sub-track", () => {
const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!;
useDirectorStore.getState().setMotion(character.id, "walk");
render(<TimelinePanel />);
expect(screen.getByText("行走")).toBeInTheDocument();
});
it("selects a trajectory point and seeks the playhead when its timeline keyframe is clicked", () => {
const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!;
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]);
const firstPointId = useDirectorStore
.getState()
.project.objects.find((item) => item.id === character.id)!.trajectoryPoints![0].id;
render(<TimelinePanel />);
fireEvent.click(screen.getByRole("button", { name: "轨迹关键帧 2.5s" }));
expect(usePlaybackStore.getState().playhead).toBe(2.5);
expect(useDirectorStore.getState().selectedTrajectoryPoint).toEqual({
objectId: character.id,
pointId: firstPointId,
});
});
it("deletes a trajectory point from its timeline keyframe", () => {
const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!;
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]);
render(<TimelinePanel />);
fireEvent.click(screen.getByRole("button", { name: "删除轨迹关键帧 5.0s" }));
const points = useDirectorStore.getState().project.objects.find((item) => item.id === character.id)!.trajectoryPoints;
expect(points).toHaveLength(1);
});
it("resolves the track order by keeping saved order first and appending new tracks", () => {
expect(resolveTimelineTrackOrder(["a", "b", "c"], [])).toEqual(["a", "b", "c"]);
// 保存过的顺序优先,未记录的轨道按默认顺序补在后面
expect(resolveTimelineTrackOrder(["a", "b", "c"], ["c", "a"])).toEqual(["c", "a", "b"]);
// 已删除的轨道 / 重复项被剔除
expect(resolveTimelineTrackOrder(["a", "b"], ["b", "b", "gone"])).toEqual(["b", "a"]);
});
it("reorders tracks by dropping one track before or after another", () => {
expect(reorderTimelineTrackKeys(["a", "b", "c"], "c", "a", "before")).toEqual(["c", "a", "b"]);
expect(reorderTimelineTrackKeys(["a", "b", "c"], "a", "c", "after")).toEqual(["b", "c", "a"]);
expect(reorderTimelineTrackKeys(["a", "b", "c"], "c", "a", "after")).toEqual(["a", "c", "b"]);
});
it("keeps the track order untouched when a drop would not change it", () => {
const order = ["a", "b", "c"];
// 拖到自己身上、落点本来就在原位、或者轨道不存在 → 原样返回同一个引用
expect(reorderTimelineTrackKeys(order, "b", "b", "after")).toBe(order);
expect(reorderTimelineTrackKeys(order, "a", "b", "before")).toBe(order);
expect(reorderTimelineTrackKeys(order, "b", "a", "after")).toBe(order);
expect(reorderTimelineTrackKeys(order, "missing", "a", "before")).toBe(order);
expect(reorderTimelineTrackKeys(order, "a", "missing", "before")).toBe(order);
});
/** 渲染一个「物体轨道 + 角色轨道」的时间轴,并给两行造好纵向布局 */
function renderTwoTrackTimeline() {
const character = getDefaultCharacter();
useDirectorStore.getState().selectObject(character.id);
usePlaybackStore.getState().setPlayhead(1);
const rendered = render(<TimelinePanel />);
fireEvent.click(screen.getByRole("button", { name: /在指针处打关键帧/ }));
const container = rendered.container;
const objectRow = container.querySelector<HTMLElement>(".timeline-grid-body > .timeline-track")!;
const characterRow = container.querySelector<HTMLElement>(".timeline-character-track")!;
stubTrackRowLayout({
[`object:${character.id}`]: { top: 0, height: 28 },
[`character:${character.id}`]: { top: 28, height: 84 },
});
return {
character,
container,
objectRow,
characterRow,
panel: container.querySelector<HTMLElement>(".timeline-panel")!,
indicator: container.querySelector<HTMLElement>(".timeline-drop-indicator")!,
};
}
/** 按下 → 移动超过阈值,进入真正的轨道拖拽 */
function beginTrackDrag(row: HTMLElement, clientY = 10) {
fireEvent.pointerDown(row, { button: 0, pointerId: 1, clientX: 10, clientY });
fireEvent.pointerMove(row, { pointerId: 1, clientX: 10, clientY: clientY + 60 });
}
it("reorders tracks by dragging a whole track row", () => {
const { character, objectRow, characterRow, panel, indicator } = renderTwoTrackTimeline();
const objectTrackKey = `object:${character.id}`;
const characterTrackKey = `character:${character.id}`;
// 默认顺序:物体轨道在前,角色轨道在后
expect(objectRow.style.order).toBe("0");
expect(characterRow.style.order).toBe("1");
// 已经不再是浏览器原生拖拽(那套机制的开销我们控不住)
expect(objectRow).not.toHaveAttribute("draggable");
beginTrackDrag(objectRow, 10);
expect(objectRow).toHaveClass("is-dragging");
// 拖拽期间关掉面板毛玻璃(背后是全屏 WebGL 画布,32px 模糊会让拖动卡死)
expect(panel).toHaveClass("is-track-dragging");
expect(document.body).toHaveClass("is-timeline-dragging");
// 指针移到角色轨道下半区 → 落点线出现在它下方(把它排到角色轨道之后)
fireEvent.pointerMove(objectRow, { pointerId: 1, clientX: 10, clientY: 100 });
expect(indicator).toHaveClass("is-visible");
expect(indicator.style.transform).toBe("translateY(112px)");
fireEvent.pointerUp(objectRow, { pointerId: 1, clientX: 10, clientY: 100 });
expect(useDirectorStore.getState().timelineTrackOrder).toEqual([characterTrackKey, objectTrackKey]);
expect(characterRow.style.order).toBe("0");
expect(objectRow.style.order).toBe("1");
expect(objectRow).not.toHaveClass("is-dragging");
expect(indicator).not.toHaveClass("is-visible");
expect(panel).not.toHaveClass("is-track-dragging");
expect(document.body).not.toHaveClass("is-timeline-dragging");
});
it("treats a press without movement as a plain click, not a track drag", () => {
const { objectRow, panel, indicator } = renderTwoTrackTimeline();
fireEvent.pointerDown(objectRow, { button: 0, pointerId: 1, clientX: 10, clientY: 10 });
// 只挪了 3px(阈值 4px)→ 还没进入拖拽,关键帧的点击不会被抢走
fireEvent.pointerMove(objectRow, { pointerId: 1, clientX: 11, clientY: 12 });
fireEvent.pointerUp(objectRow, { pointerId: 1, clientX: 11, clientY: 12 });
expect(objectRow).not.toHaveClass("is-dragging");
expect(panel).not.toHaveClass("is-track-dragging");
expect(indicator).not.toHaveClass("is-visible");
expect(useDirectorStore.getState().timelineTrackOrder).toEqual([]);
});
it("keeps repeating pointermove at the same drop position idempotent", () => {
const { objectRow, indicator } = renderTwoTrackTimeline();
beginTrackDrag(objectRow, 10);
// 指针移动频率跟鼠标一致:同一条落点上反复触发不应产生任何额外写入
const transforms = new Set<string>();
for (let index = 0; index < 6; index += 1) {
fireEvent.pointerMove(objectRow, { pointerId: 1, clientX: 10, clientY: 34 });
transforms.add(indicator.style.transform);
}
expect(indicator).toHaveClass("is-visible");
expect(transforms.size).toBe(1);
expect(useDirectorStore.getState().timelineTrackOrder).toEqual([]);
});
it("does not start a track drag from the collapse button", () => {
const { character, objectRow, panel } = renderTwoTrackTimeline();
const collapseButton = screen.getByRole("button", { name: `折叠物体轨道(${character.name})` });
fireEvent.pointerDown(collapseButton, { button: 0, pointerId: 1, clientX: 10, clientY: 10 });
fireEvent.pointerMove(collapseButton, { pointerId: 1, clientX: 10, clientY: 90 });
expect(objectRow).not.toHaveClass("is-dragging");
expect(panel).not.toHaveClass("is-track-dragging");
});
it("collapses and expands a character track", () => {
const character = getDefaultCharacter();
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
const { container } = render(<TimelinePanel />);
expect(container.querySelectorAll(".timeline-subtrack")).toHaveLength(2);
fireEvent.click(screen.getByRole("button", { name: `折叠角色轨道(${character.name})` }));
// 折叠后只剩抬头行,动作 / 轨迹子轨道都收起来
expect(container.querySelectorAll(".timeline-subtrack")).toHaveLength(0);
expect(container.querySelector(".timeline-character-track")).toHaveClass("is-collapsed");
expect(container.querySelector(".timeline-character-head-lane")?.textContent).toContain("1 个轨迹点");
expect(useDirectorStore.getState().timelineCollapsedTracks).toEqual([`character:${character.id}`]);
const expandButton = screen.getByRole("button", { name: `展开角色轨道(${character.name})` });
expect(expandButton).toHaveAttribute("aria-expanded", "false");
fireEvent.click(expandButton);
expect(container.querySelectorAll(".timeline-subtrack")).toHaveLength(2);
expect(useDirectorStore.getState().timelineCollapsedTracks).toEqual([]);
});
it("gives every track a collapse toggle, even when it has no sub-tracks", () => {
const character = getDefaultCharacter();
useDirectorStore.getState().selectObject(character.id);
usePlaybackStore.getState().setPlayhead(1);
const { container } = render(<TimelinePanel />);
fireEvent.click(screen.getByRole("button", { name: /在指针处打关键帧/ }));
// 物体轨道没有子轨道,同样带折叠开关
fireEvent.click(screen.getByRole("button", { name: `折叠物体轨道(${character.name})` }));
expect(container.querySelector(".timeline-grid-body > .timeline-track")).toHaveClass("is-collapsed");
expect(container.querySelector(".timeline-track-summary")?.textContent).toBe("1 个关键帧");
expect(screen.queryByRole("button", { name: "关键帧 1.0s" })).not.toBeInTheDocument();
expect(useDirectorStore.getState().timelineCollapsedTracks).toEqual([`object:${character.id}`]);
});
it("gives the camera track a collapse toggle as well", () => {
const cameraObject = useDirectorStore.getState().project.objects.find((item) => item.kind === "camera")!;
useDirectorStore.getState().selectObject(cameraObject.id);
usePlaybackStore.getState().setPlayhead(1);
render(<TimelinePanel />);
fireEvent.click(screen.getByRole("button", { name: /在指针处打关键帧/ }));
const toggle = screen.getByRole("button", { name: `折叠运镜轨道(${cameraObject.name})` });
expect(toggle).toHaveAttribute("aria-expanded", "true");
fireEvent.click(toggle);
expect(screen.getByRole("button", { name: `展开运镜轨道(${cameraObject.name})` })).toHaveAttribute(
"aria-expanded",
"false"
);
expect(screen.queryByRole("button", { name: "运镜关键帧 1.0s" })).not.toBeInTheDocument();
});
it("jumps to the first and last frame from the transport controls", () => {
usePlaybackStore.getState().setPlayhead(3);
render(<TimelinePanel />);

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type {
CSSProperties,
KeyboardEvent as ReactKeyboardEvent,
@@ -8,6 +8,7 @@ import {
Box,
Camera,
ChevronDown,
ChevronRight,
DiamondPlus,
Eraser,
Film,
@@ -18,11 +19,14 @@ import {
RefreshCw,
SkipBack,
SkipForward,
Square,
Trash2,
Video,
type LucideIcon,
} from "lucide-react";
import type { CameraKeyframeValue, KeyframeTransformValue } from "../schema/timeline";
import type { MotionKind } from "../schema/directorProject";
import { getTrajectoryPointTime } from "../schema/trajectoryMotion";
import { useDirectorStore } from "../store/directorStore";
import { usePlaybackStore } from "../runtime/playbackStore";
import { exportVideo, type VideoExportView } from "../io/videoExport";
@@ -37,25 +41,52 @@ function formatTime(t: number) {
return `${t.toFixed(1)}s`;
}
/** 读数里的时间:整秒省掉小数位(1s 而不是 1.0s),非整秒保留一位 */
function formatReadoutTime(t: number) {
const safeTime = Number.isFinite(t) ? t : 0;
return `${Number.isInteger(safeTime) ? safeTime : safeTime.toFixed(1)}s`;
}
/** 刻度候选步长(秒)。先按目标刻度数算出「每个刻度大约多少秒」,再取不小于它的最小候选值,
* 这样 5s / 8s / 30s 等不同时长都能得到疏密稳定的刻度。 */
const TIMELINE_RULER_STEP_CANDIDATES = [0.1, 0.2, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
const TIMELINE_RULER_TARGET_TICKS = 8;
/** 主刻度之间的细刻度步长(0.1s):介于两条主刻度之间,读数更精细 */
export const TIMELINE_RULER_MINOR_STEP = 0.1;
/** 细刻度数量上限:再密就糊成一条色带、没有读数意义,此时干脆不画 */
const TIMELINE_RULER_MINOR_MAX_COUNT = 200;
/** 时间轴帧率:读数里的帧号按它换算,与视频导出帧率保持一致 */
export const TIMELINE_FPS = 30;
/** 把秒换算成帧号(四舍五入到最近的一帧) */
export function getTimelineFrame(time: number): number {
const safeTime = Number.isFinite(time) ? Math.max(0, time) : 0;
return Math.round(safeTime * TIMELINE_FPS);
}
export interface TimelineRulerTick {
value: number;
percent: number;
}
/** 主刻度步长(秒):时长越长步长越粗,保证刻度数量稳定在目标值附近 */
export function getTimelineRulerStep(duration: number): number {
const safeDuration = Number.isFinite(duration) && duration > 0 ? duration : 0;
if (safeDuration === 0) return 0;
const roughStep = safeDuration / TIMELINE_RULER_TARGET_TICKS;
return (
TIMELINE_RULER_STEP_CANDIDATES.find((candidate) => candidate >= roughStep) ??
TIMELINE_RULER_STEP_CANDIDATES[TIMELINE_RULER_STEP_CANDIDATES.length - 1]
);
}
export function getTimelineRulerTicks(duration: number): TimelineRulerTick[] {
const safeDuration = Number.isFinite(duration) && duration > 0 ? duration : 0;
if (safeDuration === 0) return [{ value: 0, percent: 0 }];
const roughStep = safeDuration / TIMELINE_RULER_TARGET_TICKS;
const step =
TIMELINE_RULER_STEP_CANDIDATES.find((candidate) => candidate >= roughStep) ??
TIMELINE_RULER_STEP_CANDIDATES[TIMELINE_RULER_STEP_CANDIDATES.length - 1];
const step = getTimelineRulerStep(safeDuration);
const ticks: TimelineRulerTick[] = [];
for (let value = 0; value <= safeDuration + 1e-6; value += step) {
const rounded = Number(value.toFixed(4));
@@ -71,6 +102,33 @@ export function getTimelineRulerTicks(duration: number): TimelineRulerTick[] {
return ticks;
}
/**
* 主刻度之间的 0.1s 细刻度(比秒刻度更矮更淡,只画线不写数字)。
* - 主刻度本身已经细到 0.1s:不用再插细刻度;
* - 与主刻度重合的位置由主刻度负责,不重复画;
* - 时间轴太长(细刻度会糊成一片)时不画。
*/
export function getTimelineRulerMinorTicks(duration: number, majorStep: number): TimelineRulerTick[] {
const safeDuration = Number.isFinite(duration) && duration > 0 ? duration : 0;
if (safeDuration === 0 || majorStep <= TIMELINE_RULER_MINOR_STEP + 1e-9) return [];
const total = Math.floor(safeDuration / TIMELINE_RULER_MINOR_STEP + 1e-6);
if (total > TIMELINE_RULER_MINOR_MAX_COUNT) return [];
const ticks: TimelineRulerTick[] = [];
for (let index = 1; index <= total; index += 1) {
const value = Number((index * TIMELINE_RULER_MINOR_STEP).toFixed(4));
if (value >= safeDuration - 1e-6) break;
const majorRatio = value / majorStep;
if (Math.abs(majorRatio - Math.round(majorRatio)) < 1e-6) continue;
ticks.push({ value, percent: (value / safeDuration) * 100 });
}
return ticks;
}
function formatRulerTick(value: number) {
return `${Number.isInteger(value) ? value : value.toFixed(1)}s`;
}
@@ -82,6 +140,116 @@ const OBJECT_KIND_ICONS: Record<string, LucideIcon> = {
prop: Box,
};
/** 人物轨道「动作」子轨道里展示的基础动作名 */
const MOTION_LABELS: Record<MotionKind, string> = {
none: "无动作",
idle: "待机",
walk: "行走",
run: "跑步",
};
type TimelineTrackKind = "object" | "camera" | "character";
/** 轨道拖拽的启动阈值(px):小于它当作普通点击,避免抢走关键帧的选中点击 */
const TRACK_DRAG_THRESHOLD_PX = 4;
/** 轨道行的纵向几何(拖拽开始时量一次就缓存,移动中只查表) */
interface TrackRowRect {
top: number;
bottom: number;
height: number;
}
/** 时间轴轨道的稳定标识:三种轨道 id 各自命名空间,避免同名 id 撞车 */
function getTimelineTrackKey(kind: TimelineTrackKind, id: string) {
return `${kind}:${id}`;
}
/**
* 解析轨道的最终显示顺序:
* 1. 先按用户保存过的顺序排列(过滤掉已删除的轨道、去重);
* 2. 没被记录过的新轨道(新增对象 / 相机 / 角色)按默认顺序追加在后面。
*/
export function resolveTimelineTrackOrder(defaultOrder: string[], savedOrder: string[]): string[] {
const validKeys = new Set(defaultOrder);
const seen = new Set<string>();
const resolved: string[] = [];
savedOrder.forEach((key) => {
if (!validKeys.has(key) || seen.has(key)) return;
seen.add(key);
resolved.push(key);
});
defaultOrder.forEach((key) => {
if (seen.has(key)) return;
seen.add(key);
resolved.push(key);
});
return resolved;
}
/** 拖拽落点:落在目标轨道的上方还是下方 */
export type TimelineTrackDropPosition = "before" | "after";
/**
* 把 sourceKey 移动到 targetKey 的前 / 后(拖拽排序)。
* 找不到轨道、或移动后顺序没变时原样返回(调用方据此跳过写入)。
*/
export function reorderTimelineTrackKeys(
order: string[],
sourceKey: string,
targetKey: string,
position: TimelineTrackDropPosition
): string[] {
if (sourceKey === targetKey) return order;
if (!order.includes(sourceKey) || !order.includes(targetKey)) return order;
const withoutSource = order.filter((key) => key !== sourceKey);
const targetIndex = withoutSource.indexOf(targetKey);
const insertIndex = position === "after" ? targetIndex + 1 : targetIndex;
if (insertIndex === order.indexOf(sourceKey)) return order;
return [...withoutSource.slice(0, insertIndex), sourceKey, ...withoutSource.slice(insertIndex)];
}
/** 轨道行左侧的折叠开关:所有轨道都能折叠(有子轨道的收起子轨道,没有的收起关键帧车道) */
function TimelineTrackCollapseToggle({
trackLabel,
trackKindLabel,
collapsed,
onToggle,
}: {
trackLabel: string;
/** 轨道种类(物体 / 运镜 / 角色),用于区分同名轨道,让无障碍标签唯一 */
trackKindLabel: string;
collapsed: boolean;
onToggle: () => void;
}) {
const actionLabel = `${collapsed ? "展开" : "折叠"}${trackKindLabel}(${trackLabel})`;
return (
<button
className="timeline-track-icon-button timeline-track-collapse-button"
type="button"
aria-expanded={!collapsed}
aria-label={actionLabel}
title={collapsed ? "展开轨道" : "折叠轨道"}
onClick={(event) => {
event.stopPropagation();
onToggle();
}}
>
{collapsed ? (
<ChevronRight aria-hidden="true" size={13} strokeWidth={2.2} />
) : (
<ChevronDown aria-hidden="true" size={13} strokeWidth={2.2} />
)}
</button>
);
}
export function TimelinePanel() {
const project = useDirectorStore((state) => state.project);
const selectedObjectId = useDirectorStore((state) => state.selectedObjectId);
@@ -96,17 +264,41 @@ export function TimelinePanel() {
const applyCameraKeyframeToShot = useDirectorStore((state) => state.applyCameraKeyframeToShot);
const setTimelineDuration = useDirectorStore((state) => state.setTimelineDuration);
const clearAllKeyframes = useDirectorStore((state) => state.clearAllKeyframes);
const selectedTrajectoryPoint = useDirectorStore((state) => state.selectedTrajectoryPoint);
const selectTrajectoryPoint = useDirectorStore((state) => state.selectTrajectoryPoint);
const removeTrajectoryPoint = useDirectorStore((state) => state.removeTrajectoryPoint);
const timelineTrackOrder = useDirectorStore((state) => state.timelineTrackOrder);
const timelineCollapsedTracks = useDirectorStore((state) => state.timelineCollapsedTracks);
const setTimelineTrackOrder = useDirectorStore((state) => state.setTimelineTrackOrder);
const toggleTimelineTrackCollapsed = useDirectorStore((state) => state.toggleTimelineTrackCollapsed);
const isPlaying = usePlaybackStore((state) => state.isPlaying);
const playhead = usePlaybackStore((state) => state.playhead);
const duration = usePlaybackStore((state) => state.duration);
const togglePlaying = usePlaybackStore((state) => state.togglePlaying);
const stopPlayback = usePlaybackStore((state) => state.stop);
const setPlaying = usePlaybackStore((state) => state.setPlaying);
const setPlayhead = usePlaybackStore((state) => state.setPlayhead);
const [selectedKeyframe, setSelectedKeyframe] = useState<SelectedKeyframe | null>(null);
const [isRecording, setIsRecording] = useState(false);
const [exportMenuOpen, setExportMenuOpen] = useState(false);
// 轨道拖拽:生命周期(谁在拖 → 高亮 / 毛玻璃降级)走 React state,一次拖拽只重渲染两次;
// 高频的落点线则完全不经过 React(ref + transform),详见 getTrackDragProps。
const [draggingTrackKey, setDraggingTrackKey] = useState<string | null>(null);
const dropTargetRef = useRef<{ key: string; position: TimelineTrackDropPosition } | null>(null);
const dropIndicatorRef = useRef<HTMLDivElement | null>(null);
/** 当前这一次指针手势(按下 → 超过阈值才算真的开始拖拽,否则当普通点击放行) */
const dragGestureRef = useRef<{
pointerId: number;
sourceKey: string;
active: boolean;
startX: number;
startY: number;
} | null>(null);
/** 拖拽开始时一次性量好的行几何:移动中只查表,绝不在事件里读布局(强制同步布局 = layout thrashing) */
const trackRectsRef = useRef(new Map<string, TrackRowRect>());
const trackLayoutOriginRef = useRef(0);
const panelRef = useRef<HTMLElement | null>(null);
const exportMenuRef = useRef<HTMLDivElement | null>(null);
@@ -232,6 +424,13 @@ export function TimelinePanel() {
setSelectedKeyframe(null);
}
/** 人物轨迹上的「轨迹关键帧」:轨迹点本身就是关键帧,点它即选中对应轨迹点并把指针定位到该点时间 */
function handleSelectTrajectoryKeyframe(objectId: string, pointId: string, time: number) {
setPlaying(false);
setPlayhead(time);
selectTrajectoryPoint(objectId, pointId);
}
function handleClearAll() {
if (timeline.tracks.length === 0 && timeline.cameraTracks.length === 0) return;
if (window.confirm("确定清空所有关键帧吗?此操作可用撤销恢复。")) {
@@ -254,20 +453,236 @@ export function TimelinePanel() {
// 刻度尺本身就是时间指针:点击 / 拖动定位,方向键 / Home / End 微调
const rulerRef = useRef<HTMLDivElement | null>(null);
const rulerRectRef = useRef<DOMRect | null>(null);
const [isScrubbing, setIsScrubbing] = useState(false);
const rulerTicks = getTimelineRulerTicks(duration);
const rulerTicks = useMemo(() => getTimelineRulerTicks(duration), [duration]);
const rulerMinorTicks = useMemo(
() => getTimelineRulerMinorTicks(duration, getTimelineRulerStep(duration)),
[duration]
);
// 刻度节点缓存住:拖拽 / 擦洗导致的重渲染不会再去 diff 上百个刻度元素
const rulerTickNodes = useMemo(
() => (
<>
{rulerMinorTicks.map((tick) => (
<span
className="timeline-ruler-minor-tick"
key={`minor-${tick.value}`}
style={{ left: `${tick.percent}%` }}
/>
))}
{rulerTicks.map((tick) => (
<span className="timeline-ruler-tick" key={tick.value} style={{ left: `${tick.percent}%` }}>
{formatRulerTick(tick.value)}
</span>
))}
</>
),
[rulerMinorTicks, rulerTicks]
);
const playheadFrame = getTimelineFrame(playhead);
const totalFrames = getTimelineFrame(duration);
const playheadPercent = duration === 0 ? 0 : Math.min(100, Math.max(0, (playhead / duration) * 100));
const hasKeyframes = timeline.tracks.length > 0 || timeline.cameraTracks.length > 0;
// 相邻刻度间距(百分比)交给 CSS:轨道底色用它画贯穿网格线,与刻度尺天然对齐
const tickPercent = rulerTicks.length > 1 ? rulerTicks[1].percent : 100;
const gridStyle = { "--timeline-tick-percent": `${tickPercent}%` } as CSSProperties;
// 人物轨道:每个角色一条,内含「动作」与「人物轨迹」两条子轨道
const characterObjects = project.objects.filter((item) => item.kind === "character");
// 轨道顺序:默认「物体轨道 → 相机运镜轨道 → 人物轨道」,用户可以上移 / 下移调整。
// 视觉顺序靠 flex order 实现,DOM 顺序保持稳定(不影响既有查询与无障碍阅读顺序)。
const defaultTrackKeys = [
...timeline.tracks.map((track) => getTimelineTrackKey("object", track.objectId)),
...timeline.cameraTracks.map((track) => getTimelineTrackKey("camera", track.cameraId)),
...characterObjects.map((item) => getTimelineTrackKey("character", item.id)),
];
const orderedTrackKeys = resolveTimelineTrackOrder(defaultTrackKeys, timelineTrackOrder);
const trackOrderIndex = new Map(orderedTrackKeys.map((key, index) => [key, index]));
function getTrackOrder(key: string) {
return trackOrderIndex.get(key) ?? 0;
}
function renderCollapseToggle(trackKey: string, trackLabel: string, trackKindLabel: string, collapsed: boolean) {
return (
<TimelineTrackCollapseToggle
trackLabel={trackLabel}
trackKindLabel={trackKindLabel}
collapsed={collapsed}
onToggle={() => toggleTimelineTrackCollapsed(trackKey)}
/>
);
}
/** 拖拽开始时量一次所有轨道的几何(此后拖拽期间不再读布局) */
function measureTrackRows() {
const panel = panelRef.current;
const body = panel?.querySelector<HTMLElement>(".timeline-grid-body");
trackLayoutOriginRef.current = body ? body.getBoundingClientRect().top : 0;
const rects = new Map<string, { top: number; bottom: number; height: number }>();
panel?.querySelectorAll<HTMLElement>("[data-track-key]").forEach((row) => {
const key = row.dataset.trackKey;
if (!key) return;
const rect = row.getBoundingClientRect();
rects.set(key, { top: rect.top, bottom: rect.bottom, height: rect.height });
});
trackRectsRef.current = rects;
}
/** 把落点线移到目标行的上 / 下边缘:只改 transform(合成层位移),不触发行重绘 */
function showDropIndicator(rowKey: string, position: TimelineTrackDropPosition) {
const indicator = dropIndicatorRef.current;
const rect = trackRectsRef.current.get(rowKey);
if (!indicator || !rect) return;
const y = (position === "before" ? rect.top : rect.bottom) - trackLayoutOriginRef.current;
indicator.style.transform = `translateY(${y}px)`;
indicator.classList.add("is-visible");
}
function hideDropIndicator() {
dropIndicatorRef.current?.classList.remove("is-visible");
}
/** 收尾:落点线、拖拽状态、拖拽期视觉降级一并还原 */
function clearTrackDragStyles() {
dropTargetRef.current = null;
trackRectsRef.current = new Map();
dragGestureRef.current = null;
hideDropIndicator();
setDraggingTrackKey(null);
document.body.classList.remove("is-timeline-dragging");
}
/** 行内真正的按钮 / 输入框自己处理交互,不抢来做轨道拖拽(关键帧等 role=button 的块仍可当拖拽把手) */
function isTrackDragBlockedTarget(target: EventTarget | null) {
return target instanceof Element && Boolean(target.closest("button, input, textarea, select, a"));
}
/** 由指针位置找落点行:优先命中的行,都没命中就取最近的一行(拖到末尾之外也能落到最后一行) */
function findDropTargetAt(clientY: number) {
let hit: { key: string; rect: TrackRowRect } | null = null;
let nearest: { key: string; rect: TrackRowRect } | null = null;
let nearestDistance = Number.POSITIVE_INFINITY;
for (const [key, rect] of trackRectsRef.current) {
if (clientY >= rect.top && clientY <= rect.bottom) {
hit = { key, rect };
break;
}
const distance = Math.min(Math.abs(clientY - rect.top), Math.abs(clientY - rect.bottom));
if (distance < nearestDistance) {
nearestDistance = distance;
nearest = { key, rect };
}
}
const target = hit ?? nearest;
if (!target) return null;
const { key, rect } = target;
const position: TimelineTrackDropPosition = clientY - rect.top < rect.height / 2 ? "before" : "after";
return { key, position };
}
function handleTrackPointerDown(event: ReactPointerEvent<HTMLDivElement>, trackKey: string) {
if (event.button !== 0) return;
if (isTrackDragBlockedTarget(event.target)) return;
dragGestureRef.current = {
pointerId: event.pointerId,
sourceKey: trackKey,
active: false,
startX: event.clientX,
startY: event.clientY,
};
}
function handleTrackPointerMove(event: ReactPointerEvent<HTMLDivElement>) {
const gesture = dragGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
if (!gesture.active) {
// 没超过阈值就是在普通点击(选关键帧等),不能抢走交互
const moved = Math.abs(event.clientY - gesture.startY) + Math.abs(event.clientX - gesture.startX);
if (moved < TRACK_DRAG_THRESHOLD_PX) return;
gesture.active = true;
// 量一次行几何,之后拖拽期间只查表;同时开启拖拽期视觉降级
measureTrackRows();
dropTargetRef.current = null;
setDraggingTrackKey(gesture.sourceKey);
document.body.classList.add("is-timeline-dragging");
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// jsdom 等环境不支持 pointer capture,忽略后仍能继续拖动
}
}
event.preventDefault();
const target = findDropTargetAt(event.clientY);
if (!target) return;
if (dropTargetRef.current?.key === target.key && dropTargetRef.current.position === target.position) return;
dropTargetRef.current = target;
showDropIndicator(target.key, target.position);
}
function handleTrackPointerUp(event: ReactPointerEvent<HTMLDivElement>) {
const gesture = dragGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
const { active, sourceKey } = gesture;
const target = dropTargetRef.current;
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// 同上,忽略不支持的环境
}
clearTrackDragStyles();
if (!active || !target) return;
const nextOrder = reorderTimelineTrackKeys(orderedTrackKeys, sourceKey, target.key, target.position);
if (nextOrder === orderedTrackKeys) return;
setTimelineTrackOrder(nextOrder);
}
/**
* 整行作为一个拖拽单元:按住拖动,落到目标轨道上方 / 下方即完成重排。
*
* 这里刻意**不用 HTML5 拖拽(draggable + dragstart/dragover/drop)**:原生拖拽会把 drag image
* 快照、dragover 风暴、命中测试等一整套开销交给浏览器,我们既控不了频率也控不了代价,
* 在「全屏 WebGL 画布 + 毛玻璃悬浮面板」这种页面里就是拖一次卡一次。
* 改用 pointer events 自己实现后,每次指针移动只做:查一次缓存表 + 可能移动一条 transform 线。
*/
function getTrackDragProps(trackKey: string) {
return {
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => handleTrackPointerDown(event, trackKey),
onPointerMove: handleTrackPointerMove,
onPointerUp: handleTrackPointerUp,
onPointerCancel: handleTrackPointerUp,
};
}
function getTimeFromPointer(clientX: number) {
const element = rulerRef.current;
if (!element) return null;
const rect = element.getBoundingClientRect();
// 拖动播放头时每次 pointermove 都读 rect 会强制同步布局(拖动最常见的卡顿来源),
// 一次拖动内刻度尺不会移动,缓存一次即可。
const rect = rulerRectRef.current ?? element.getBoundingClientRect();
rulerRectRef.current = rect;
if (rect.width <= 0) return null;
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
@@ -281,6 +696,8 @@ export function TimelinePanel() {
}
function handleRulerPointerDown(event: ReactPointerEvent<HTMLDivElement>) {
// 每次开始拖动重新量一次刻度尺(窗口可能刚被缩放)
rulerRectRef.current = null;
const time = getTimeFromPointer(event.clientX);
if (time === null) return;
@@ -306,6 +723,7 @@ export function TimelinePanel() {
if (!isScrubbing) return;
setIsScrubbing(false);
rulerRectRef.current = null;
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
@@ -339,14 +757,27 @@ export function TimelinePanel() {
: "请先选择一个对象或相机";
return (
<section className="timeline-panel" aria-label="时间轴" ref={panelRef}>
<section
className={`timeline-panel${draggingTrackKey ? " is-track-dragging" : ""}`}
aria-label="时间轴"
ref={panelRef}
>
<div className="timeline-toolbar">
<div className="timeline-toolbar-group">
<span className="timeline-time-readout">
{formatTime(playhead)} / {formatTime(duration)}
{/* 读数 = 当前帧号 / 当前时间;总时长单独放在右边的时长输入框里 */}
<span
className="timeline-time-readout"
title={`当前第 ${playheadFrame} 帧 / 共 ${totalFrames} 帧(${TIMELINE_FPS}fps)`}
>
<span className="timeline-time-readout-frame">F{playheadFrame}</span>
<span className="timeline-time-readout-divider" aria-hidden="true">
/
</span>
<span className="timeline-time-readout-clock">{formatReadoutTime(playhead)}</span>
</span>
<label className="timeline-duration-chip" title="时间轴总时长(秒)">
<MoveHorizontal aria-hidden="true" size={14} strokeWidth={1.8} />
<span className="timeline-duration-caption">总时长</span>
<input
className="timeline-duration-input"
aria-label="时间轴总时长"
@@ -384,6 +815,15 @@ export function TimelinePanel() {
<Play aria-hidden="true" size={15} strokeWidth={1.8} />
)}
</button>
<button
className="timeline-icon-button"
type="button"
aria-label="停止"
title="停止播放并回到起点"
onClick={stopPlayback}
>
<Square aria-hidden="true" size={13} strokeWidth={2} />
</button>
<button
className="timeline-icon-button"
type="button"
@@ -505,11 +945,8 @@ export function TimelinePanel() {
onPointerUp={handleRulerPointerEnd}
onPointerCancel={handleRulerPointerEnd}
>
{rulerTicks.map((tick) => (
<span className="timeline-ruler-tick" key={tick.value} style={{ left: `${tick.percent}%` }}>
{formatRulerTick(tick.value)}
</span>
))}
{/* 0.1s 细刻度先画,主刻度的文字与竖线压在上面 */}
{rulerTickNodes}
</div>
</div>
@@ -518,46 +955,62 @@ export function TimelinePanel() {
const obj = project.objects.find((o) => o.id === track.objectId);
const KindIcon = (obj ? OBJECT_KIND_ICONS[obj.kind] : undefined) ?? Box;
const trackName = obj?.name ?? track.objectId;
const trackKey = getTimelineTrackKey("object", track.objectId);
const collapsed = timelineCollapsedTracks.includes(trackKey);
const dragProps = getTrackDragProps(trackKey);
return (
<div className="timeline-track" key={track.objectId}>
<div className="timeline-track-label" title={trackName}>
<div
className={`timeline-track${collapsed ? " is-collapsed" : ""}${
draggingTrackKey === trackKey ? " is-dragging" : ""
}`}
key={track.objectId}
data-track-key={trackKey}
style={{ order: getTrackOrder(trackKey) }}
{...dragProps}
>
<div className="timeline-track-label" title={`${trackName} · 拖动整行可调整轨道顺序`}>
{renderCollapseToggle(trackKey, trackName, "物体轨道", collapsed)}
<KindIcon aria-hidden="true" className="timeline-track-icon" size={14} strokeWidth={1.8} />
<span className="timeline-track-name">{trackName}</span>
<span className="timeline-track-count">{track.keyframes.length}</span>
</div>
<div className="timeline-track-lane">
{track.keyframes.map((kf) => {
const leftPercent = duration === 0 ? 0 : (kf.time / duration) * 100;
const isSelected =
selectedKeyframe?.kind === "object" &&
selectedKeyframe.targetId === track.objectId &&
selectedKeyframe.keyframeId === kf.id;
return (
<div
key={kf.id}
className={`timeline-keyframe${isSelected ? " is-selected" : ""}`}
style={{ left: `${leftPercent}%` }}
role="button"
tabIndex={0}
aria-label={`关键帧 ${formatTime(kf.time)}`}
onClick={() => handleSelectObjectKeyframe(track.objectId, kf.id, kf.time, kf.value)}
>
<span className="timeline-keyframe-dot" aria-hidden="true" />
<button
type="button"
className="timeline-keyframe-delete"
aria-label={`删除关键帧 ${formatTime(kf.time)}`}
onClick={(event) => {
event.stopPropagation();
removeKeyframe(track.objectId, kf.id);
}}
{collapsed ? (
<span className="timeline-track-summary">{track.keyframes.length} 个关键帧</span>
) : (
track.keyframes.map((kf) => {
const leftPercent = duration === 0 ? 0 : (kf.time / duration) * 100;
const isSelected =
selectedKeyframe?.kind === "object" &&
selectedKeyframe.targetId === track.objectId &&
selectedKeyframe.keyframeId === kf.id;
return (
<div
key={kf.id}
className={`timeline-keyframe${isSelected ? " is-selected" : ""}`}
style={{ left: `${leftPercent}%` }}
role="button"
tabIndex={0}
aria-label={`关键帧 ${formatTime(kf.time)}`}
onClick={() => handleSelectObjectKeyframe(track.objectId, kf.id, kf.time, kf.value)}
>
×
</button>
</div>
);
})}
<span className="timeline-keyframe-dot" aria-hidden="true" />
<button
type="button"
className="timeline-keyframe-delete"
aria-label={`删除关键帧 ${formatTime(kf.time)}`}
onClick={(event) => {
event.stopPropagation();
removeKeyframe(track.objectId, kf.id);
}}
>
×
</button>
</div>
);
})
)}
</div>
</div>
);
@@ -566,50 +1019,170 @@ export function TimelinePanel() {
{timeline.cameraTracks.map((track) => {
const shot = project.cameras.find((c) => c.id === track.cameraId);
const trackName = shot?.name ?? track.cameraId;
const trackKey = getTimelineTrackKey("camera", track.cameraId);
const collapsed = timelineCollapsedTracks.includes(trackKey);
const dragProps = getTrackDragProps(trackKey);
return (
<div className="timeline-track timeline-track-camera" key={track.cameraId}>
<div className="timeline-track-label" title={trackName}>
<div
className={`timeline-track timeline-track-camera${collapsed ? " is-collapsed" : ""}${
draggingTrackKey === trackKey ? " is-dragging" : ""
}`}
key={track.cameraId}
data-track-key={trackKey}
style={{ order: getTrackOrder(trackKey) }}
{...dragProps}
>
<div className="timeline-track-label" title={`${trackName} · 拖动整行可调整轨道顺序`}>
{renderCollapseToggle(trackKey, trackName, "运镜轨道", collapsed)}
<Video aria-hidden="true" className="timeline-track-icon" size={14} strokeWidth={1.8} />
<span className="timeline-track-name">{trackName}</span>
<span className="timeline-track-count">{track.keyframes.length}</span>
</div>
<div className="timeline-track-lane">
{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 (
<div
key={kf.id}
className={`timeline-keyframe timeline-keyframe-camera${isSelected ? " is-selected" : ""}`}
style={{ left: `${leftPercent}%` }}
role="button"
tabIndex={0}
aria-label={`运镜关键帧 ${formatTime(kf.time)}`}
onClick={() => handleSelectCameraKeyframe(track.cameraId, kf.id, kf.time, kf.value)}
>
<span className="timeline-keyframe-dot" aria-hidden="true" />
<button
type="button"
className="timeline-keyframe-delete"
aria-label={`删除运镜关键帧 ${formatTime(kf.time)}`}
onClick={(event) => {
event.stopPropagation();
removeCameraKeyframe(track.cameraId, kf.id);
}}
{collapsed ? (
<span className="timeline-track-summary">{track.keyframes.length} 个运镜关键帧</span>
) : (
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 (
<div
key={kf.id}
className={`timeline-keyframe timeline-keyframe-camera${isSelected ? " is-selected" : ""}`}
style={{ left: `${leftPercent}%` }}
role="button"
tabIndex={0}
aria-label={`运镜关键帧 ${formatTime(kf.time)}`}
onClick={() => handleSelectCameraKeyframe(track.cameraId, kf.id, kf.time, kf.value)}
>
×
</button>
</div>
);
})}
<span className="timeline-keyframe-dot" aria-hidden="true" />
<button
type="button"
className="timeline-keyframe-delete"
aria-label={`删除运镜关键帧 ${formatTime(kf.time)}`}
onClick={(event) => {
event.stopPropagation();
removeCameraKeyframe(track.cameraId, kf.id);
}}
>
×
</button>
</div>
);
})
)}
</div>
</div>
);
})}
{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 (
<div
className={`timeline-character-track${collapsed ? " is-collapsed" : ""}${
draggingTrackKey === trackKey ? " is-dragging" : ""
}`}
key={`character-track-${character.id}`}
data-track-key={trackKey}
style={{ order: getTrackOrder(trackKey) }}
{...dragProps}
>
{/* 分组抬头:折叠开关 + 角色名 + 轨迹点数(整组可拖动排序) */}
<div className="timeline-track timeline-character-head">
<div className="timeline-track-label" title={`${character.name} · 拖动整行可调整轨道顺序`}>
{renderCollapseToggle(trackKey, character.name, "角色轨道", collapsed)}
<PersonStanding aria-hidden="true" className="timeline-track-icon" size={14} strokeWidth={1.8} />
<span className="timeline-track-name">{character.name}</span>
<span className="timeline-track-count">{trajectoryPoints.length}</span>
</div>
{/* 折叠后抬头行里给一句摘要,避免整条轨道变成空白 */}
<div className="timeline-track-lane timeline-character-head-lane">
{collapsed ? (
<span className="timeline-track-summary">
{motionLabel} · {trajectoryPoints.length} 个轨迹点
</span>
) : null}
</div>
</div>
{collapsed ? null : (
<>
{/* 动作轨道:角色当前的基础动作(待机 / 行走 / 跑步) */}
<div className="timeline-subtrack" title={motionTitle}>
<div className="timeline-subtrack-label">动作</div>
<div className="timeline-track-lane timeline-motion-lane">
<span className={`timeline-motion-chip${motionDisabled ? " is-disabled" : ""}`}>
{motionLabel}
{motionDisabled ? "(已关闭)" : ""}
</span>
</div>
</div>
{/* 人物轨迹:轨迹点即关键帧,0s 从角色起始位置出发,末点落在时间轴结束时刻 */}
<div
className="timeline-subtrack"
title="人物轨迹:轨迹点即关键帧,0s 从角色起始位置出发,最后一个轨迹点落在时间轴结束时刻"
>
<div className="timeline-subtrack-label">轨迹</div>
<div className="timeline-track-lane">
{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 (
<div
key={point.id}
className={`timeline-keyframe timeline-keyframe-trajectory${isSelected ? " is-selected" : ""}`}
style={{ left: `${leftPercent}%` }}
role="button"
tabIndex={0}
aria-label={`轨迹关键帧 ${formatTime(pointTime)}`}
title={`第 ${index + 1} 个轨迹点 · ${formatTime(pointTime)}`}
onClick={() => handleSelectTrajectoryKeyframe(character.id, point.id, pointTime)}
>
<span className="timeline-keyframe-dot" aria-hidden="true" />
<button
type="button"
className="timeline-keyframe-delete"
aria-label={`删除轨迹关键帧 ${formatTime(pointTime)}`}
onClick={(event) => {
event.stopPropagation();
removeTrajectoryPoint(character.id, point.id);
}}
>
×
</button>
</div>
);
})}
</div>
</div>
</>
)}
</div>
);
})}
{/* 拖拽落点线:绝对定位在轨道区上,靠 transform 移动(详见 showDropIndicator) */}
<div className="timeline-drop-indicator" ref={dropIndicatorRef} aria-hidden="true" />
</div>
<div className="timeline-playhead-layer" aria-hidden="true">

View File

@@ -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);

View File

@@ -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<PlaybackState>((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 };
});

View File

@@ -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();

View File

@@ -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]);

View File

@@ -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["transform"]>
): 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();
});

View File

@@ -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),
};
}

View File

@@ -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");

View File

@@ -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<DirectorStore>((set, get) => {
createInitialDirectorState({ includePersistedLocalAssets: true, includePersistedScene: true })
);
/** 拖拽(撤销批次)期间的持久化防抖间隔:连续拖拽时完全不序列化,停手 150ms 后落盘一次 */
const UNDO_BATCH_PERSIST_DEBOUNCE_MS = 150;
let undoBatchPersistTimer: ReturnType<typeof setTimeout> | 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<DirectorStore>((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<DirectorStore>((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<DirectorStore>((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<DirectorStore>((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<DirectorStore>((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,

View File

@@ -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 挂载 <Canvas> 时会构造已弃用的 THREE.Clock
installThreeConsoleFilter();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>

View File

@@ -3495,8 +3495,9 @@ textarea:focus-visible {
height: 0; /* 覆盖 .canvas-frame 自带的 height:100%,让 flex 分配主导,给底部时间轴留位置 */
}
.timeline-panel {
/* 左侧标签列宽度:刻度尺、网格线、播放头都靠它对齐 */
--timeline-gutter-width: 132px;
/* 左侧标签列宽度:刻度尺、网格线、播放头都靠它对齐。
比原来宽一些,给每行的「上移 / 下移 / 折叠」按钮留出位置 */
--timeline-gutter-width: 166px;
--timeline-ruler-height: 22px;
--timeline-row-height: 28px;
display: flex;
@@ -3504,6 +3505,9 @@ textarea:focus-visible {
gap: 10px;
padding: 8px 12px;
flex-shrink: 0;
/* 时间轴是操作面板:禁掉文本选中,避免拖动轨道 / 关键帧时选中一大片文字(选中高亮全量重绘,拖拽时会很卡) */
user-select: none;
-webkit-user-select: none;
/* 高度自适应内容:轨道多了才长高,到上限后在面板内部滚动 */
height: auto;
max-height: 420px;
@@ -3543,12 +3547,22 @@ textarea:focus-visible {
background: rgb(var(--border-rgb) / 0.55);
}
.timeline-time-readout {
display: inline-flex;
align-items: baseline;
gap: 2px;
color: rgb(var(--text-rgb));
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* 帧号用强调色,和时间读数区分开(F30/1s) */
.timeline-time-readout-frame {
color: rgb(var(--accent-rgb));
}
.timeline-time-readout-divider {
color: rgb(var(--text-dim-rgb));
}
.timeline-duration-chip {
display: inline-flex;
align-items: center;
@@ -3564,6 +3578,12 @@ textarea:focus-visible {
font-variant-numeric: tabular-nums;
cursor: ew-resize;
}
/* 「总时长」文案:和读数分开展示,让总时间一眼可辨 */
.timeline-duration-caption {
color: rgb(var(--text-dim-rgb));
font-size: 11px;
font-weight: 600;
}
.timeline-duration-chip:focus-within {
border-color: rgb(var(--accent-rgb) / 0.6);
}
@@ -3575,6 +3595,9 @@ textarea:focus-visible {
min-width: 0;
border: 0;
padding: 0;
/* 时长输入框仍然要能选中文本 */
user-select: text;
-webkit-user-select: text;
color: rgb(var(--text-rgb));
background: transparent;
font: inherit;
@@ -3810,9 +3833,21 @@ textarea:focus-visible {
height: 5px;
background: rgb(var(--text-dim-rgb) / 0.55);
}
/* 主刻度之间的 0.1s 细刻度:比秒刻度更矮(3px vs 5px)、更淡(0.22 vs 0.55) */
.timeline-ruler-minor-tick {
position: absolute;
bottom: 0;
width: 1px;
height: 3px;
background: rgb(var(--text-dim-rgb) / 0.22);
pointer-events: none;
}
.timeline-grid-body {
position: relative;
z-index: 1;
/* 纵向 flex:每行用 order 决定显示顺序,实现「轨道上下移动」而不改动 DOM 顺序 */
display: flex;
flex-direction: column;
}
.timeline-track {
display: grid;
@@ -3856,6 +3891,111 @@ textarea:focus-visible {
font-variant-numeric: tabular-nums;
line-height: 15px;
}
/* 折叠开关:平时半透明,鼠标移到该行时提亮 */
.timeline-track-icon-button {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: rgb(var(--text-dim-rgb));
cursor: pointer;
opacity: 0.5;
transition:
opacity 0.12s ease,
color 0.12s ease,
background-color 0.12s ease;
}
.timeline-track-icon-button:hover:not(:disabled),
.timeline-track-icon-button:focus-visible {
background: rgb(var(--text-rgb) / 0.1);
color: rgb(var(--text-rgb));
opacity: 1;
}
.timeline-track:hover .timeline-track-icon-button,
.timeline-subtrack:hover .timeline-track-icon-button {
opacity: 0.85;
}
/* 折叠开关在轨道行最左侧(类似树的折叠箭头),始终清晰可见 */
.timeline-track-collapse-button {
opacity: 0.75;
}
/* ---- 轨道拖拽排序:整行都是拖拽单元 ---- */
.timeline-track,
.timeline-character-track {
cursor: grab;
}
.timeline-track:active,
.timeline-character-track:active {
cursor: grabbing;
}
.timeline-track.is-dragging,
.timeline-character-track.is-dragging {
opacity: 0.42;
}
/* 拖拽落点线:独立的一条线,只靠 transform 上下移动。
不用给整行加 box-shadow —— 那会触发行重绘,而面板有 backdrop-filter,重绘代价极高。 */
.timeline-drop-indicator {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 4;
height: 2px;
margin-top: -1px;
background: rgb(var(--accent-rgb));
box-shadow: 0 0 6px rgb(var(--accent-rgb) / 0.6);
opacity: 0;
pointer-events: none;
transform: translateY(0);
transition:
transform 0.08s ease-out,
opacity 0.08s ease-out;
}
.timeline-drop-indicator.is-visible {
opacity: 1;
}
/* 拖拽期间把落点线提升为独立合成层,移动它不会让面板重绘 */
.timeline-panel.is-track-dragging .timeline-drop-indicator {
will-change: transform;
}
/* 拖拽轨道时临时关掉毛玻璃:面板背后是全屏 WebGL 画布,backdrop-filter 的 32px
高斯模糊会随拖拽期间的每一次重绘重算,这是「拖动轨道卡死」的主因。
背景本身是 0.9 不透明的面板色,关掉后视觉差异很小。 */
.timeline-panel.is-track-dragging {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* 拖拽期间把页面上所有毛玻璃一并停掉:视口画幅遮罩、快捷工具条、侧栏卡片都带
backdrop-filter,只要它们在拖拽时被重绘,就会整块重算背景模糊。
只在拖拽进行中的那几秒生效,松手立即恢复。 */
body.is-timeline-dragging * {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* 折叠后的轨道:车道内容换成一句摘要,网格线淡化表示「这里被折起来了」 */
.timeline-track.is-collapsed .timeline-track-lane {
background-image: none;
}
.timeline-track-summary {
position: absolute;
top: 50%;
left: 8px;
padding: 0 8px;
border: 1px solid rgb(var(--border-rgb) / 0.45);
border-radius: 999px;
color: rgb(var(--text-dim-rgb));
background: rgb(var(--panel-rgb) / 0.6);
font-size: 11px;
line-height: 15px;
white-space: nowrap;
transform: translateY(-50%);
}
.timeline-track-lane {
position: relative;
height: var(--timeline-row-height);
@@ -3869,6 +4009,62 @@ textarea:focus-visible {
transparent var(--timeline-tick-percent, 12.5%)
);
}
/* 人物轨道:一条角色轨道内部再分「动作」「人物轨迹」两条子轨道。
子轨道的左标签列宽度必须和 --timeline-gutter-width 一致,否则轨迹关键帧会与播放头错位。 */
.timeline-character-track {
background: rgb(var(--accent-rgb) / 0.05);
}
.timeline-subtrack {
display: grid;
grid-template-columns: var(--timeline-gutter-width) minmax(0, 1fr);
align-items: center;
}
.timeline-subtrack:hover {
background: rgb(var(--text-rgb) / 0.03);
}
.timeline-subtrack-label {
display: flex;
align-items: center;
height: var(--timeline-row-height);
min-width: 0;
padding: 0 10px 0 22px;
border-right: 1px solid rgb(var(--border-rgb) / 0.35);
border-bottom: 1px solid rgb(var(--border-rgb) / 0.35);
color: rgb(var(--text-dim-rgb));
font-size: 11px;
font-weight: 600;
}
/* 分组抬头行:展开时轨道留空只作分组抬头,折叠时显示一行摘要 */
.timeline-character-head-lane {
display: flex;
align-items: center;
padding: 0 8px;
border-bottom-color: rgb(var(--border-rgb) / 0.22);
}
.timeline-character-track.is-collapsed {
background: rgb(var(--accent-rgb) / 0.03);
}
.timeline-motion-lane {
display: flex;
align-items: center;
padding: 0 8px;
}
.timeline-motion-chip {
display: inline-flex;
align-items: center;
height: 16px;
padding: 0 8px;
border: 1px solid rgb(var(--border-rgb) / 0.55);
border-radius: 999px;
color: rgb(var(--text-muted-rgb));
background: rgb(var(--panel-rgb) / 0.6);
font-size: 11px;
line-height: 1;
white-space: nowrap;
}
.timeline-motion-chip.is-disabled {
opacity: 0.55;
}
.timeline-playhead-layer {
position: absolute;
top: 0;
@@ -3925,6 +4121,10 @@ textarea:focus-visible {
.timeline-keyframe-camera .timeline-keyframe-dot {
background: #12b886;
}
/* 人物轨迹关键帧:用紫色和物体 / 运镜关键帧区分(选中态仍走下面的 .is-selected 规则) */
.timeline-keyframe-trajectory .timeline-keyframe-dot {
background: #a06ef5;
}
.timeline-keyframe.is-selected .timeline-keyframe-dot {
background: #f2a900;
box-shadow:

44
src/threeConsole.test.ts Normal file
View File

@@ -0,0 +1,44 @@
import { Clock, getConsoleFunction, log as threeLog, setConsoleFunction, warn as threeWarn } from "three";
import { afterEach, expect, it, vi } from "vitest";
import { installThreeConsoleFilter, isSuppressedThreeMessage } from "./threeConsole";
// 安装前的 three 控制台处理函数(默认是 null → 走原生 console)
const originalConsoleFunction = getConsoleFunction();
afterEach(() => {
setConsoleFunction(originalConsoleFunction);
vi.restoreAllMocks();
});
it("suppresses the deprecated THREE.Clock notice raised by @react-three/fiber", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
installThreeConsoleFilter();
// R3F v8 就是通过 new THREE.Clock() 触发这条弃用告警的
new Clock();
expect(warnSpy).not.toHaveBeenCalled();
});
it("keeps forwarding every other three.js log, warning and error", () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
installThreeConsoleFilter();
// three 的 log / warn / error 会自动加 "THREE." 前缀
threeLog("Hello: still visible");
threeWarn("SomethingElse: still visible");
expect(logSpy).toHaveBeenCalledWith("THREE.Hello: still visible");
expect(warnSpy).toHaveBeenCalledWith("THREE.SomethingElse: still visible");
expect(errorSpy).not.toHaveBeenCalled();
});
it("only treats the deprecated Clock warning as noise", () => {
expect(
isSuppressedThreeMessage("warn", "THREE.Clock: This module has been deprecated. Please use THREE.Timer instead.")
).toBe(true);
expect(isSuppressedThreeMessage("warn", "THREE.WebGLRenderer: context lost")).toBe(false);
expect(isSuppressedThreeMessage("error", "THREE.Clock: This module has been deprecated")).toBe(false);
});

34
src/threeConsole.ts Normal file
View File

@@ -0,0 +1,34 @@
import { setConsoleFunction } from "three";
/**
* three.js 从 r183 起弃用了 THREE.Clock(建议改用 THREE.Timer),而 @react-three/fiber v8
* 内部仍在 `new THREE.Clock()`(挂在 R3F store 上做每帧 delta),于是每次挂载 <Canvas> 都会
* 在控制台打一条弃用告警:
* THREE.Clock: This module has been deprecated. Please use THREE.Timer instead.
*
* 这条告警来自依赖内部,我们无法直接改写:R3F v8 用到的时钟成员(elapsedTime / oldTime /
* start / stop)与 Timer 的接口并不兼容,真正移除这个用法需要升级到 R3F v9(依赖 React 19)。
* 因此这里用 three 官方提供的 setConsoleFunction 钩子,只过滤这条已知的依赖弃用告警,
* 其余 three.js 的 log / warn / error 全部原样转发到控制台。
*/
const SUPPRESSED_THREE_WARNING_PREFIXES = ["THREE.Clock: This module has been deprecated"];
/** 这条 three.js 控制台消息是否属于我们要屏蔽的已知噪音 */
export function isSuppressedThreeMessage(type: string, message: string): boolean {
if (type !== "warn") return false;
return SUPPRESSED_THREE_WARNING_PREFIXES.some((prefix) => message.startsWith(prefix));
}
/**
* 安装 three.js 控制台过滤器。
* 需要在渲染(挂载 <Canvas>)之前调用,因为 R3F 是在创建 root 时才构造那个 Clock。
*/
export function installThreeConsoleFilter() {
setConsoleFunction((type, message, ...params) => {
if (isSuppressedThreeMessage(type, message)) return;
if (type === "error") console.error(message, ...params);
else if (type === "warn") console.warn(message, ...params);
else console.log(message, ...params);
});
}