diff --git a/src/editor/canvas/PlaybackTransformSync.tsx b/src/editor/canvas/PlaybackTransformSync.tsx index 952cf6a..d7eb296 100644 --- a/src/editor/canvas/PlaybackTransformSync.tsx +++ b/src/editor/canvas/PlaybackTransformSync.tsx @@ -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; diff --git a/src/editor/canvas/SceneRoot.test.tsx b/src/editor/canvas/SceneRoot.test.tsx index 5c3105b..bc19386 100644 --- a/src/editor/canvas/SceneRoot.test.tsx +++ b/src/editor/canvas/SceneRoot.test.tsx @@ -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(); + + 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 diff --git a/src/editor/canvas/SceneRoot.tsx b/src/editor/canvas/SceneRoot.tsx index 3c2ac8e..93cbde8 100644 --- a/src/editor/canvas/SceneRoot.tsx +++ b/src/editor/canvas/SceneRoot.tsx @@ -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 = ( { @@ -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(null!); const points = owner.trajectoryPoints ?? []; @@ -903,6 +913,14 @@ function TrajectoryPath({ function selectPoint(pointId: string, event: ThreeEvent) { 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)); } /** diff --git a/src/editor/io/videoExport.ts b/src/editor/io/videoExport.ts index 02d6667..bc2ce07 100644 --- a/src/editor/io/videoExport.ts +++ b/src/editor/io/videoExport.ts @@ -176,7 +176,8 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise { 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((resolve) => { recorder.onstop = () => resolve(); @@ -263,7 +262,7 @@ async function exportVideoWithMediaRecorder(view: VideoExportView): Promise { if (!state.isPlaying) { try { @@ -289,7 +288,6 @@ async function exportVideoWithMediaRecorder(view: VideoExportView): Promise item.kind === "character")!; +} + +/** + * jsdom 不做真实布局(所有 getBoundingClientRect 都是 0), + * 轨道拖拽要靠行几何算落点,这里按 data-track-key 造一份给定布局。 + */ +function stubTrackRowLayout(layout: Record) { + 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(); +afterEach(() => { + vi.restoreAllMocks(); +}); + +it("renders play button and the frame/time readout with the total duration on its own", () => { + const { container } = render(); + 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(); + + // 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(); + + 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(); + 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(); + + 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(); + + 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(); + 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(); + 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(); + fireEvent.click(screen.getByRole("button", { name: /在指针处打关键帧/ })); + + const container = rendered.container; + const objectRow = container.querySelector(".timeline-grid-body > .timeline-track")!; + const characterRow = container.querySelector(".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(".timeline-panel")!, + indicator: container.querySelector(".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(); + 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(); + + 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(); + 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(); + 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(); diff --git a/src/editor/panels/TimelinePanel.tsx b/src/editor/panels/TimelinePanel.tsx index d970424..999585c 100644 --- a/src/editor/panels/TimelinePanel.tsx +++ b/src/editor/panels/TimelinePanel.tsx @@ -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 = { prop: Box, }; +/** 人物轨道「动作」子轨道里展示的基础动作名 */ +const MOTION_LABELS: Record = { + 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(); + 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 ( + + ); +} + 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(null); const [isRecording, setIsRecording] = useState(false); const [exportMenuOpen, setExportMenuOpen] = useState(false); + // 轨道拖拽:生命周期(谁在拖 → 高亮 / 毛玻璃降级)走 React state,一次拖拽只重渲染两次; + // 高频的落点线则完全不经过 React(ref + transform),详见 getTrackDragProps。 + const [draggingTrackKey, setDraggingTrackKey] = useState(null); + const dropTargetRef = useRef<{ key: string; position: TimelineTrackDropPosition } | null>(null); + const dropIndicatorRef = useRef(null); + /** 当前这一次指针手势(按下 → 超过阈值才算真的开始拖拽,否则当普通点击放行) */ + const dragGestureRef = useRef<{ + pointerId: number; + sourceKey: string; + active: boolean; + startX: number; + startY: number; + } | null>(null); + /** 拖拽开始时一次性量好的行几何:移动中只查表,绝不在事件里读布局(强制同步布局 = layout thrashing) */ + const trackRectsRef = useRef(new Map()); + const trackLayoutOriginRef = useRef(0); const panelRef = useRef(null); const exportMenuRef = useRef(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(null); + const rulerRectRef = useRef(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) => ( + + ))} + {rulerTicks.map((tick) => ( + + {formatRulerTick(tick.value)} + + ))} + + ), + [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 ( + toggleTimelineTrackCollapsed(trackKey)} + /> + ); + } + + /** 拖拽开始时量一次所有轨道的几何(此后拖拽期间不再读布局) */ + function measureTrackRows() { + const panel = panelRef.current; + const body = panel?.querySelector(".timeline-grid-body"); + trackLayoutOriginRef.current = body ? body.getBoundingClientRect().top : 0; + + const rects = new Map(); + panel?.querySelectorAll("[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, 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) { + 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) { + 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) => 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) { + // 每次开始拖动重新量一次刻度尺(窗口可能刚被缩放) + 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 ( -
+
- - {formatTime(playhead)} / {formatTime(duration)} + {/* 读数 = 当前帧号 / 当前时间;总时长单独放在右边的时长输入框里 */} + + F{playheadFrame} + + {formatReadoutTime(playhead)}
@@ -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 ( -
-
+
+
+ {renderCollapseToggle(trackKey, trackName, "物体轨道", collapsed)}
- {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 ( -
handleSelectObjectKeyframe(track.objectId, kf.id, kf.time, kf.value)} - > -
- ); - })} +
+ ); + }) + )}
); @@ -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 ( -
-
+
+
+ {renderCollapseToggle(trackKey, trackName, "运镜轨道", collapsed)}
- {track.keyframes.map((kf) => { - const leftPercent = duration === 0 ? 0 : (kf.time / duration) * 100; - const isSelected = - selectedKeyframe?.kind === "camera" && - selectedKeyframe.targetId === track.cameraId && - selectedKeyframe.keyframeId === kf.id; - return ( -
handleSelectCameraKeyframe(track.cameraId, kf.id, kf.time, kf.value)} - > -
- ); - })} +
+ ); + }) + )}
); })} + + {characterObjects.map((character) => { + const trajectoryPoints = character.trajectoryPoints ?? []; + const motion = character.characterRig?.motion; + const motionLabel = MOTION_LABELS[motion?.kind ?? "none"]; + const motionDisabled = motion?.enabled === false; + const motionTitle = motionDisabled + ? `动作轨道:${motionLabel}(当前已关闭)` + : `动作轨道:${motionLabel}`; + const trackKey = getTimelineTrackKey("character", character.id); + const collapsed = timelineCollapsedTracks.includes(trackKey); + const dragProps = getTrackDragProps(trackKey); + + return ( +
+ {/* 分组抬头:折叠开关 + 角色名 + 轨迹点数(整组可拖动排序) */} +
+
+ {renderCollapseToggle(trackKey, character.name, "角色轨道", collapsed)} +
+ {/* 折叠后抬头行里给一句摘要,避免整条轨道变成空白 */} +
+ {collapsed ? ( + + {motionLabel} · {trajectoryPoints.length} 个轨迹点 + + ) : null} +
+
+ + {collapsed ? null : ( + <> + {/* 动作轨道:角色当前的基础动作(待机 / 行走 / 跑步) */} +
+
动作
+
+ + {motionLabel} + {motionDisabled ? "(已关闭)" : ""} + +
+
+ + {/* 人物轨迹:轨迹点即关键帧,0s 从角色起始位置出发,末点落在时间轴结束时刻 */} +
+
轨迹
+
+ {trajectoryPoints.map((point, index) => { + const pointTime = getTrajectoryPointTime(index, trajectoryPoints.length, duration); + const leftPercent = duration === 0 ? 0 : (pointTime / duration) * 100; + const isSelected = + selectedTrajectoryPoint?.objectId === character.id && + selectedTrajectoryPoint.pointId === point.id; + + return ( +
handleSelectTrajectoryKeyframe(character.id, point.id, pointTime)} + > +
+ ); + })} +
+
+ + )} +
+ ); + })} + + {/* 拖拽落点线:绝对定位在轨道区上,靠 transform 移动(详见 showDropIndicator) */} +