item.kind === "character")!;
}
+/** 加一个非角色物体(几何体)用于「物体轨道」相关测试,避免角色被渲染成两条轨道 */
+function addPropObject() {
+ useDirectorStore.getState().addGeometryPrimitive("box");
+ const objects = useDirectorStore.getState().project.objects;
+ return objects[objects.length - 1];
+}
+
/**
* jsdom 不做真实布局(所有 getBoundingClientRect 都是 0):
* - 轨道拖拽排序要靠行几何算落点 → 按 data-track-key 造一份给定布局;
@@ -205,7 +212,7 @@ it("disables add-keyframe button when no object is selected", () => {
});
it("adds an object keyframe for the selected object at the playhead", () => {
- const objectId = useDirectorStore.getState().project.objects[0].id;
+ const objectId = addPropObject().id;
useDirectorStore.getState().selectObject(objectId);
usePlaybackStore.getState().setPlayhead(2);
@@ -232,7 +239,7 @@ it("adds a camera keyframe when a manual camera is selected", () => {
});
it("deletes a selected keyframe", () => {
- const objectId = useDirectorStore.getState().project.objects[0].id;
+ const objectId = addPropObject().id;
useDirectorStore.getState().selectObject(objectId);
usePlaybackStore.getState().setPlayhead(1);
render(
);
@@ -245,20 +252,21 @@ it("deletes a selected keyframe", () => {
expect(useDirectorStore.getState().project.timeline.tracks).toEqual([]);
});
-it("deletes a keyframe via the per-keyframe × button", () => {
- const objectId = useDirectorStore.getState().project.objects[0].id;
+it("deletes a keyframe after selecting it", () => {
+ const objectId = addPropObject().id;
useDirectorStore.getState().selectObject(objectId);
usePlaybackStore.getState().setPlayhead(1);
render(
);
fireEvent.click(screen.getByRole("button", { name: /在指针处打关键帧/ }));
- fireEvent.click(screen.getByRole("button", { name: "删除关键帧 1.0s" }));
+ fireEvent.click(screen.getByRole("button", { name: "关键帧 1.0s" }));
+ fireEvent.click(screen.getByRole("button", { name: "删除选中关键帧" }));
expect(useDirectorStore.getState().project.timeline.tracks).toEqual([]);
});
it("clears all keyframes via the clear-all button", () => {
- const objectId = useDirectorStore.getState().project.objects[0].id;
+ const objectId = addPropObject().id;
useDirectorStore.getState().selectObject(objectId);
render(
);
fireEvent.click(screen.getByRole("button", { name: /在指针处打关键帧/ }));
@@ -317,6 +325,35 @@ it("adds a character track with motion and trajectory sub-tracks", () => {
expect(screen.getByRole("button", { name: "轨迹关键帧 5.0s" })).toBeInTheDocument();
});
+it("renders a single track for a character, never a duplicate object track", () => {
+ const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!;
+ // 即便角色带有一条 transform 关键帧轨道(历史数据 / 误加),也不应再渲染成「物体轨道」
+ useDirectorStore.getState().addKeyframe(character.id, 1, {
+ position: [0, 0, 0],
+ rotation: [0, 0, 0],
+ scale: [1, 1, 1],
+ });
+ useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
+
+ const { container } = render(
);
+
+ // 只有一条「角色轨道」,且不存在以该角色 id 命名的「物体轨道」
+ expect(container.querySelectorAll(".timeline-character-track")).toHaveLength(1);
+ expect(container.querySelector(`.timeline-track[data-track-key="object:${character.id}"]`)).toBeNull();
+});
+
+it("does not create a transform track when adding a keyframe to a selected character", () => {
+ const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!;
+ useDirectorStore.getState().selectObject(character.id);
+ usePlaybackStore.getState().setPlayhead(1);
+
+ render(
);
+ fireEvent.click(screen.getByRole("button", { name: /在指针处打关键帧/ }));
+
+ const track = useDirectorStore.getState().project.timeline.tracks.find((t) => t.objectId === character.id);
+ expect(track).toBeUndefined();
+});
+
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");
@@ -344,13 +381,71 @@ it("selects a trajectory point and seeks the playhead when its timeline keyframe
});
});
-it("deletes a trajectory point from its timeline keyframe", () => {
+it("clears the trajectory keyframe selection when clicking the track background", () => {
+ 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(
);
+ const keyframe = screen.getByRole("button", { name: "轨迹关键帧 2.5s" });
+ fireEvent.click(keyframe);
+ expect(keyframe).toHaveClass("is-selected");
+ expect(useDirectorStore.getState().selectedTrajectoryPoint).not.toBeNull();
+
+ // 点轨道空白处:选中态取消(角色本身的选择不受影响)
+ fireEvent.pointerDown(container.querySelector
(".timeline-grid-body")!, {
+ button: 0,
+ pointerId: 1,
+ });
+
+ expect(useDirectorStore.getState().selectedTrajectoryPoint).toBeNull();
+ expect(screen.getByRole("button", { name: "轨迹关键帧 2.5s" })).not.toHaveClass("is-selected");
+ expect(useDirectorStore.getState().selectedObjectId).toBe(character.id);
+});
+
+it("moves focus off the trajectory keyframe when clicking elsewhere", () => {
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();
- fireEvent.click(screen.getByRole("button", { name: "删除轨迹关键帧 5.0s" }));
+ const keyframe = screen.getByRole("button", { name: "轨迹关键帧 2.5s" });
+ fireEvent.click(keyframe);
+ keyframe.focus();
+ expect(document.activeElement).toBe(keyframe);
+
+ fireEvent.pointerDown(document.body, { button: 0, pointerId: 1 });
+
+ // 焦点从已取消选中的关键帧上移走,焦点框不再留在它身上
+ expect(document.activeElement).not.toBe(keyframe);
+});
+
+it("keeps the trajectory keyframe selected when clicking the keyframe itself", () => {
+ 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();
+ const keyframe = screen.getByRole("button", { name: "轨迹关键帧 2.5s" });
+ fireEvent.click(keyframe);
+
+ // 再点自己:那是选中 / 拖它改时间,不该被当成「点到别处」清掉
+ fireEvent.pointerDown(keyframe, { button: 0, pointerId: 1 });
+
+ expect(useDirectorStore.getState().selectedTrajectoryPoint).not.toBeNull();
+ expect(keyframe).toHaveClass("is-selected");
+});
+
+it("deletes a selected trajectory point with the Delete key", () => {
+ 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();
+ // 轨迹点没有独立删除按钮:选中后按 Delete 删除
+ const lastKeyframe = screen.getByRole("button", { name: "轨迹关键帧 5.0s" });
+ fireEvent.click(lastKeyframe);
+ fireEvent.keyDown(lastKeyframe, { key: "Delete" });
const points = useDirectorStore.getState().project.objects.find((item) => item.id === character.id)!.trajectoryPoints;
expect(points).toHaveLength(1);
@@ -383,8 +478,11 @@ it("keeps the track order untouched when a drop would not change it", () => {
/** 渲染一个「物体轨道 + 角色轨道」的时间轴,并给两行造好纵向布局 */
function renderTwoTrackTimeline() {
- const character = getDefaultCharacter();
- useDirectorStore.getState().selectObject(character.id);
+ // 用「几何体」造物体轨道:角色只应有角色轨道,不应再被渲染成一条物体轨道
+ useDirectorStore.getState().addGeometryPrimitive("box");
+ const objects = useDirectorStore.getState().project.objects;
+ const object = objects[objects.length - 1];
+ useDirectorStore.getState().selectObject(object.id);
usePlaybackStore.getState().setPlayhead(1);
const rendered = render();
@@ -392,14 +490,16 @@ function renderTwoTrackTimeline() {
const container = rendered.container;
const objectRow = container.querySelector(".timeline-grid-body > .timeline-track")!;
+ const character = getDefaultCharacter();
const characterRow = container.querySelector(".timeline-character-track")!;
stubTrackRowLayout({
- [`object:${character.id}`]: { top: 0, height: 28 },
+ [`object:${object.id}`]: { top: 0, height: 28 },
[`character:${character.id}`]: { top: 28, height: 84 },
});
return {
+ object,
character,
container,
objectRow,
@@ -416,8 +516,8 @@ function beginTrackDrag(row: HTMLElement, clientY = 10) {
}
it("reorders tracks by dragging a whole track row", () => {
- const { character, objectRow, characterRow, panel, indicator } = renderTwoTrackTimeline();
- const objectTrackKey = `object:${character.id}`;
+ const { object, character, objectRow, characterRow, panel, indicator } = renderTwoTrackTimeline();
+ const objectTrackKey = `object:${object.id}`;
const characterTrackKey = `character:${character.id}`;
// 默认顺序:物体轨道在前,角色轨道在后
@@ -480,8 +580,8 @@ it("keeps repeating pointermove at the same drop position idempotent", () => {
});
it("does not start a track drag from the collapse button", () => {
- const { character, objectRow, panel } = renderTwoTrackTimeline();
- const collapseButton = screen.getByRole("button", { name: `折叠物体轨道(${character.name})` });
+ const { object, objectRow, panel } = renderTwoTrackTimeline();
+ const collapseButton = screen.getByRole("button", { name: `折叠物体轨道(${object.name})` });
fireEvent.pointerDown(collapseButton, { button: 0, pointerId: 1, clientX: 10, clientY: 10 });
fireEvent.pointerMove(collapseButton, { pointerId: 1, clientX: 10, clientY: 90 });
@@ -497,40 +597,120 @@ it("renders a trajectory bar whose right handle shows the walk duration", () =>
const { container } = render();
- // 2 个轨迹点在 5s 时间轴上均分 → 走到 5s
- const handle = screen.getByRole("slider", { name: `${character.name} 轨迹时长` });
+ // 2 个轨迹点在 5s 时间轴上均分 → 走到 5s;停留结束默认就是 5s
+ const handle = screen.getByRole("slider", { name: `${character.name} 轨迹停留时长` });
expect(handle).toHaveAttribute("aria-valuenow", "5");
expect(container.querySelector(".timeline-trajectory-bar")?.textContent).toBe("5.0s");
- // 条宽度 = 走完时间占整条时间轴的比例
+ // 条宽度 = 停留结束占整条时间轴的比例
expect(container.querySelector(".timeline-trajectory-bar")?.style.width).toBe("100%");
});
-it("drags the duration handle to rescale every trajectory point", () => {
+it("drags the duration handle to change the trajectory hold duration", () => {
const character = getDefaultCharacter();
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]);
+ // 最后一点落在 3s,停留结束仍默认到 5s,留出 2s 停留段
+ useDirectorStore.getState().setTrajectoryPointTimes(character.id, [1.5, 3]);
render();
// 5s 时间轴对应 500px 轨道 → 1px = 0.01s
stubTrackRowLayout({}, 500);
- const handle = screen.getByRole("slider", { name: `${character.name} 轨迹时长` });
- fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 400 });
+ const handle = screen.getByRole("slider", { name: `${character.name} 轨迹停留时长` });
+ fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 500 });
// 拖轨迹时冻结全站毛玻璃,避免背后 WebGL 画布反复重算模糊
expect(document.body).toHaveClass("is-timeline-dragging");
- // 往左拖 100px → 时长 5s → 4s,两个点按比例缩到 2s / 4s
- fireEvent.pointerMove(handle, { pointerId: 1, clientX: 300 });
- fireEvent.pointerUp(handle, { pointerId: 1, clientX: 300 });
+ // 把手在条右端(5s / 100%);往左拖 100px = -1s → 停留结束 4s,各轨迹点不动
+ fireEvent.pointerMove(handle, { pointerId: 1, clientX: 400 });
+ fireEvent.pointerUp(handle, { pointerId: 1, clientX: 400 });
const points = getTrajectoryPoints(character.id);
- expect(points[0].time).toBeCloseTo(2, 5);
- expect(points[1].time).toBeCloseTo(4, 5);
+ expect(points[0].time).toBeCloseTo(1.5, 5);
+ expect(points[1].time).toBeCloseTo(3, 5);
+ const obj = useDirectorStore.getState().project.objects.find((item) => item.id === character.id)!;
+ expect(obj.trajectoryEndTime).toBeCloseTo(4, 5);
expect(document.body).not.toHaveClass("is-timeline-dragging");
// 拖滑块不应被当成「轨道拖动排序」
expect(useDirectorStore.getState().timelineTrackOrder).toEqual([]);
});
+it("drags the left trim handle to delay the clip start without moving the points", () => {
+ const character = getDefaultCharacter();
+ const objectId = character.id;
+ useDirectorStore.getState().addTrajectoryPoint(objectId, [1, 0, 0]);
+ useDirectorStore.getState().addTrajectoryPoint(objectId, [2, 0, 0]);
+ // 两个点落在 1.5s / 3s,片段起点默认 0s,左边留出可修剪的空间
+ useDirectorStore.getState().setTrajectoryPointTimes(objectId, [1.5, 3]);
+
+ const { container } = render();
+ stubTrackRowLayout({}, 500);
+
+ const bar = container.querySelector(".timeline-character-head-lane .timeline-trajectory-bar")!;
+ // 左右两端各有一条修剪把手
+ expect(bar.querySelectorAll(".timeline-trajectory-trim")).toHaveLength(2);
+
+ const handle = screen.getByRole("slider", { name: `${character.name} 轨迹起点` });
+ fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 0 });
+ // 往右拖 100px = +1s → 起点推后到 1s(角色先原地等 1s),两个轨迹点留在原地
+ fireEvent.pointerMove(handle, { pointerId: 1, clientX: 100 });
+ fireEvent.pointerUp(handle, { pointerId: 1, clientX: 100 });
+
+ const obj = useDirectorStore.getState().project.objects.find((item) => item.id === objectId)!;
+ expect(obj.trajectoryStartTime).toBeCloseTo(1, 5);
+ const points = getTrajectoryPoints(objectId);
+ expect(points[0].time).toBeCloseTo(1.5, 5);
+ expect(points[1].time).toBeCloseTo(3, 5);
+ // 停留结束不受影响,仍是时间轴末尾
+ expect(obj.trajectoryEndTime).toBeUndefined();
+ // 拖把手不应被当成「轨道拖动排序」
+ expect(useDirectorStore.getState().timelineTrackOrder).toEqual([]);
+});
+
+it("clamps the trim-start handle so it cannot overrun the first trajectory point", () => {
+ const character = getDefaultCharacter();
+ const objectId = character.id;
+ useDirectorStore.getState().addTrajectoryPoint(objectId, [1, 0, 0]);
+ useDirectorStore.getState().addTrajectoryPoint(objectId, [2, 0, 0]);
+ useDirectorStore.getState().setTrajectoryPointTimes(objectId, [1.5, 3]);
+
+ render();
+ stubTrackRowLayout({}, 500);
+
+ const handle = screen.getByRole("slider", { name: `${character.name} 轨迹起点` });
+ fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 0 });
+ // 一路拖过第一个点也不让它越过去:最多到 1.45s(第一个点前留一个最小间隔)
+ fireEvent.pointerMove(handle, { pointerId: 1, clientX: 400 });
+ fireEvent.pointerUp(handle, { pointerId: 1, clientX: 400 });
+
+ const obj = useDirectorStore.getState().project.objects.find((item) => item.id === objectId)!;
+ expect(obj.trajectoryStartTime).toBeCloseTo(1.45, 5);
+ const points = getTrajectoryPoints(objectId);
+ expect(points[0].time).toBeCloseTo(1.5, 5);
+ expect(points[1].time).toBeCloseTo(3, 5);
+});
+
+it("nudges the trim-start handle with the keyboard", () => {
+ const character = getDefaultCharacter();
+ const objectId = character.id;
+ useDirectorStore.getState().addTrajectoryPoint(objectId, [1, 0, 0]);
+ useDirectorStore.getState().addTrajectoryPoint(objectId, [2, 0, 0]);
+ useDirectorStore.getState().setTrajectoryPointTimes(objectId, [1.5, 3]);
+ useDirectorStore.getState().setTrajectoryClipTrimStart(objectId, 1);
+
+ render();
+
+ const handle = screen.getByRole("slider", { name: `${character.name} 轨迹起点` });
+ fireEvent.keyDown(handle, { key: "ArrowLeft" });
+
+ const obj = useDirectorStore.getState().project.objects.find((item) => item.id === objectId)!;
+ // 1s → 0.9s,各轨迹点不动
+ expect(obj.trajectoryStartTime).toBeCloseTo(0.9, 5);
+ const points = getTrajectoryPoints(objectId);
+ expect(points[0].time).toBeCloseTo(1.5, 5);
+ expect(points[1].time).toBeCloseTo(3, 5);
+});
+
it("drags a single trajectory keyframe to change only its own time", () => {
const character = getDefaultCharacter();
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
@@ -553,20 +733,23 @@ it("drags a single trajectory keyframe to change only its own time", () => {
expect(screen.getByRole("button", { name: "轨迹关键帧 3.0s" })).toBeInTheDocument();
});
-it("nudges the trajectory duration with the keyboard", () => {
+it("nudges the trajectory hold duration with the keyboard", () => {
const character = getDefaultCharacter();
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]);
+ useDirectorStore.getState().setTrajectoryPointTimes(character.id, [1.5, 3]);
render();
- const handle = screen.getByRole("slider", { name: `${character.name} 轨迹时长` });
+ const handle = screen.getByRole("slider", { name: `${character.name} 轨迹停留时长` });
fireEvent.keyDown(handle, { key: "ArrowLeft" });
const points = getTrajectoryPoints(character.id);
- // 时长 5s → 4.9s,各点等比缩放
- expect(points[1].time).toBeCloseTo(4.9, 5);
- expect(points[0].time).toBeCloseTo(2.45, 5);
+ // 停留结束 5s → 4.9s,各轨迹点不动
+ expect(points[1].time).toBeCloseTo(3, 5);
+ expect(points[0].time).toBeCloseTo(1.5, 5);
+ const obj = useDirectorStore.getState().project.objects.find((item) => item.id === character.id)!;
+ expect(obj.trajectoryEndTime).toBeCloseTo(4.9, 5);
});
it("collapses and expands a character track", () => {
@@ -579,7 +762,7 @@ it("collapses and expands a character track", () => {
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 个轨迹点");
@@ -595,20 +778,22 @@ it("collapses and expands a character track", () => {
});
it("gives every track a collapse toggle, even when it has no sub-tracks", () => {
- const character = getDefaultCharacter();
- useDirectorStore.getState().selectObject(character.id);
+ useDirectorStore.getState().addGeometryPrimitive("box");
+ const objects = useDirectorStore.getState().project.objects;
+ const object = objects[objects.length - 1];
+ useDirectorStore.getState().selectObject(object.id);
usePlaybackStore.getState().setPlayhead(1);
const { container } = render();
fireEvent.click(screen.getByRole("button", { name: /在指针处打关键帧/ }));
// 物体轨道没有子轨道,同样带折叠开关
- fireEvent.click(screen.getByRole("button", { name: `折叠物体轨道(${character.name})` }));
+ fireEvent.click(screen.getByRole("button", { name: `折叠物体轨道(${object.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}`]);
+ expect(useDirectorStore.getState().timelineCollapsedTracks).toEqual([`object:${object.id}`]);
});
it("gives the camera track a collapse toggle as well", () => {
@@ -700,6 +885,64 @@ it("drags the camera clip handle to rescale the whole camera move", () => {
expect(document.body).not.toHaveClass("is-timeline-dragging");
});
+it("drags the camera clip left handle to trim the start while the last keyframe stays put", () => {
+ const camera = useDirectorStore.getState().project.cameras[0];
+ addCameraKeyframeAt(camera.id, 1);
+ addCameraKeyframeAt(camera.id, 4);
+
+ const { container } = render();
+ stubTrackRowLayout({}, 500);
+
+ const bar = container.querySelector(".timeline-clip-bar-camera")!;
+ // 左右两端各有一条修剪把手
+ expect(bar.querySelectorAll(".timeline-trajectory-trim")).toHaveLength(2);
+
+ const handle = screen.getByRole("slider", { name: `${camera.name} 运镜起点` });
+ fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 100 });
+ // 往右拖 100px = +1s → 运镜推迟到 2s 才开始,末帧仍停在 4s
+ fireEvent.pointerMove(handle, { pointerId: 1, clientX: 200 });
+ fireEvent.pointerUp(handle, { pointerId: 1, clientX: 200 });
+
+ const times = getCameraKeyframeTimes(camera.id);
+ expect(times[0]).toBeCloseTo(2, 5);
+ expect(times[1]).toBeCloseTo(4, 5);
+});
+
+it("clamps the camera trim-start handle so it cannot overrun the last keyframe", () => {
+ const camera = useDirectorStore.getState().project.cameras[0];
+ addCameraKeyframeAt(camera.id, 1);
+ addCameraKeyframeAt(camera.id, 4);
+
+ render();
+ stubTrackRowLayout({}, 500);
+
+ const handle = screen.getByRole("slider", { name: `${camera.name} 运镜起点` });
+ fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 100 });
+ // 一路拖到末帧之后也不越过去:最多到 3.95s(末帧前留一个最小间隔)
+ fireEvent.pointerMove(handle, { pointerId: 1, clientX: 500 });
+ fireEvent.pointerUp(handle, { pointerId: 1, clientX: 500 });
+
+ const times = getCameraKeyframeTimes(camera.id);
+ expect(times[0]).toBeCloseTo(3.95, 5);
+ expect(times[1]).toBeCloseTo(4, 5);
+});
+
+it("nudges the camera clip start with the arrow keys", () => {
+ const camera = useDirectorStore.getState().project.cameras[0];
+ addCameraKeyframeAt(camera.id, 1);
+ addCameraKeyframeAt(camera.id, 4);
+
+ render();
+
+ const handle = screen.getByRole("slider", { name: `${camera.name} 运镜起点` });
+ fireEvent.keyDown(handle, { key: "ArrowRight" });
+
+ const times = getCameraKeyframeTimes(camera.id);
+ // 起点 1s → 1.1s,末帧不动
+ expect(times[0]).toBeCloseTo(1.1, 5);
+ expect(times[1]).toBeCloseTo(4, 5);
+});
+
it("nudges the camera clip duration with the arrow keys", () => {
const camera = useDirectorStore.getState().project.cameras[0];
addCameraKeyframeAt(camera.id, 1);
@@ -764,9 +1007,10 @@ it("drags the trajectory bar body to move the whole walk later", () => {
const { container } = render();
stubTrackRowLayout({}, 500);
- const bar = container.querySelector(".timeline-trajectory-lane .timeline-trajectory-bar")!;
+ const bar = container.querySelector(".timeline-character-head-lane .timeline-trajectory-bar")!;
expect(bar.style.left).toBe("0%");
- expect(bar.style.width).toBe("60%");
+ // 停留结束默认到时间轴末尾(5s),所以整条条铺满 100%,最后一个点(3s)只是途经点
+ expect(bar.style.width).toBe("100%");
// 往右拖 100px = +1s:整段刚性平移,两个点一起 +1s
fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 });
@@ -787,7 +1031,7 @@ it("keeps the trajectory clip inside the timeline when dragged to the end", () =
const { container } = render();
stubTrackRowLayout({}, 500);
- const bar = container.querySelector(".timeline-trajectory-lane .timeline-trajectory-bar")!;
+ const bar = container.querySelector(".timeline-character-head-lane .timeline-trajectory-bar")!;
fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 });
// 往右拖 1000px 远超时间轴:末点顶到 5s 就不再挪
fireEvent.pointerMove(bar, { pointerId: 1, clientX: 1100 });
@@ -806,7 +1050,7 @@ it("does not move a trajectory that already fills the whole timeline", () => {
const { container } = render();
stubTrackRowLayout({}, 500);
- const bar = container.querySelector(".timeline-trajectory-lane .timeline-trajectory-bar")!;
+ const bar = container.querySelector(".timeline-character-head-lane .timeline-trajectory-bar")!;
expect(bar.style.width).toBe("100%");
fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 });
@@ -869,7 +1113,7 @@ it("selects the trajectory clip when it is grabbed, and clears it on an empty ar
const { container } = render();
stubTrackRowLayout({}, 500);
- const bar = container.querySelector(".timeline-trajectory-lane .timeline-trajectory-bar")!;
+ const bar = container.querySelector(".timeline-character-head-lane .timeline-trajectory-bar")!;
expect(bar).not.toHaveClass("is-selected");
fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 });
@@ -883,7 +1127,7 @@ it("selects the trajectory clip when it is grabbed, and clears it on an empty ar
clientX: 5,
});
- expect(container.querySelector(".timeline-trajectory-lane .timeline-trajectory-bar")).not.toHaveClass("is-selected");
+ expect(container.querySelector(".timeline-character-head-lane .timeline-trajectory-bar")).not.toHaveClass("is-selected");
});
it("selects the clip when one of its own keyframes is grabbed", () => {
@@ -899,7 +1143,7 @@ it("selects the clip when one of its own keyframes is grabbed", () => {
fireEvent.pointerDown(keyframe, { button: 0, pointerId: 1, clientX: 150 });
fireEvent.pointerUp(keyframe, { pointerId: 1, clientX: 150 });
- expect(container.querySelector(".timeline-trajectory-lane .timeline-trajectory-bar")).toHaveClass("is-selected");
+ expect(container.querySelector(".timeline-character-head-lane .timeline-trajectory-bar")).toHaveClass("is-selected");
});
it("selects the camera clip both when its body is grabbed and when its keyframe is grabbed", () => {
@@ -1126,8 +1370,9 @@ it("nudges the playhead with the ruler keyboard controls", () => {
});
it("positions the playhead on the ruler and labels every track with its object", () => {
- const objectId = useDirectorStore.getState().project.objects[0].id;
- const objectName = useDirectorStore.getState().project.objects[0].name;
+ const propObject = addPropObject();
+ const objectId = propObject.id;
+ const objectName = propObject.name;
useDirectorStore.getState().selectObject(objectId);
const { container } = render();
diff --git a/src/editor/panels/TimelinePanel.tsx b/src/editor/panels/TimelinePanel.tsx
index d205b93..8f84779 100644
--- a/src/editor/panels/TimelinePanel.tsx
+++ b/src/editor/panels/TimelinePanel.tsx
@@ -40,12 +40,15 @@ import {
moveCameraKeyframeTime,
normalizeTimelineDuration,
normalizeTimelineFps,
+ scaleCameraKeyframeStart,
scaleCameraKeyframeTimes,
type CameraKeyframeValue,
type KeyframeTransformValue,
} from "../schema/timeline";
import type { MotionKind } from "../schema/directorProject";
import {
+ TRAJECTORY_POINT_MIN_GAP,
+ getTrajectoryClipEndTime,
getTrajectoryPointTimes,
getTrajectoryStartTime,
moveTrajectoryPointTime,
@@ -318,6 +321,8 @@ export function TimelinePanel() {
const updateTrajectoryPoint = useDirectorStore((state) => state.updateTrajectoryPoint);
const setTrajectoryPointTimes = useDirectorStore((state) => state.setTrajectoryPointTimes);
const setTrajectoryClipStart = useDirectorStore((state) => state.setTrajectoryClipStart);
+ const setTrajectoryClipTrimStart = useDirectorStore((state) => state.setTrajectoryClipTrimStart);
+ const setTrajectoryClipEnd = useDirectorStore((state) => state.setTrajectoryClipEnd);
const timelineTrackOrder = useDirectorStore((state) => state.timelineTrackOrder);
const timelineCollapsedTracks = useDirectorStore((state) => state.timelineCollapsedTracks);
const setTimelineTrackOrder = useDirectorStore((state) => state.setTimelineTrackOrder);
@@ -368,15 +373,18 @@ export function TimelinePanel() {
* - point:拖单个关键帧,只改这一帧的时间
* - duration:拖条右端把手,整段时长按比例缩放
* - clip:拖条本体,整段左右平移(可以脱离 0s 往后放)
+ * - trimStart:拖条左端把手,只挪片段起点(各轨迹点原地不动)
*/
const timeDragRef = useRef<{
pointerId: number;
owner: TimelineTimeDragOwner;
- kind: "point" | "duration" | "clip";
+ kind: "point" | "duration" | "clip" | "trimStart";
index: number;
startTimes: number[];
/** 片段左端在按下时的位置(平移基准) */
startEdge: number;
+ /** 片段右端在按下时的位置(轨迹最后一点停留结束,作为拖关键帧的上界) */
+ endEdge: number;
startClientX: number;
secondsPerPixel: number;
} | null>(null);
@@ -439,6 +447,30 @@ export function TimelinePanel() {
};
}, [exportMenuOpen]);
+ // 轨迹关键帧没有独立的删除按钮,选中态(.is-selected)就是「按 Delete 会删掉它」的唯一提示,
+ // 所以点到别处(轨道空白、别的轨道、面板其它区域,甚至时间轴之外)就必须取消选中并移走焦点,
+ // 否则选中高亮和焦点框会一直留在那颗关键帧上,看起来像还能按 Delete 删它。
+ useEffect(() => {
+ if (!selectedTrajectoryPoint) return;
+
+ function clearTrajectoryPointOnOutsidePointerDown(event: PointerEvent) {
+ // 点在轨迹关键帧自己身上不算「别处」——那一下是选中它 / 拖它改时间
+ if (event.target instanceof Element && event.target.closest(".timeline-keyframe-trajectory")) return;
+
+ selectTrajectoryPoint(null, null);
+
+ // 焦点还停在那颗关键帧上时一并移走,避免焦点框留在一个已经没选中的元素上
+ const active = document.activeElement;
+ if (active instanceof HTMLElement && active.classList.contains("timeline-keyframe-trajectory")) {
+ active.blur();
+ }
+ }
+
+ document.addEventListener("pointerdown", clearTrajectoryPointOnOutsidePointerDown);
+
+ return () => document.removeEventListener("pointerdown", clearTrajectoryPointOnOutsidePointerDown);
+ }, [selectedTrajectoryPoint, selectTrajectoryPoint]);
+
// 设置弹窗:Esc 直接关掉(等同取消,草稿值丢弃)
useEffect(() => {
if (!settingsOpen) return;
@@ -484,7 +516,9 @@ export function TimelinePanel() {
if (selectionIsManualCamera) {
if (!selectedShot || selectedShot.targetMode !== "manual") return;
addCameraKeyframe(selectedShot.id, playhead, snapshotShot());
- } else if (selectedObject) {
+ } else if (selectedObject && selectedObject.kind !== "character") {
+ // 角色由轨迹(trajectory)驱动,不占 transform 关键帧轨道,
+ // 否则同一个角色会同时出现「物体轨道」和「角色轨道」两条。
addKeyframe(selectedObject.id, playhead, snapshotObjectTransform());
}
}
@@ -511,6 +545,10 @@ export function TimelinePanel() {
}
}
function handleDeleteSelected() {
+ if (selectedTrajectoryPoint) {
+ removeTrajectoryPoint(selectedTrajectoryPoint.objectId, selectedTrajectoryPoint.pointId);
+ return;
+ }
if (!selectedKeyframe) return;
if (selectedKeyframe.kind === "object") {
removeKeyframe(selectedKeyframe.targetId, selectedKeyframe.keyframeId);
@@ -655,8 +693,15 @@ export function TimelinePanel() {
// 轨道顺序:默认「物体轨道 → 相机运镜轨道 → 人物轨道」,用户可以上移 / 下移调整。
// 视觉顺序靠 flex order 实现,DOM 顺序保持稳定(不影响既有查询与无障碍阅读顺序)。
+ // 角色由轨迹驱动,只走「角色轨道」;其 transform 关键帧不单独占一条「物体轨道」,
+ // 否则同一个角色会在时间轴上出现两条。
+ const nonCharacterObjectIds = new Set(
+ project.objects.filter((item) => item.kind !== "character").map((item) => item.id)
+ );
const defaultTrackKeys = [
- ...timeline.tracks.map((track) => getTimelineTrackKey("object", track.objectId)),
+ ...timeline.tracks
+ .filter((track) => nonCharacterObjectIds.has(track.objectId))
+ .map((track) => getTimelineTrackKey("object", track.objectId)),
...timeline.cameraTracks.map((track) => getTimelineTrackKey("camera", track.cameraId)),
...characterObjects.map((item) => getTimelineTrackKey("character", item.id)),
];
@@ -788,38 +833,40 @@ export function TimelinePanel() {
return selectedClip === getTimelineClipKey(kind, id);
}
- /** 拖整段时长(条右端把手 / 键盘微调):末帧落到 newEnd,其余关键帧按比例缩放,片段起点固定 */
+ /** 拖整段时长(条右端把手 / 键盘微调):
+ * - 运镜:末帧落到 newEnd,其余关键帧按比例缩放,片段起点固定;
+ * - 人物轨迹:末点不再等于时间结束位置,这条把手改的是「停留时长」(角色走完最后一点后
+ * 原地停留到这一刻),所以只写 trajectoryEndTime,不动各轨迹点的时间。 */
function scaleClipDuration(
owner: TimelineTimeDragOwner,
times: number[],
startEdge: number,
deltaSeconds: number
) {
- const targetEnd = (times[times.length - 1] ?? startEdge) + deltaSeconds;
+ const targetEnd =
+ (owner.kind === "trajectory" ? startEdge : times[times.length - 1] ?? startEdge) + deltaSeconds;
if (owner.kind === "trajectory") {
- setTrajectoryPointTimes(
- owner.objectId,
- scaleTrajectoryPointTimes(times, targetEnd, duration, startEdge)
- );
+ setTrajectoryClipEnd(owner.objectId, targetEnd);
return;
}
setCameraKeyframeTimes(owner.cameraId, scaleCameraKeyframeTimes(times, targetEnd, duration));
}
- /** 拖单个关键帧:只改它自己的时间(被前后邻居夹住) */
+ /** 拖单个关键帧:只改它自己的时间(被前后邻居夹住;轨迹最后一点还受停留结束时间约束) */
function moveClipKeyframe(
owner: TimelineTimeDragOwner,
times: number[],
index: number,
startEdge: number,
- deltaSeconds: number
+ deltaSeconds: number,
+ endEdge = duration
) {
const targetTime = (times[index] ?? 0) + deltaSeconds;
if (owner.kind === "trajectory") {
- const nextTimes = moveTrajectoryPointTime(times, index, targetTime, duration, startEdge);
+ const nextTimes = moveTrajectoryPointTime(times, index, targetTime, duration, startEdge, endEdge);
updateTrajectoryPoint(owner.objectId, owner.pointId, { time: nextTimes[index] });
return;
}
@@ -852,6 +899,29 @@ export function TimelinePanel() {
setCameraKeyframeTimes(owner.cameraId, times.map((time) => time + shift));
}
+ /**
+ * 修剪起点(条左端把手),和右端把手对称:
+ * - 人物轨迹:只挪片段起点 —— 角色在这之前原地等待,各轨迹点与停留结束时间留在原地,
+ * 所以「等待多久」变了,走位本身的节奏没变;
+ * - 运镜:末帧固定不动,首帧推到新位置,其余关键帧按比例缩放 —— 和右端把手(首帧固定、
+ * 末帧伸缩)正好反过来,整段运镜的节奏保持不变。
+ */
+ function trimClipStart(
+ owner: TimelineTimeDragOwner,
+ times: number[],
+ startEdge: number,
+ deltaSeconds: number
+ ) {
+ if (owner.kind === "camera") {
+ setCameraKeyframeTimes(owner.cameraId, scaleCameraKeyframeStart(times, startEdge + deltaSeconds, duration));
+ return;
+ }
+
+ if (owner.kind !== "trajectory") return;
+
+ setTrajectoryClipTrimStart(owner.objectId, startEdge + deltaSeconds);
+ }
+
/**
* 时间滑块(人物轨迹条 / 运镜片段条同构):拖动关键帧改「这一帧的时间」,
* 拖动条右端把手改「整段时长」(其余帧按比例缩放)。像素 → 秒用轨道宽度换算,宽度只在按下时量一次。
@@ -859,10 +929,11 @@ export function TimelinePanel() {
function beginTimeDrag(
event: ReactPointerEvent,
owner: TimelineTimeDragOwner,
- kind: "point" | "duration" | "clip",
+ kind: "point" | "duration" | "clip" | "trimStart",
index: number,
times: number[],
- startEdge = 0
+ startEdge = 0,
+ endEdge = duration
) {
if (event.button !== 0) return;
if (isTrackDragBlockedTarget(event.target)) return;
@@ -884,6 +955,7 @@ export function TimelinePanel() {
index,
startTimes: [...times],
startEdge,
+ endEdge,
startClientX: event.clientX,
secondsPerPixel: duration / laneWidth,
};
@@ -917,7 +989,12 @@ export function TimelinePanel() {
return;
}
- moveClipKeyframe(drag.owner, drag.startTimes, drag.index, drag.startEdge, deltaTime);
+ if (drag.kind === "trimStart") {
+ trimClipStart(drag.owner, drag.startTimes, drag.startEdge, deltaTime);
+ return;
+ }
+
+ moveClipKeyframe(drag.owner, drag.startTimes, drag.index, drag.startEdge, deltaTime, drag.endEdge);
}
function handleTimeDragEnd(event: ReactPointerEvent) {
@@ -937,14 +1014,15 @@ export function TimelinePanel() {
/** 把某个关键帧 / 时长把手 / 条本体接到同一套拖拽手势上 */
function getTimeDragProps(
owner: TimelineTimeDragOwner,
- kind: "point" | "duration" | "clip",
+ kind: "point" | "duration" | "clip" | "trimStart",
index: number,
times: number[],
- startEdge = 0
+ startEdge = 0,
+ endEdge = duration
) {
return {
onPointerDown: (event: ReactPointerEvent) =>
- beginTimeDrag(event, owner, kind, index, times, startEdge),
+ beginTimeDrag(event, owner, kind, index, times, startEdge, endEdge),
onPointerMove: handleTimeDragMove,
onPointerUp: handleTimeDragEnd,
onPointerCancel: handleTimeDragEnd,
@@ -966,6 +1044,21 @@ export function TimelinePanel() {
scaleClipDuration(owner, times, startEdge, delta);
}
+ /** 修剪起点的把手同样支持键盘微调(左右方向键 ±0.1s,按住 Shift ±0.5s) */
+ function handleTrimStartKeyDown(
+ event: ReactKeyboardEvent,
+ owner: TimelineTimeDragOwner,
+ times: number[],
+ startEdge: number
+ ) {
+ const step = event.shiftKey ? 0.5 : 0.1;
+ const delta = event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0;
+ if (delta === 0) return;
+
+ event.preventDefault();
+ trimClipStart(owner, times, startEdge, delta);
+ }
+
function handleTrackPointerMove(event: ReactPointerEvent) {
const gesture = dragGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
@@ -1110,14 +1203,27 @@ export function TimelinePanel() {
action();
}
- // 焦点在时间轴上时,空格键切换播放 / 暂停(正在播放则暂停,暂停或停止则播放)。
- // 焦点落在原生可激活控件(按钮 / 输入框等)上时交给控件自身处理,避免与它们的空格行为重复触发。
+ // 焦点在时间轴上时:
+ // - 空格键切换播放 / 暂停(正在播放则暂停,暂停或停止则播放);
+ // - Delete / Backspace 删除选中的轨迹点(角色轨迹点没有独立删除按钮,选中后按删除键移除)。
+ // 焦点落在原生可激活控件(按钮 / 输入框等)上时交给控件自身处理,避免与它们的行为重复触发。
function handlePanelKeyDown(event: ReactKeyboardEvent) {
- if (event.key !== " " && event.key !== "Spacebar") return;
const target = event.target as HTMLElement | null;
- if (target && target.closest("button, input, textarea, select, [contenteditable='true']")) {
+ const insideNativeControl = !!(
+ target && target.closest("button, input, textarea, select, [contenteditable='true']")
+ );
+
+ if (event.key === "Delete" || event.key === "Backspace") {
+ if (insideNativeControl) return;
+ if (selectedTrajectoryPoint) {
+ event.preventDefault();
+ handleDeleteSelected();
+ }
return;
}
+
+ if (event.key !== " " && event.key !== "Spacebar") return;
+ if (insideNativeControl) return;
event.preventDefault();
togglePlaying();
}
@@ -1387,7 +1493,9 @@ export function TimelinePanel() {
{/* tabIndex 让整块轨道区可聚焦:点轨道空白处后焦点会落进面板,
这样「焦点在轨道上时空格切换播放/暂停」才能生效(否则焦点在面板外,空格够不到面板级的键盘处理) */}
- {timeline.tracks.map((track) => {
+ {timeline.tracks
+ .filter((track) => project.objects.find((o) => o.id === track.objectId)?.kind !== "character")
+ .map((track) => {
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;
@@ -1432,17 +1540,6 @@ export function TimelinePanel() {
onClick={() => handleSelectObjectKeyframe(track.objectId, kf.id, kf.time, kf.value)}
>
-
);
})
@@ -1467,10 +1564,15 @@ export function TimelinePanel() {
const clipLeftPercent = duration > 0 ? (clipStart / duration) * 100 : 0;
const clipWidthPercent = duration > 0 ? (clipDuration / duration) * 100 : 0;
const clipOwner: TimelineTimeDragOwner = { kind: "camera", cameraId: track.cameraId };
+ // 运镜关键帧连线:在「机位」子轨道上把各关键帧用折线连起来(视图坐标 0~100 对应整条时间轴宽度,
+ // 50 = 轨道垂直中点),直观呈现运镜过程
+ const cameraConnectorPoints = cameraTimes
+ .map((t) => `${(duration > 0 ? (t / duration) * 100 : 0).toFixed(2)},50`)
+ .join(" ");
return (
-
- {renderCollapseToggle(trackKey, trackName, "运镜轨道", collapsed)}
-
-
{trackName}
-
{track.keyframes.length}
+
+
+ {renderCollapseToggle(trackKey, trackName, "运镜轨道", collapsed)}
+
+ {trackName}
+ {track.keyframes.length}
+
+ {/* 运镜轨道头部车道:展开时放「运镜片段条」,折叠时放一行摘要(与角色轨道头部一致) */}
+
+ {collapsed ? (
+
{track.keyframes.length} 个运镜关键帧
+ ) : cameraTimes.length >= 2 && duration > 0 ? (
+
+ {formatTime(clipDuration)}
+ {/* 左端把手 = 修剪起点:末帧不动,首帧推后,各关键帧按比例缩放 */}
+ handleTrimStartKeyDown(event, clipOwner, cameraTimes, clipStart)}
+ {...getTimeDragProps(clipOwner, "trimStart", 0, cameraTimes, clipStart)}
+ />
+ {/* 右端把手 = 修剪终点:首帧不动,末帧伸缩 */}
+
+ handleTimeHandleKeyDown(event, clipOwner, cameraTimes, clipStart)
+ }
+ {...getTimeDragProps(
+ clipOwner,
+ "duration",
+ cameraTimes.length - 1,
+ cameraTimes,
+ clipStart
+ )}
+ />
+
+ ) : null}
+
-
- {collapsed ? (
-
{track.keyframes.length} 个运镜关键帧
- ) : (
- <>
- {/* 运镜片段条:拖条本体可整段左右平移(前面那段时间相机保持首帧机位),
- 拖右端把手整体缩放这段运镜的时长;只有一个关键帧时没有「运镜过程」,不画条。 */}
- {cameraTimes.length >= 2 && duration > 0 ? (
-
- {formatTime(clipDuration)}
-
- handleTimeHandleKeyDown(event, clipOwner, cameraTimes, clipStart)
- }
- {...getTimeDragProps(
- clipOwner,
- "duration",
- cameraTimes.length - 1,
- cameraTimes,
- clipStart
- )}
- />
-
- ) : null}
- {track.keyframes.map((kf, index) => {
- const leftPercent = duration === 0 ? 0 : (kf.time / duration) * 100;
- const isSelected =
- selectedKeyframe?.kind === "camera" &&
- selectedKeyframe.targetId === track.cameraId &&
- selectedKeyframe.keyframeId === kf.id;
- return (
-
handleSelectCameraKeyframe(track.cameraId, kf.id, kf.time, kf.value)}
- {...getTimeDragProps(clipOwner, "point", index, cameraTimes)}
- >
-
-
+
+ >
+ )}
);
})}
@@ -1580,11 +1716,18 @@ export function TimelinePanel() {
const trackKey = getTimelineTrackKey("character", character.id);
const collapsed = timelineCollapsedTracks.includes(trackKey);
const dragProps = getTrackDragProps(trackKey);
- // 轨迹片段的起点(拖条本体可整段平移,0 表示从 0s 就走)与每个轨迹点的关键帧时间
+ // 轨迹片段的起点(拖条本体可整段平移,0 表示从 0s 就走)、每个轨迹点的关键帧时间,
+ // 以及片段结束时间(角色走完最后一点后原地停留到这一刻,即「停留时长」)。
+ // 默认结束时间 = 时间轴末尾,所以最后一个点落在时间结束处;一旦设置 trajectoryEndTime,
+ // 最后一个点只是路径中途的途经点,条右边缘到结束时间之间都是停留段。
const trajectoryStart = getTrajectoryStartTime(character, duration);
const trajectoryTimes = getTrajectoryPointTimes(trajectoryPoints, duration, trajectoryStart);
- const trajectoryEndTime =
- trajectoryTimes.length > 0 ? trajectoryTimes[trajectoryTimes.length - 1] : trajectoryStart;
+ const trajectoryEndTime = getTrajectoryClipEndTime(character, duration, trajectoryStart);
+ // 轨迹点连线:在「轨迹」轨道上把各轨迹点用折线连起来(视图坐标 0~100 对应整条时间轴宽度,
+ // 50 = 轨道垂直中点),直观呈现走位路径(最后一个点只是途经点,后面是停留段)
+ const trajectoryConnectorPoints = [trajectoryStart, ...trajectoryTimes]
+ .map((t) => `${(duration > 0 ? (t / duration) * 100 : 0).toFixed(2)},50`)
+ .join(" ");
const trajectoryLeftPercent = duration > 0 ? (trajectoryStart / duration) * 100 : 0;
const trajectoryWidthPercent =
duration > 0 ? Math.min(100, ((trajectoryEndTime - trajectoryStart) / duration) * 100) : 0;
@@ -1613,12 +1756,71 @@ export function TimelinePanel() {
{character.name}
{trajectoryPoints.length}
- {/* 折叠后抬头行里给一句摘要,避免整条轨道变成空白 */}
+ {/* 分组抬头行:展开时显示轨迹片段条,折叠时显示一行摘要 */}
{collapsed ? (
{motionLabel} · {trajectoryPoints.length} 个轨迹点
+ ) : trajectoryTimes.length > 0 && duration > 0 ? (
+
+ {formatTime(trajectoryEndTime)}
+ {/* 左端把手 = 修剪起点:只挪片段起点(角色在此之前原地等待),轨迹点留在原地 */}
+
+ handleTrimStartKeyDown(event, trajectoryOwner, trajectoryTimes, trajectoryStart)
+ }
+ {...getTimeDragProps(trajectoryOwner, "trimStart", 0, trajectoryTimes, trajectoryStart)}
+ />
+ {/* 右端把手 = 修剪停留终点:角色走完最后一点后停留到这一刻 */}
+
+ handleTimeHandleKeyDown(event, trajectoryOwner, trajectoryTimes, trajectoryEndTime)
+ }
+ {...getTimeDragProps(
+ trajectoryOwner,
+ "duration",
+ trajectoryTimes.length - 1,
+ trajectoryTimes,
+ trajectoryEndTime
+ )}
+ />
+
) : null}
@@ -1636,57 +1838,25 @@ export function TimelinePanel() {