feat(timeline): 支持角色轨迹关键帧与轨道拖拽排序
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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" ? "上帝视角" : "机位视角";
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
|
||||
147
src/editor/schema/trajectoryMotion.test.ts
Normal file
147
src/editor/schema/trajectoryMotion.test.ts
Normal 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();
|
||||
});
|
||||
123
src/editor/schema/trajectoryMotion.ts
Normal file
123
src/editor/schema/trajectoryMotion.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
44
src/threeConsole.test.ts
Normal 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
34
src/threeConsole.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user